mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
cd0360ff3f
commit
bf224abc05
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 启动时自动检测
|
||||
|
||||
不用手动配。启动时:
|
||||
|
||||
@ -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", " ")));
|
||||
}
|
||||
}
|
||||
@ -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(""));
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user