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:
+ *
+ *
+ * - 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.
+ *
+ * - 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.
+ *
*/
@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}:
+ *
+ *
+ * - 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(""));
+ }
+}