diff --git a/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiModelsPath.java b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiModelsPath.java new file mode 100644 index 00000000..17eae75a --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/chatmodel/OpenAiModelsPath.java @@ -0,0 +1,50 @@ +package vip.mate.llm.chatmodel; + +import org.springframework.util.StringUtils; + +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Single source of truth for the OpenAI-compatible models-listing path. + * + *

Both the discovery flow ({@code ModelDiscoveryService}) and the failover + * liveness probe ({@code OpenAiCompatibleListModelsProbe}) list a provider's + * models to do their jobs. Keeping the path resolution here means an operator's + * {@code modelsPath} override is honored identically by both — otherwise a + * self-hosted endpoint behind a non-standard prefix could be discoverable yet + * still marked unhealthy by a probe hitting the wrong hard-coded path. + * + *

Resolution order: + *

    + *
  1. an explicit {@code modelsPath} in {@code generateKwargs} — used verbatim + * (leading slash added if missing), for reverse-proxy / gateway prefixes + * such as {@code /openai/v1/models};
  2. + *
  3. otherwise {@code /v1/models}, collapsed to {@code /models} when the base + * URL already ends in a {@code /v{N}} segment (LM Studio {@code /v1}, + * Zhipu {@code /v4}, Volcano Ark {@code /api/v3}) to avoid {@code /vN/v1/models}.
  4. + *
+ * Mirrors the sibling {@code completionsPath} override on the chat path. + */ +public final class OpenAiModelsPath { + + /** Trailing {@code /v{N}} segment on a base URL (any numeric major version). */ + private static final Pattern VERSION_SUFFIX = Pattern.compile(".*/v\\d+$"); + + private OpenAiModelsPath() {} + + public static String resolve(String baseUrl, Map kwargs) { + if (kwargs != null) { + Object raw = kwargs.get("modelsPath"); + if (raw instanceof String value && StringUtils.hasText(value)) { + String path = value.trim(); + return path.startsWith("/") ? path : "/" + path; + } + } + String path = "/v1/models"; + if (baseUrl != null && VERSION_SUFFIX.matcher(baseUrl).matches()) { + path = "/models"; + } + return path; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java index ad62ba2d..a66513d4 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbe.java @@ -1,5 +1,7 @@ package vip.mate.llm.failover.probe; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; @@ -9,6 +11,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; import org.springframework.web.client.RestClient; +import vip.mate.llm.chatmodel.OpenAiModelsPath; import vip.mate.llm.failover.ProbeResult; import vip.mate.llm.failover.ProviderProbeStrategy; import vip.mate.llm.model.ModelProtocol; @@ -16,7 +19,7 @@ import vip.mate.llm.model.ModelProviderEntity; import java.net.http.HttpClient; import java.time.Duration; -import java.util.regex.Pattern; +import java.util.Map; /** * Probes an OpenAI-compatible provider by listing its models. @@ -24,14 +27,11 @@ import java.util.regex.Pattern; *

Two non-trivial things this implementation handles:

* *
    - *
  1. Path construction. Different vendors set Base URL to different - * depths: OpenAI/DeepSeek/Kimi point at the API root - * ({@code https://api.openai.com}) while LMStudio / ZhipuAI bake the - * version segment in ({@code http://localhost:1234/v1}, - * {@code https://open.bigmodel.cn/api/paas/v4}). We append {@code /models} - * when the URL already ends with a {@code /vN} segment, otherwise - * {@code /v1/models}. Without this we'd hit {@code /v1/v1/models} on - * LMStudio and {@code /v4/v1/models} on Zhipu — both 404.
  2. + *
  3. Path construction. Delegated to {@link OpenAiModelsPath} so the + * probe lists the exact same endpoint discovery does — including an + * operator's {@code modelsPath} override for non-standard gateway prefixes. + * Without sharing, a provider could be discoverable yet still marked + * unhealthy here by a probe hitting the wrong hard-coded path.
  4. * *
  5. Permissive 4xx/5xx handling. Not every OpenAI-compatible * vendor implements {@code /models}. Kimi for Coding returns a @@ -49,8 +49,11 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { /** Conservative HTTP timeout — keeps a stalled provider from holding up the parallel batch. */ private static final Duration TIMEOUT = Duration.ofSeconds(5); - /** Matches a trailing {@code /v1}, {@code /v2}, ..., {@code /v99} segment on the base URL. */ - private static final Pattern VERSION_SUFFIX = Pattern.compile("/v\\d{1,2}$"); + private final ObjectMapper objectMapper; + + public OpenAiCompatibleListModelsProbe(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } @Override public ModelProtocol supportedProtocol() { @@ -63,7 +66,7 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { return ProbeResult.fail(0, "base URL not configured"); } String baseUrl = stripTrailingSlash(provider.getBaseUrl().trim()); - String modelsPath = resolveModelsPath(baseUrl); + String modelsPath = OpenAiModelsPath.resolve(baseUrl, parseKwargs(provider.getGenerateKwargs())); long start = System.currentTimeMillis(); try { HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); @@ -124,15 +127,21 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { } /** - * Pick the right path to append. If the base URL already ends in a {@code /vN} - * version segment, append only {@code /models}. Otherwise append {@code /v1/models}. - * Package-private so the unit test can exercise it directly. + * Parse the provider's {@code generateKwargs} JSON into a map so a + * {@code modelsPath} override is visible to {@link OpenAiModelsPath}. Returns + * an empty map on null / blank / malformed JSON — a bad kwargs blob must not + * knock a provider out of the pool; it simply falls back to the default path. */ - static String resolveModelsPath(String baseUrl) { - if (baseUrl != null && VERSION_SUFFIX.matcher(baseUrl).find()) { - return "/models"; + private Map parseKwargs(String json) { + if (!StringUtils.hasText(json)) { + return Map.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.debug("[Probe] ignoring unparseable generateKwargs: {}", e.getMessage()); + return Map.of(); } - return "/v1/models"; } private static String stripTrailingSlash(String url) { diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java index 8d9017dd..471fa050 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProtocol.java @@ -4,24 +4,30 @@ import java.util.Arrays; public enum ModelProtocol { - OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel"), - OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel"), - ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel"), + OPENAI_COMPATIBLE("openai-compatible", "OpenAIChatModel", true), + // OAuth-based: discovery relies on a separately established OAuth session + // (stored, auto-refreshed access token) rather than the provider row's + // baseUrl/apiKey, so a self-configured custom provider cannot drive it. + OPENAI_CHATGPT("openai-chatgpt", "ChatGPTChatModel", false), + ANTHROPIC_MESSAGES("anthropic-messages", "AnthropicChatModel", true), /** * RFC-062: same Anthropic Messages API but authenticated with the user's * Claude Code OAuth token (Pro/Max subscription) instead of an API key. - * Routed by {@code ClaudeCodeChatModelBuilder}. + * Routed by {@code ClaudeCodeChatModelBuilder}. Has no discovery endpoint + * (fixed, Flyway-seeded catalog), so discovery is unsupported. */ - ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel"), - GEMINI_NATIVE("gemini-native", "GeminiChatModel"), - DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel"); + ANTHROPIC_CLAUDE_CODE("anthropic-claude-code", "ClaudeCodeChatModel", false), + GEMINI_NATIVE("gemini-native", "GeminiChatModel", true), + DASHSCOPE_NATIVE("dashscope-native", "DashScopeChatModel", true); private final String id; private final String chatModelClass; + private final boolean supportsSelfConfiguredDiscovery; - ModelProtocol(String id, String chatModelClass) { + ModelProtocol(String id, String chatModelClass, boolean supportsSelfConfiguredDiscovery) { this.id = id; this.chatModelClass = chatModelClass; + this.supportsSelfConfiguredDiscovery = supportsSelfConfiguredDiscovery; } public String getId() { @@ -32,6 +38,23 @@ public enum ModelProtocol { return chatModelClass; } + /** + * Whether a self-configured provider (baseUrl + apiKey) of this protocol can + * drive model discovery. Used to decide the default {@code supportModelDiscovery} + * flag for user-created custom providers. OAuth-based protocols return false: + * their discovery hangs off a separately established OAuth session, not the + * provider row's baseUrl/apiKey. + * + *

    Note: this is narrower than "can this protocol ever discover" — + * the built-in ChatGPT-OAuth provider does discover (via its OAuth + * session) yet this returns false for {@code OPENAI_CHATGPT}. Do not reuse + * this to gate the discover button in general; it answers only the custom- + * provider default. + */ + public boolean supportsSelfConfiguredDiscovery() { + return supportsSelfConfiguredDiscovery; + } + public static ModelProtocol fromChatModel(String chatModel) { if (chatModel == null || chatModel.isBlank()) { return OPENAI_COMPATIBLE; @@ -52,13 +75,24 @@ public enum ModelProtocol { .orElse(OPENAI_COMPATIBLE); } - public static String resolveChatModel(String protocolId, String chatModel) { + /** + * Resolve the effective protocol from an explicit protocol id, falling back + * to inference from the chat-model class, and finally to + * {@link #OPENAI_COMPATIBLE}. Single source of truth so callers can derive + * both the chat-model class and capability flags (e.g. {@link #supportsSelfConfiguredDiscovery()}) + * from one consistent resolution. + */ + public static ModelProtocol resolve(String protocolId, String chatModel) { if (protocolId != null && !protocolId.isBlank()) { - return fromId(protocolId).getChatModelClass(); + return fromId(protocolId); } if (chatModel != null && !chatModel.isBlank()) { - return fromChatModel(chatModel).getChatModelClass(); + return fromChatModel(chatModel); } - return OPENAI_COMPATIBLE.getChatModelClass(); + return OPENAI_COMPATIBLE; + } + + public static String resolveChatModel(String protocolId, String chatModel) { + return resolve(protocolId, chatModel).getChatModelClass(); } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java index df18b4ec..ca6ebf2f 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelDiscoveryService.java @@ -11,6 +11,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.RestClient; import vip.mate.exception.MateClawException; +import vip.mate.llm.chatmodel.OpenAiModelsPath; import vip.mate.llm.model.*; import vip.mate.llm.oauth.OpenAIOAuthService; @@ -461,12 +462,12 @@ public class ModelDiscoveryService { .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .build(); - RestClient.RequestHeadersSpec spec = client.get().uri(resolveModelsPath(baseUrl)); + Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); + RestClient.RequestHeadersSpec spec = client.get().uri(OpenAiModelsPath.resolve(baseUrl, kwargs)); if (modelProviderService.hasUsableApiKey(apiKey)) { spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim()); } // Apply any custom headers declared in generateKwargs. - Map kwargs = modelProviderService.readProviderGenerateKwargs(provider); applyCustomHeaders(spec, kwargs); String body = spec.retrieve().body(String.class); @@ -931,19 +932,6 @@ public class ModelDiscoveryService { return path; } - /** - * Resolve the OpenAI-compatible {@code /v1/models} path against a base URL, - * stripping the {@code /v1} prefix when the base already carries a {@code /v{N}} - * suffix (Volcano Engine Ark, etc.). - */ - private String resolveModelsPath(String baseUrl) { - String path = "/v1/models"; - if (baseUrl != null && BASE_URL_VERSION_SUFFIX.matcher(baseUrl).matches()) { - path = "/models"; - } - return path; - } - private String normalizeBaseUrl(String baseUrl) { if (!StringUtils.hasText(baseUrl)) { return null; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 03ed03b1..8b05972b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -157,11 +157,12 @@ public class ModelProviderService { if (modelProviderMapper.selectById(request.getId()) != null) { throw new MateClawException("err.llm.provider_exists", "Provider 已存在: " + request.getId()); } + ModelProtocol protocol = ModelProtocol.resolve(request.getProtocol(), request.getChatModel()); ModelProviderEntity provider = new ModelProviderEntity(); provider.setProviderId(request.getId()); provider.setName(request.getName()); provider.setApiKeyPrefix(request.getApiKeyPrefix()); - provider.setChatModel(ModelProtocol.resolveChatModel(request.getProtocol(), request.getChatModel())); + provider.setChatModel(protocol.getChatModelClass()); provider.setBaseUrl(request.getDefaultBaseUrl()); provider.setGenerateKwargs("{}"); provider.setIsCustom(true); @@ -169,7 +170,12 @@ public class ModelProviderService { // RFC-074: custom providers are user-created, so opt them in by default // — the user just made the row, no need to make them flip a second toggle. provider.setEnabled(true); - provider.setSupportModelDiscovery(false); + // Default model discovery on for protocols whose discovery works from a + // self-configured baseUrl+apiKey (OpenAI-compatible, DashScope, Gemini, + // Anthropic). Previously hard-coded false, which left self-hosted + // OpenAI-compatible endpoints (vLLM/Xinference/LocalAI/…) unable to + // surface the "discover models" button at all. OAuth protocols stay off. + provider.setSupportModelDiscovery(protocol.supportsSelfConfiguredDiscovery()); provider.setSupportConnectionCheck(false); provider.setFreezeUrl(false); provider.setRequireApiKey(request.getRequireApiKey() == null || Boolean.TRUE.equals(request.getRequireApiKey())); diff --git a/mateclaw-server/src/main/resources/docs/en/models.md b/mateclaw-server/src/main/resources/docs/en/models.md index 33291b96..ef0d06a8 100644 --- a/mateclaw-server/src/main/resources/docs/en/models.md +++ b/mateclaw-server/src/main/resources/docs/en/models.md @@ -198,6 +198,18 @@ Providers that expose a model list (OpenAI, Ollama, LM Studio, OpenRouter, etc.) For OpenRouter specifically, Model Discovery surfaces the **200+ free-tier models** — pick a free model and you have a working setup with zero cost. +### Custom (self-added) providers + +Compatible endpoints you create via "Add provider" (vLLM / Xinference / LocalAI / gateways) enable discovery by protocol: `openai-compatible`, `dashscope-native`, `gemini-native`, and `anthropic-messages` get the **Discover models** button by default; OAuth protocols (ChatGPT OAuth, Claude Code OAuth) do not — their discovery runs through a dedicated sign-in callback, unrelated to `baseUrl`. + +If the endpoint's model-listing path is not the standard `/v1/models` (e.g. a reverse proxy adds a `/openai/v1/models` prefix), override it with a `modelsPath` entry in the provider's Generate Kwargs (JSON): + +```json +{ "modelsPath": "/openai/v1/models" } +``` + +The sibling `completionsPath` key overrides the chat-completions path (default `/v1/chat/completions`); the two are independent. If the endpoint exposes no OpenAI-style listing at all, just use "Add model" to enter model ids manually. + ### Ollama auto-detection on startup No manual configuration needed. On startup: diff --git a/mateclaw-server/src/main/resources/docs/zh/models.md b/mateclaw-server/src/main/resources/docs/zh/models.md index e312ebf6..c6c4df9f 100644 --- a/mateclaw-server/src/main/resources/docs/zh/models.md +++ b/mateclaw-server/src/main/resources/docs/zh/models.md @@ -199,6 +199,18 @@ token 持久化和刷新走的是和浏览器回调流**完全相同**的代码 对 OpenRouter 特别有用——**让 200+ 免费档模型全都可见**。挑一个免费模型零成本有一套能用的环境。 +### 自建供应商的模型发现 + +自己「添加供应商」建的兼容端点(vLLM / Xinference / LocalAI / 各类兼容网关)默认按协议开启发现:`openai-compatible`、`dashscope-native`、`gemini-native`、`anthropic-messages` 会自动带上「发现模型」按钮;OAuth 类协议(ChatGPT OAuth、Claude Code OAuth)不带(它们的发现走专属登录回调,与 baseUrl 无关)。 + +如果端点的模型列举路径不是标准的 `/v1/models`(例如反向代理加了前缀 `/openai/v1/models`),在「生成参数(JSON)」里加一行 `modelsPath` 覆盖即可: + +```json +{ "modelsPath": "/openai/v1/models" } +``` + +同一个 JSON 里的 `completionsPath` 用来覆盖对话补全路径(默认 `/v1/chat/completions`),两者互不影响。若端点根本不提供 OpenAI 风格的列举接口,直接用「添加模型」手动录入模型 id。 + ### Ollama 启动时自动检测 不用手动配。启动时: diff --git a/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiModelsPathTest.java b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiModelsPathTest.java new file mode 100644 index 00000000..5739b08b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/chatmodel/OpenAiModelsPathTest.java @@ -0,0 +1,74 @@ +package vip.mate.llm.chatmodel; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pins {@link OpenAiModelsPath}: the single source of truth for the + * OpenAI-compatible models-listing path, shared by discovery + * ({@code ModelDiscoveryService}) and the failover liveness probe + * ({@code OpenAiCompatibleListModelsProbe}). A regression here desyncs the two — + * a provider could be discoverable yet marked unhealthy, or vice versa. + */ +class OpenAiModelsPathTest { + + // ---- default path (no override) ---- + + @Test + @DisplayName("API-root base URL → /v1/models (OpenAI / DeepSeek / Kimi)") + void apiRootBaseGetsV1Models() { + assertEquals("/v1/models", OpenAiModelsPath.resolve("https://api.openai.com", null)); + assertEquals("/v1/models", OpenAiModelsPath.resolve("https://api.deepseek.com", Map.of())); + assertEquals("/v1/models", OpenAiModelsPath.resolve("https://api.moonshot.cn", null)); + } + + @Test + @DisplayName("base URL ending in /v{N} → only /models (LM Studio /v1, Zhipu /v4, Ark /api/v3)") + void versionedBaseDropsV1() { + assertEquals("/models", OpenAiModelsPath.resolve("http://localhost:1234/v1", null)); + assertEquals("/models", OpenAiModelsPath.resolve("https://open.bigmodel.cn/api/paas/v4", null)); + assertEquals("/models", OpenAiModelsPath.resolve("https://ark.cn-beijing.volces.com/api/v3", null)); + } + + @Test + @DisplayName("/vN mid-path (not a suffix) → /v1/models") + void midPathVersionDoesNotMatch() { + assertEquals("/v1/models", OpenAiModelsPath.resolve("https://api.example.com/v1/proxy", null)); + } + + @Test + @DisplayName("null / blank base URL falls back to /v1/models") + void nullOrBlankBase() { + assertEquals("/v1/models", OpenAiModelsPath.resolve(null, null)); + assertEquals("/v1/models", OpenAiModelsPath.resolve("", null)); + } + + // ---- modelsPath override ---- + + @Test + @DisplayName("explicit modelsPath wins over any default, even for a versioned base") + void explicitOverrideWins() { + assertEquals("/openai/v1/models", + OpenAiModelsPath.resolve("https://gw.internal", Map.of("modelsPath", "/openai/v1/models"))); + assertEquals("/custom/models", + OpenAiModelsPath.resolve("https://ark.example.com/api/v3", Map.of("modelsPath", "/custom/models"))); + } + + @Test + @DisplayName("modelsPath without a leading slash is normalized to one") + void overrideGetsLeadingSlash() { + assertEquals("/api/models", + OpenAiModelsPath.resolve("https://gw.internal", Map.of("modelsPath", "api/models"))); + } + + @Test + @DisplayName("blank modelsPath is ignored and falls back to the default") + void blankOverrideIgnored() { + assertEquals("/v1/models", + OpenAiModelsPath.resolve("https://api.example.com", Map.of("modelsPath", " "))); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbeTest.java b/mateclaw-server/src/test/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbeTest.java deleted file mode 100644 index 4991a38e..00000000 --- a/mateclaw-server/src/test/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbeTest.java +++ /dev/null @@ -1,69 +0,0 @@ -package vip.mate.llm.failover.probe; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -/** - * Locks down the URL-resolution rule for {@link OpenAiCompatibleListModelsProbe}: - * - *

      - *
    • Vendors that point at the API root (OpenAI / Kimi / DeepSeek) get - * {@code /v1/models} appended.
    • - *
    • Vendors that include a {@code /vN} segment in their Base URL - * (LMStudio's {@code /v1}, ZhipuAI's {@code /v4}, etc.) get only - * {@code /models} appended — preventing the {@code /v1/v1/models} or - * {@code /v4/v1/models} 404s the original implementation produced.
    • - *
    - */ -class OpenAiCompatibleListModelsProbeTest { - - @Test - @DisplayName("API-root base URL → append /v1/models (OpenAI / DeepSeek / Kimi)") - void apiRootBaseGetsV1Models() { - assertEquals("/v1/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("https://api.openai.com")); - assertEquals("/v1/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("https://api.deepseek.com")); - assertEquals("/v1/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("https://api.moonshot.cn")); - } - - @Test - @DisplayName("Base URL ends in /v1 → append only /models (LMStudio)") - void v1SuffixGetsOnlyModels() { - assertEquals("/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("http://localhost:1234/v1")); - } - - @Test - @DisplayName("Base URL ends in /v4 → append only /models (ZhipuAI)") - void v4SuffixGetsOnlyModels() { - assertEquals("/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("https://open.bigmodel.cn/api/paas/v4")); - } - - @Test - @DisplayName("Base URL ends in /v2 (hypothetical) → append only /models") - void otherVersionSuffixGetsOnlyModels() { - assertEquals("/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("https://example.com/api/v2")); - assertEquals("/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("https://example.com/v3")); - } - - @Test - @DisplayName("Base URL contains /vN mid-path but doesn't end with it → append /v1/models") - void midPathVersionDoesNotMatch() { - assertEquals("/v1/models", - OpenAiCompatibleListModelsProbe.resolveModelsPath("https://api.example.com/v1/proxy")); - } - - @Test - @DisplayName("Edge: null / blank base URL falls back to /v1/models (caller validates emptiness separately)") - void nullOrBlankBase() { - assertEquals("/v1/models", OpenAiCompatibleListModelsProbe.resolveModelsPath(null)); - assertEquals("/v1/models", OpenAiCompatibleListModelsProbe.resolveModelsPath("")); - } -} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/model/ModelProtocolTest.java b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelProtocolTest.java new file mode 100644 index 00000000..390e2ea8 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/model/ModelProtocolTest.java @@ -0,0 +1,62 @@ +package vip.mate.llm.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Pins the protocol → discovery-capability table and the {@link ModelProtocol#resolve} + * fallback chain. The capability flag drives the default {@code supportModelDiscovery} + * of user-created custom providers (see {@code ModelProviderService.createCustomProvider}), + * so a wrong entry here silently hides — or wrongly shows — the "discover models" button. + */ +class ModelProtocolTest { + + @Test + @DisplayName("baseUrl+apiKey protocols support discovery; OAuth protocols do not") + void discoveryCapabilityTable() { + assertTrue(ModelProtocol.OPENAI_COMPATIBLE.supportsSelfConfiguredDiscovery()); + assertTrue(ModelProtocol.ANTHROPIC_MESSAGES.supportsSelfConfiguredDiscovery()); + assertTrue(ModelProtocol.GEMINI_NATIVE.supportsSelfConfiguredDiscovery()); + assertTrue(ModelProtocol.DASHSCOPE_NATIVE.supportsSelfConfiguredDiscovery()); + + // OAuth-based: discovery hangs off a separately established OAuth session, + // not the provider row's baseUrl/apiKey — so a self-configured custom + // provider must not default the button on. + assertFalse(ModelProtocol.OPENAI_CHATGPT.supportsSelfConfiguredDiscovery()); + assertFalse(ModelProtocol.ANTHROPIC_CLAUDE_CODE.supportsSelfConfiguredDiscovery()); + } + + @Test + @DisplayName("resolve() prefers explicit protocol id over chat-model class") + void resolvePrefersProtocolId() { + assertEquals(ModelProtocol.DASHSCOPE_NATIVE, + ModelProtocol.resolve("dashscope-native", "OpenAIChatModel")); + } + + @Test + @DisplayName("resolve() falls back to chat-model class when protocol id is blank") + void resolveFallsBackToChatModel() { + assertEquals(ModelProtocol.ANTHROPIC_MESSAGES, + ModelProtocol.resolve(null, "AnthropicChatModel")); + assertEquals(ModelProtocol.GEMINI_NATIVE, + ModelProtocol.resolve(" ", "GeminiChatModel")); + } + + @Test + @DisplayName("resolve() defaults to OpenAI-compatible when nothing is supplied or recognized") + void resolveDefaultsToOpenAiCompatible() { + assertEquals(ModelProtocol.OPENAI_COMPATIBLE, ModelProtocol.resolve(null, null)); + assertEquals(ModelProtocol.OPENAI_COMPATIBLE, ModelProtocol.resolve("no-such-proto", null)); + } + + @Test + @DisplayName("resolveChatModel() stays consistent with resolve().getChatModelClass()") + void resolveChatModelConsistency() { + assertEquals(ModelProtocol.resolve("dashscope-native", null).getChatModelClass(), + ModelProtocol.resolveChatModel("dashscope-native", null)); + assertEquals(ModelProtocol.OPENAI_COMPATIBLE.getChatModelClass(), + ModelProtocol.resolveChatModel(null, null)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java index 843f2a90..2562724c 100644 --- a/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java +++ b/mateclaw-server/src/test/java/vip/mate/llm/service/ModelProviderServiceCustomProviderTest.java @@ -169,6 +169,41 @@ class ModelProviderServiceCustomProviderTest { assertEquals("err.llm.provider_fields_required", ex.getMsgKey()); } + // ==================== model-discovery default (issue: custom providers + // never showed the "discover models" button) ======== + + @Test + @DisplayName("createCustomProvider defaults supportModelDiscovery=true for openai-compatible") + void discoveryDefaultsOnForOpenAiCompatible() { + CreateCustomProviderRequest req = req("vllm-internal", "Internal vLLM"); + req.setProtocol("openai-compatible"); + req.setChatModel("OpenAIChatModel"); + when(providerMapper.selectById("vllm-internal")).thenReturn(null); + + service.createCustomProvider(req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ModelProviderEntity.class); + verify(providerMapper).insert(captor.capture()); + assertTrue(captor.getValue().getSupportModelDiscovery(), + "self-hosted OpenAI-compatible endpoints should expose discovery by default"); + } + + @Test + @DisplayName("createCustomProvider keeps supportModelDiscovery=false for OAuth (claude-code) protocol") + void discoveryStaysOffForOAuthProtocol() { + CreateCustomProviderRequest req = req("my-claude-code", "Claude Code"); + req.setProtocol("anthropic-claude-code"); + req.setChatModel("ClaudeCodeChatModel"); + when(providerMapper.selectById("my-claude-code")).thenReturn(null); + + service.createCustomProvider(req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ModelProviderEntity.class); + verify(providerMapper).insert(captor.capture()); + assertFalse(captor.getValue().getSupportModelDiscovery(), + "OAuth-based protocols cannot discover from a self-configured provider row"); + } + // ==================== delete-side: dirty data rescue ==================== @Test