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: + *
Two non-trivial things this implementation handles:
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