diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/AnthropicListModelsProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/AnthropicListModelsProbe.java index 7f1d701f..eaea668f 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/AnthropicListModelsProbe.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/AnthropicListModelsProbe.java @@ -6,6 +6,8 @@ import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; 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.failover.ProbeResult; import vip.mate.llm.failover.ProviderProbeStrategy; @@ -53,9 +55,22 @@ public class AnthropicListModelsProbe implements ProviderProbeStrategy { String body = client.get().uri("/v1/models").retrieve().body(String.class); long latency = System.currentTimeMillis() - start; - if (body == null || body.isBlank()) { - return ProbeResult.fail(latency, "empty body from /v1/models"); + // Empty body from a 200 — uncommon but not necessarily fatal. + return ProbeResult.ok(latency); + } catch (HttpClientErrorException e) { + long latency = System.currentTimeMillis() - start; + int status = e.getStatusCode().value(); + // Real auth failure → HARD remove. Anything else (404 endpoint missing, + // 429 probe rate-limited, etc.) is fail-open: the chat path is authoritative. + if (status == 401 || status == 403) { + return ProbeResult.fail(latency, "auth failed (" + status + ")"); } + log.info("[Probe] anthropic /v1/models returned {} — fail-open (in-pool)", status); + return ProbeResult.ok(latency); + } catch (HttpServerErrorException e) { + long latency = System.currentTimeMillis() - start; + log.info("[Probe] anthropic /v1/models returned {} — fail-open (5xx may be transient)", + e.getStatusCode().value()); return ProbeResult.ok(latency); } catch (Exception e) { long latency = System.currentTimeMillis() - start; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/DashScopeListModelsProbe.java b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/DashScopeListModelsProbe.java index c16ae697..730f4b73 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/DashScopeListModelsProbe.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/failover/probe/DashScopeListModelsProbe.java @@ -6,6 +6,8 @@ import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; 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.failover.ProbeResult; import vip.mate.llm.failover.ProviderProbeStrategy; @@ -52,9 +54,20 @@ public class DashScopeListModelsProbe implements ProviderProbeStrategy { String body = client.get().uri("/v1/models").retrieve().body(String.class); long latency = System.currentTimeMillis() - start; - if (body == null || body.isBlank()) { - return ProbeResult.fail(latency, "empty body from /v1/models"); + return ProbeResult.ok(latency); + } catch (HttpClientErrorException e) { + long latency = System.currentTimeMillis() - start; + int status = e.getStatusCode().value(); + // Real auth failure → HARD remove. Other 4xx are inconclusive: fail-open. + if (status == 401 || status == 403) { + return ProbeResult.fail(latency, "auth failed (" + status + ")"); } + log.info("[Probe] dashscope /v1/models returned {} — fail-open (in-pool)", status); + return ProbeResult.ok(latency); + } catch (HttpServerErrorException e) { + long latency = System.currentTimeMillis() - start; + log.info("[Probe] dashscope /v1/models returned {} — fail-open (5xx may be transient)", + e.getStatusCode().value()); return ProbeResult.ok(latency); } catch (Exception e) { long latency = System.currentTimeMillis() - start; 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 c796fb6d..ad62ba2d 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 @@ -6,6 +6,8 @@ import org.springframework.http.MediaType; import org.springframework.http.client.JdkClientHttpRequestFactory; import org.springframework.stereotype.Component; 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.failover.ProbeResult; import vip.mate.llm.failover.ProviderProbeStrategy; @@ -14,14 +16,31 @@ import vip.mate.llm.model.ModelProviderEntity; import java.net.http.HttpClient; import java.time.Duration; +import java.util.regex.Pattern; /** - * Probes an OpenAI-compatible provider by calling its {@code GET /v1/models} - * endpoint — free (zero token cost) and authenticates the API key in one - * round-trip. A 200 response with body present is sufficient to confirm - * reachability + auth; we don't try to parse the model list because - * different providers (Kimi / DeepSeek / Moonshot / OpenRouter) have minor - * schema differences that aren't relevant to a liveness check. + * Probes an OpenAI-compatible provider by listing its models. + * + *

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. Permissive 4xx/5xx handling. Not every OpenAI-compatible + * vendor implements {@code /models}. Kimi for Coding returns a + * structured 404 here even though chat works fine. So we treat + * {@code 404 / 405 / 410} as fail-open (probe inconclusive, + * leave the provider in the pool — first chat call will be authoritative) + * rather than HARD removing. Only auth (401/403) and connection-level + * failures (DNS / refused / timeout) are treated as definitive negatives.
  4. + *
*/ @Slf4j @Component @@ -30,6 +49,9 @@ 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}$"); + @Override public ModelProtocol supportedProtocol() { return ModelProtocol.OPENAI_COMPATIBLE; @@ -40,16 +62,18 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { if (provider == null || !StringUtils.hasText(provider.getBaseUrl())) { return ProbeResult.fail(0, "base URL not configured"); } + String baseUrl = stripTrailingSlash(provider.getBaseUrl().trim()); + String modelsPath = resolveModelsPath(baseUrl); long start = System.currentTimeMillis(); try { HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build(); RestClient client = RestClient.builder() - .baseUrl(normalizeBaseUrl(provider.getBaseUrl())) + .baseUrl(baseUrl) .requestFactory(new JdkClientHttpRequestFactory(httpClient)) .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) .build(); - RestClient.RequestHeadersSpec spec = client.get().uri("/v1/models"); + RestClient.RequestHeadersSpec spec = client.get().uri(modelsPath); String apiKey = provider.getApiKey(); if (StringUtils.hasText(apiKey)) { spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim()); @@ -58,18 +82,60 @@ public class OpenAiCompatibleListModelsProbe implements ProviderProbeStrategy { String body = spec.retrieve().body(String.class); long latency = System.currentTimeMillis() - start; if (body == null || body.isBlank()) { - return ProbeResult.fail(latency, "empty body from /v1/models"); + // Empty body from a 200 — unusual but not necessarily fatal; treat as ok. + log.debug("[Probe] {} {} returned empty body — treating as ok", + provider.getProviderId(), modelsPath); + return ProbeResult.ok(latency); } return ProbeResult.ok(latency); + } catch (HttpClientErrorException e) { + long latency = System.currentTimeMillis() - start; + int status = e.getStatusCode().value(); + // 401/403 = real auth failure → HARD remove + if (status == 401 || status == 403) { + log.debug("[Probe] {} {} auth failed: {}", provider.getProviderId(), modelsPath, status); + return ProbeResult.fail(latency, "auth failed (" + status + ")"); + } + // 404/405/410 = endpoint not implemented — many providers (e.g. Kimi for Coding) + // don't expose /v1/models even though chat works. Stay in pool, let the chat + // path be authoritative; the worst case is one wasted first request. + if (status == 404 || status == 405 || status == 410) { + log.info("[Probe] {} {} returned {} — endpoint not implemented; treating as fail-open (in-pool)", + provider.getProviderId(), modelsPath, status); + return ProbeResult.ok(latency); + } + // Other 4xx (e.g., 400 invalid request, 429 rate limit on the probe itself): + // ambiguous — fail-open too, since these don't tell us about chat capability. + log.info("[Probe] {} {} returned {} — fail-open (inconclusive)", + provider.getProviderId(), modelsPath, status); + return ProbeResult.ok(latency); + } catch (HttpServerErrorException e) { + long latency = System.currentTimeMillis() - start; + // 5xx — the provider's listing infrastructure is flaky but chat may still work. + // Fail-open and let runtime cooldown handle it if chat also fails. + log.info("[Probe] {} {} returned {} — fail-open (5xx may be transient)", + provider.getProviderId(), modelsPath, e.getStatusCode().value()); + return ProbeResult.ok(latency); } catch (Exception e) { long latency = System.currentTimeMillis() - start; - log.debug("[Probe] {} /v1/models failed: {}", provider.getProviderId(), e.getMessage()); + log.debug("[Probe] {} {} failed: {}", provider.getProviderId(), modelsPath, e.getMessage()); return ProbeResult.fail(latency, shortMessage(e)); } } - private static String normalizeBaseUrl(String url) { - // Strip trailing slash for clean URL composition; /v1/models then concatenates correctly. + /** + * 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. + */ + static String resolveModelsPath(String baseUrl) { + if (baseUrl != null && VERSION_SUFFIX.matcher(baseUrl).find()) { + return "/models"; + } + return "/v1/models"; + } + + private static String stripTrailingSlash(String url) { return url.endsWith("/") ? url.substring(0, url.length() - 1) : url; } 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 new file mode 100644 index 00000000..4991a38e --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/llm/failover/probe/OpenAiCompatibleListModelsProbeTest.java @@ -0,0 +1,69 @@ +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}: + * + * + */ +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("")); + } +}