feat(llm): default model discovery by protocol for custom providers + configurable modelsPath

Custom (user-added) providers were hard-coded supportModelDiscovery=false in
createCustomProvider, so self-hosted OpenAI-compatible endpoints (vLLM /
Xinference / LocalAI / gateways) never surfaced the 'discover models' button —
users had to add every model id by hand.

- ModelProtocol: add per-protocol supportsSelfConfiguredDiscovery() + resolve()
  helper (single source of truth for chat-model class and capability flags).
  baseUrl+apiKey protocols (openai-compatible, dashscope-native, gemini-native,
  anthropic-messages) => true; OAuth protocols => false. The flag is deliberately
  narrower than 'can ever discover' (built-in ChatGPT-OAuth still discovers via
  its OAuth session); javadoc warns against reusing it to gate the button.
- createCustomProvider: default supportModelDiscovery from the resolved protocol
  instead of always false. Existing rows are unaffected (no migration).
- OpenAiModelsPath: new single source of truth for the models-listing path,
  honoring an optional 'modelsPath' generateKwargs override (mirrors the existing
  'completionsPath' override) for endpoints behind a reverse proxy / non-standard
  prefix (e.g. /openai/v1/models) that would otherwise 404 on /v1/models.
  Shared by BOTH discovery (ModelDiscoveryService) and the failover liveness
  probe (OpenAiCompatibleListModelsProbe) so an override can't make a provider
  discoverable yet still marked unhealthy by a probe hitting the wrong path.
- Tests: ModelProtocolTest (capability table + resolve fallback), OpenAiModelsPathTest
  (path branch table + vendor cases + modelsPath override), and custom-provider
  discovery-default assertions. Path-resolution coverage consolidated into
  OpenAiModelsPathTest (was split across the discovery + probe test files).
- Docs: zh/en models.md note custom-provider discovery + modelsPath override.

Refs matevip/mateclaw#519
This commit is contained in:
倪程伟 2026-07-15 14:50:53 +08:00 committed by GitHub
parent cd0360ff3f
commit bf224abc05
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 330 additions and 117 deletions

View File

@ -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 <em>models-listing</em> path.
*
* <p>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.
*
* <p>Resolution order:
* <ol>
* <li>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};</li>
* <li>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}.</li>
* </ol>
* 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<String, Object> 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;
}
}

View File

@ -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;
* <p>Two non-trivial things this implementation handles:</p>
*
* <ol>
* <li><b>Path construction.</b> 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.</li>
* <li><b>Path construction.</b> 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.</li>
*
* <li><b>Permissive 4xx/5xx handling.</b> 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<String, Object> parseKwargs(String json) {
if (!StringUtils.hasText(json)) {
return Map.of();
}
try {
return objectMapper.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.debug("[Probe] ignoring unparseable generateKwargs: {}", e.getMessage());
return Map.of();
}
return "/v1/models";
}
private static String stripTrailingSlash(String url) {

View File

@ -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.
*
* <p><b>Note:</b> this is narrower than "can this protocol ever discover"
* the built-in ChatGPT-OAuth provider <em>does</em> 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();
}
}

View File

@ -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<String, Object> 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<String, Object> 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;

View File

@ -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()));

View File

@ -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:

View File

@ -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 启动时自动检测
不用手动配。启动时:

View File

@ -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", " ")));
}
}

View File

@ -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}:
*
* <ul>
* <li>Vendors that point at the API root (OpenAI / Kimi / DeepSeek) get
* {@code /v1/models} appended.</li>
* <li>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.</li>
* </ul>
*/
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(""));
}
}

View File

@ -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));
}
}

View File

@ -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<ModelProviderEntity> 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<ModelProviderEntity> 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