feat(llm): track primary health + split BILLING / MODEL_NOT_FOUND from generic client errors

Track the primary model health, not just fallback entries
- NodeStreamingChatHelper accepts primaryProviderId via a new 5-arg
  constructor; AgentGraphBuilder passes ModelConfigEntity.getProvider()
- Before the 5-retry primary loop, check
  healthTracker.isInCooldown(primaryProviderId): if true, log + broadcast
  "主模型暂时不可用(冷却中),直接尝试备选模型..." and short-circuit
  straight to the fallback chain. Prevents a degraded primary from
  burning 30+ seconds of backoff on every conversation turn.
- recordPrimary(success/failure) now fires on every primary verdict —
  AUTH, BILLING, MODEL_NOT_FOUND, EMPTY_RESPONSE, generic UNKNOWN, and
  the explicit success path. Three consecutive failures push the
  primary provider into cooldown automatically.
- Legacy 1/2/3-arg constructors leave primaryProviderId null; tracking
  silently disables for them so existing tests/wiring keep working.

Split BILLING and MODEL_NOT_FOUND out of CLIENT_ERROR / AUTH_ERROR
- BILLING (HTTP 402, "insufficient_quota", "credit balance is too low",
  "billing_hard_limit_reached", "quota exceeded"): payment failure on
  primary does not kill the call — a different provider may have credits.
  Skips same-model retries and heads to fallback chain.
- MODEL_NOT_FOUND (HTTP 404, "Model not exist", "model_not_found",
  DashScope "[InvalidParameter] url error"): unknown model id will not
  start working on retry. Was previously misclassified as CLIENT_ERROR
  and terminated the whole call; now routes to fallback so a different
  provider can attempt with its default model.
- classifyError ordering matters: BILLING / MODEL_NOT_FOUND are matched
  BEFORE the generic 400 / Bad Request branch, otherwise they would be
  swallowed by CLIENT_ERROR.

Tests
- ErrorClassificationTest: 11 tests, covers multi-vendor error phrasing
  for both new types + regression checks that 401 / 429 / 400 still
  classify as before
- NodeStreamingChatHelperFallbackChainTest: +2 tests verifying
  primaryProviderId persistence on the new constructor and null on
  legacy ones
- 181 tests pass (was 168 + 13 new)
This commit is contained in:
matevip 2026-04-19 17:10:27 +08:00
parent 7b12c5f0c9
commit 7ba8fe602b
4 changed files with 270 additions and 34 deletions

View File

@ -260,7 +260,9 @@ public class AgentGraphBuilder {
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
PlanGenerationNode planGenerationNode = new PlanGenerationNode(chatModel, planningService, streamingHelper, conversationWindowManager, toolSet);
StepExecutionNode stepExecutionNode = new StepExecutionNode(chatModel, toolSet, executor, planningService, streamTracker, reasoningEffort, streamingHelper, conversationWindowManager);
@ -359,7 +361,9 @@ public class AgentGraphBuilder {
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
try {
List<vip.mate.llm.failover.FallbackEntry> fallbackChain = buildFallbackChain(primaryModelConfig);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker);
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(
streamTracker, fallbackChain, llmCacheMetricsAggregator, providerHealthTracker,
primaryModelConfig != null ? primaryModelConfig.getProvider() : null);
ToolExecutionExecutor executor = new ToolExecutionExecutor(toolSet, toolGuardService, approvalService, streamTracker, toolTimeoutProperties, toolResultStorage, toolConcurrencyRegistry);
ReasoningNode reasoningNode = new ReasoningNode(chatModel, toolSet, reasoningEffort, streamingHelper, conversationWindowManager, streamTracker, 0, wikiContextService);
ActionNode actionNode = new ActionNode(executor, streamTracker);

View File

@ -61,48 +61,70 @@ public class NodeStreamingChatHelper {
/** Optional per-provider health tracker; {@code null} in tests or when bean absent. */
private final vip.mate.llm.failover.ProviderHealthTracker healthTracker;
/**
* Provider id of the primary {@link ChatModel} this helper drives. Used
* by {@link #streamCallInternal} to consult / update {@link #healthTracker}
* for the primary too if a provider's API key is revoked, primary
* cooldown lets us bypass the 5-retry stall on subsequent calls within
* the same conversation. Falls back to {@code null} when unknown
* (legacy callers, tests).
*/
private final String primaryProviderId;
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
this(streamTracker, List.of(), null, null);
this(streamTracker, List.of(), null, null, null);
}
/**
* @deprecated use the list-based constructor with FallbackEntry a single
* fallback cannot express the ordered multi-provider chain
* @deprecated use the full constructor a single fallback cannot
* express the ordered multi-provider chain
*/
@Deprecated
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
this(streamTracker, wrap(fallbackModel), null, null);
this(streamTracker, wrap(fallbackModel), null, null, null);
}
/**
* @deprecated use the list-based constructor with FallbackEntry a single
* fallback cannot express the ordered multi-provider chain
* @deprecated use the full constructor.
*/
@Deprecated
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
this(streamTracker, wrap(fallbackModel), cacheMetrics, null);
this(streamTracker, wrap(fallbackModel), cacheMetrics, null, null);
}
/**
* Full chain constructor without health tracker primarily for tests and
* legacy wiring. Production callers should use the 4-arg variant so
* cooldown state is honored.
* Chain constructor without health tracker primarily for tests and
* legacy wiring. Production callers should use the full constructor.
*/
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
this(streamTracker, fallbackChain, cacheMetrics, null);
this(streamTracker, fallbackChain, cacheMetrics, null, null);
}
/**
* Constructor with health tracker but unknown primary provider used by
* tests where the helper isn't tied to a specific primary. Primary
* health tracking is disabled for instances built this way.
*/
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
vip.mate.llm.failover.ProviderHealthTracker healthTracker) {
this(streamTracker, fallbackChain, cacheMetrics, healthTracker, null);
}
public NodeStreamingChatHelper(ChatStreamTracker streamTracker,
List<vip.mate.llm.failover.FallbackEntry> fallbackChain,
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics,
vip.mate.llm.failover.ProviderHealthTracker healthTracker) {
vip.mate.llm.failover.ProviderHealthTracker healthTracker,
String primaryProviderId) {
this.streamTracker = streamTracker;
this.fallbackChain = fallbackChain == null ? List.of() : List.copyOf(fallbackChain);
this.cacheMetrics = cacheMetrics;
this.healthTracker = healthTracker;
this.primaryProviderId = primaryProviderId;
}
private static List<vip.mate.llm.failover.FallbackEntry> wrap(ChatModel m) {
@ -111,6 +133,17 @@ public class NodeStreamingChatHelper {
return m == null ? List.of() : List.of(new vip.mate.llm.failover.FallbackEntry("__legacy__", m));
}
/**
* Record a single primary-model outcome to the health tracker. No-op when
* either the tracker bean isn't wired or the primary's providerId is
* unknown (e.g., tests, legacy callers built without the full constructor).
*/
private void recordPrimary(boolean success) {
if (healthTracker == null || primaryProviderId == null) return;
if (success) healthTracker.recordSuccess(primaryProviderId);
else healthTracker.recordFailure(primaryProviderId);
}
/**
* 流式调用 LLM 并实时广播增量内容
*
@ -202,24 +235,38 @@ public class NodeStreamingChatHelper {
|| msg.contains("thinking block")) {
return ErrorType.THINKING_BLOCK_ERROR;
}
// BILLING payment / quota exhausted. Distinct from AUTH because
// a different provider may have credits, so we should fall back instead of
// terminating the call. Both OpenAI ("insufficient_quota") and Anthropic
// ("credit balance is too low") use these phrases in 402-class responses.
if (msg.contains("402") || msg.contains("insufficient_quota")
|| msg.contains("credit balance is too low")
|| msg.contains("billing_error") || msg.contains("billing_hard_limit_reached")
|| msg.contains("You exceeded your current quota")
|| msg.contains("quota exceeded") || msg.contains("Quota exceeded")) {
return ErrorType.BILLING;
}
// MODEL_NOT_FOUND provider rejects the requested model id.
// Includes DashScope's "[InvalidParameter] url error, please check url"
// (https://help.aliyun.com/zh/model-studio/error-code#error-url) which despite
// the wording is the provider rejecting an unknown/unsupported model id on
// the native protocol. Splitting this out from CLIENT_ERROR lets us hand off
// to the fallback chain instead of terminating a different provider may
// recognize the model name (or have an equivalent default).
if (msg.contains("Model not exist")
|| msg.contains("model_not_found")
|| msg.contains("Model not found")
|| msg.contains("does not exist")
|| msg.contains("[InvalidParameter]")
|| msg.contains("InvalidParameter")
|| msg.contains("url error")) {
return ErrorType.MODEL_NOT_FOUND;
}
// Client errors (400 Bad Request unsupported format, invalid params, etc.) NOT retryable
if (msg.contains("400") || msg.contains("Bad Request")
|| msg.contains("invalid_request_error") || msg.contains("unsupported")) {
return ErrorType.CLIENT_ERROR;
}
// DashScope-specific "model name does not map to a valid endpoint" reported as
// "[InvalidParameter] url error, please check url" (see
// https://help.aliyun.com/zh/model-studio/error-code#error-url). Despite the wording
// it's not a URL issue it's the provider rejecting an unknown/unsupported model id
// on the native protocol. Treat as client error so we do NOT retry.
if (msg.contains("[InvalidParameter]")
|| msg.contains("InvalidParameter")
|| msg.contains("url error")
|| msg.contains("Model not exist")
|| msg.contains("model_not_found")
|| msg.contains("Model not found")) {
return ErrorType.CLIENT_ERROR;
}
// Server errors
if (msg.contains("500") || msg.contains("502") || msg.contains("503") || msg.contains("504")
|| msg.contains("APITimeoutError") || msg.contains("APIConnectionError")
@ -253,19 +300,47 @@ public class NodeStreamingChatHelper {
throw new CancellationException("Stream stopped by user");
}
// if the primary's provider is in cooldown (3+ recent
// consecutive failures within the cooldown window), skip the 5-retry
// primary loop entirely and head straight to the fallback chain.
// Without this short-circuit a degraded primary forces every LLM
// call in the conversation to wait through the full backoff.
boolean primarySkipped = primaryProviderId != null
&& healthTracker != null
&& healthTracker.isInCooldown(primaryProviderId);
if (primarySkipped) {
log.warn("[{}] Primary provider={} is in cooldown — skipping straight to fallback chain",
phase, primaryProviderId);
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("主模型暂时不可用(冷却中),直接尝试备选模型..."));
}
}
// 主模型重试循环
StreamResult lastResult = null;
for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
if (!primarySkipped) for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) {
lastResult = doStreamCall(chatModel, prompt, conversationId, phase, broadcast, attempt);
if (lastResult != null) {
// PTL: 不重试直接返回给上层 Node 处理
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
return lastResult;
}
// AUTH: 不重试
// AUTH: 不重试 但要记账auth 不会自愈连续 N 次后冷却避免每轮都撞
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
recordPrimary(false);
return lastResult;
}
// BILLING / MODEL_NOT_FOUND provider-side hard failures
// that won't change on retry. Skip to fallback chain (a different
// provider may have credits, or the model name may be valid there).
if (lastResult.errorType() == ErrorType.BILLING
|| lastResult.errorType() == ErrorType.MODEL_NOT_FOUND) {
log.warn("[{}] Primary error={} — skipping same-model retries, handing off to fallback chain",
phase, lastResult.errorType());
recordPrimary(false);
break;
}
// CLIENT_ERROR (400 Bad Request): 不重试参数/格式错误重试也不会变
if (lastResult.errorType() == ErrorType.CLIENT_ERROR) {
return lastResult;
@ -284,20 +359,27 @@ public class NodeStreamingChatHelper {
// productive; a different provider has a better chance of succeeding.
if (lastResult.errorType() == ErrorType.EMPTY_RESPONSE) {
log.warn("[{}] Primary returned empty response — skipping same-model retries, handing off to fallback chain", phase);
recordPrimary(false);
break;
}
// 成功
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
recordPrimary(true);
return lastResult;
}
// Any other non-null errored result with a classified type that doStreamCall
// chose NOT to retry (i.e. UNKNOWN, or RATE_LIMIT/SERVER_ERROR past MAX_RETRIES)
// must exit otherwise we silently spin through attempts and waste seconds
// per turn on unrecoverable errors like DashScope's "url error" / unknown model.
recordPrimary(false);
return lastResult;
}
// lastResult == null 表示需要重试
}
// If we exhausted the retry loop without a verdict, primary effectively failed.
if (!primarySkipped && lastResult != null && lastResult.errorType() != ErrorType.NONE) {
recordPrimary(false);
}
// Primary exhausted retries walk the fallback chain in priority order.
// Each fallback gets a single shot (no retry); first successful result wins.
@ -850,6 +932,22 @@ public class NodeStreamingChatHelper {
* rejection that comes back as HTTP 200 with empty body.
*/
EMPTY_RESPONSE,
/**
* payment / billing failure (HTTP 402, "insufficient_quota",
* "credit balance is too low", etc.). Distinct from {@link #AUTH_ERROR}
* because the right response is to <i>switch provider</i> (a different
* provider may have credits) rather than just terminate. Skips same-model
* retries and falls through to the fallback chain.
*/
BILLING,
/**
* requested model id not recognized by the provider
* (HTTP 404, "Model not exist", "model_not_found", DashScope's
* "url error"). Same handling as {@link #BILLING} heads straight
* to the fallback chain instead of looping retries against a model
* that does not exist.
*/
MODEL_NOT_FOUND,
/** 其他未知错误 */
UNKNOWN
}

View File

@ -0,0 +1,111 @@
package vip.mate.agent.graph;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* classification tests for the new error types
* ({@link NodeStreamingChatHelper.ErrorType#BILLING},
* {@link NodeStreamingChatHelper.ErrorType#MODEL_NOT_FOUND}).
*
* <p>These two are split out from {@code AUTH_ERROR} / {@code CLIENT_ERROR}
* because the right action is to switch provider, not to terminate.
* Mis-classifying a billing error as auth would break the whole call chain.</p>
*/
class ErrorClassificationTest {
private static NodeStreamingChatHelper.ErrorType classify(Throwable t) throws Exception {
Method m = NodeStreamingChatHelper.class.getDeclaredMethod("classifyError", Throwable.class);
m.setAccessible(true);
return (NodeStreamingChatHelper.ErrorType) m.invoke(null, t);
}
// ===== BILLING =====
@Test
@DisplayName("HTTP 402 → BILLING")
void status402IsBilling() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.BILLING,
classify(new RuntimeException("402 Payment Required")));
}
@Test
@DisplayName("OpenAI 'insufficient_quota' → BILLING")
void openaiQuotaIsBilling() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.BILLING,
classify(new RuntimeException("Error code: insufficient_quota — please check your plan")));
}
@Test
@DisplayName("Anthropic 'credit balance is too low' → BILLING")
void anthropicCreditIsBilling() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.BILLING,
classify(new RuntimeException("Your credit balance is too low to access the API")));
}
@Test
@DisplayName("'You exceeded your current quota' → BILLING")
void quotaExceededIsBilling() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.BILLING,
classify(new RuntimeException("You exceeded your current quota, please check your plan")));
}
// ===== MODEL_NOT_FOUND =====
@Test
@DisplayName("'Model not exist' → MODEL_NOT_FOUND")
void modelNotExistIsModelNotFound() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.MODEL_NOT_FOUND,
classify(new RuntimeException("Model not exist: gpt-99")));
}
@Test
@DisplayName("'model_not_found' → MODEL_NOT_FOUND")
void modelNotFoundCodeIsModelNotFound() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.MODEL_NOT_FOUND,
classify(new RuntimeException("Error: model_not_found")));
}
@Test
@DisplayName("DashScope '[InvalidParameter] url error' → MODEL_NOT_FOUND (not CLIENT_ERROR)")
void dashscopeInvalidParameterIsModelNotFound() throws Exception {
// Despite the wording, DashScope returns this when the model id is unknown
// the right action is to try a fallback provider, not terminate as 400.
assertEquals(NodeStreamingChatHelper.ErrorType.MODEL_NOT_FOUND,
classify(new RuntimeException("[InvalidParameter] url error, please check url")));
}
@Test
@DisplayName("Anthropic 'model does not exist' → MODEL_NOT_FOUND")
void anthropicDoesNotExistIsModelNotFound() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.MODEL_NOT_FOUND,
classify(new RuntimeException("model claude-99 does not exist")));
}
// ===== Regression: existing classifications still work =====
@Test
@DisplayName("HTTP 401 still classifies as AUTH_ERROR (not billing)")
void status401StillAuth() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR,
classify(new RuntimeException("401 Unauthorized: Invalid API Key")));
}
@Test
@DisplayName("HTTP 429 still classifies as RATE_LIMIT")
void status429StillRateLimit() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.RATE_LIMIT,
classify(new RuntimeException("429 Too Many Requests")));
}
@Test
@DisplayName("Plain 400 Bad Request still classifies as CLIENT_ERROR")
void status400StillClientError() throws Exception {
assertEquals(NodeStreamingChatHelper.ErrorType.CLIENT_ERROR,
classify(new RuntimeException("400 Bad Request: malformed JSON")));
}
}

View File

@ -88,12 +88,35 @@ class NodeStreamingChatHelperFallbackChainTest {
}
@Test
@DisplayName("EMPTY_RESPONSE error type exists (fallback trigger)")
void emptyResponseErrorTypeExists() {
// Compile-time safety net: the enum constant the streaming pipeline relies on
@DisplayName("EMPTY_RESPONSE / BILLING / MODEL_NOT_FOUND error types exist ")
void fallbackTriggerErrorTypesExist() {
// Compile-time safety net: these enum constants the streaming pipeline relies on
// must not be renamed or removed without breaking the fallback contract.
NodeStreamingChatHelper.ErrorType t = NodeStreamingChatHelper.ErrorType.EMPTY_RESPONSE;
assertNotNull(t);
assertNotNull(NodeStreamingChatHelper.ErrorType.EMPTY_RESPONSE);
assertNotNull(NodeStreamingChatHelper.ErrorType.BILLING);
assertNotNull(NodeStreamingChatHelper.ErrorType.MODEL_NOT_FOUND);
}
@Test
@DisplayName("primary providerId is stored when supplied via the full constructor")
void primaryProviderIdStored() throws Exception {
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(
streamTracker, List.of(), null, null, "openai");
Field f = NodeStreamingChatHelper.class.getDeclaredField("primaryProviderId");
f.setAccessible(true);
assertEquals("openai", f.get(helper),
"primary provider id must be retained for health tracking");
}
@Test
@DisplayName("legacy constructors leave primaryProviderId null (tracking disabled)")
void primaryProviderIdNullForLegacyConstructors() throws Exception {
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker);
Field f = NodeStreamingChatHelper.class.getDeclaredField("primaryProviderId");
f.setAccessible(true);
assertNull(f.get(helper),
"legacy constructors must leave primaryProviderId unset so tracking is silently disabled");
}
@SuppressWarnings("unchecked")