mateclaw/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java
matevip 3b11a3def6 fix(failover): AUTH_ERROR triggers fallback chain + UI splits provider 401 from session expiry
Two related issues from the Kimi-401 user report:

1. Backend (NodeStreamingChatHelper): a primary AUTH_ERROR (e.g. Kimi 401
   with an invalid API key) returned immediately without trying the
   fallback chain — a fallback provider with a different, valid key
   never got a chance. Even with DashScope correctly configured as the
   fallback, the user chat dead-ended on a 401.

   The original assumption ("auth never self-heals so do not retry")
   holds for the primary same-model retry loop but is wrong for the
   fallback chain — different providers have different keys. Apply the
   same break-into-fallback policy that BILLING and MODEL_NOT_FOUND
   already use. recordPrimary(false) is preserved so the cooldown
   counter still accumulates.

2. Frontend (chatError.ts + i18n): the error-text matching for
   /认证|auth|unauthorized|401/i was so broad it matched the substring
   "auth" inside URLs like https://api.kimi.com/.../auth, classifying
   any model 401 as user "session expired" and rendering the misleading
   "页面将自动跳转到登录页" copy. (The redirect itself only fires from
   /api/v1/auth/* axios paths and SSE-connection 401s, not from this
   payload-text path — but the copy alone is the worst kind of false
   alarm.)

   Add a new ChatErrorCategory provider_auth_error and split the
   pattern matching: narrow auth_expired (HTTP 401 / 登录已过期 /
   session expired / 凭证失效) is matched FIRST, then the broad
   401-ish pattern routes to provider_auth_error. BACKEND_ERROR_TYPE_MAP
   for AUTH_ERROR is also remapped, since structured backend payloads
   currently always come from LLM providers — never from our own
   /api/v1/auth path.

Tests
- NodeStreamingChatHelperFailoverTest (5 cases): primary 401 →
  fallback succeeds; chain skips auth-failing fallback to next healthy
  one; whole-chain failure surfaces last AUTH_ERROR (no silent drop);
  BILLING regression unchanged; primary-success path does not touch
  chain
- Browser preview verified: new i18n keys resolve in en-US, classifier
  correctly routes "[错误] 401 from kimi.com" → provider_auth_error
  while "[错误] HTTP 401 from /api/v1/auth/ping" stays auth_expired
- 186 tests pass (was 181 + 5 new); vue-tsc clean

Do-not-touch list: handleAuthFailure() in useStream/api/index.ts (real
session-expiry path) is unmodified — only the misclassification
upstream is fixed. auth_expired i18n copy is unchanged.
2026-04-19 17:45:15 +08:00

191 lines
9.1 KiB
Java

package vip.mate.agent.graph;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.ChatGenerationMetadata;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import vip.mate.channel.web.ChatStreamTracker;
import vip.mate.llm.failover.FallbackEntry;
import vip.mate.llm.failover.ProviderHealthProperties;
import vip.mate.llm.failover.ProviderHealthTracker;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
/**
* Regression test for the AUTH_ERROR-must-fall-back fix.
*
* <p>Prior to this fix, primary AUTH_ERROR (e.g. Kimi 401 with an invalid
* API key) returned immediately without trying the fallback chain — a
* fallback provider with a different, valid key never got a chance.
* After the fix, AUTH_ERROR breaks out of the same-model retry loop
* and falls through to the chain walker, mirroring how BILLING and
* MODEL_NOT_FOUND already behave.</p>
*/
class NodeStreamingChatHelperFailoverTest {
private ChatStreamTracker streamTracker;
private ProviderHealthTracker healthTracker;
@BeforeEach
void setUp() {
streamTracker = mock(ChatStreamTracker.class);
when(streamTracker.isStopRequested(any())).thenReturn(false);
ProviderHealthProperties props = new ProviderHealthProperties();
healthTracker = new ProviderHealthTracker(props);
}
/** Build a chat-model mock whose stream() emits a single successful chunk with the given text. */
private static ChatModel successModel(String text) {
ChatModel m = mock(ChatModel.class);
Generation gen = new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL);
ChatResponse resp = mock(ChatResponse.class);
when(resp.getResults()).thenReturn(List.of(gen));
when(resp.getResult()).thenReturn(gen);
when(resp.getMetadata()).thenReturn(null);
when(m.stream(any(Prompt.class))).thenReturn(Flux.just(resp));
return m;
}
/** Build a chat-model mock whose stream() errors with the given Throwable. */
private static ChatModel errorModel(Throwable err) {
ChatModel m = mock(ChatModel.class);
when(m.stream(any(Prompt.class))).thenReturn(Flux.error(err));
return m;
}
private NodeStreamingChatHelper helper(ChatModel primary, List<FallbackEntry> chain, String primaryProviderId) {
// Construct via the full constructor so health tracking is wired and the
// chain walker has provider-id context.
return new NodeStreamingChatHelper(streamTracker, chain, null, healthTracker, primaryProviderId);
}
private static Prompt smallPrompt() {
return new Prompt(List.of(new UserMessage("hi")));
}
// ============================================================
// C1: primary 401 + fallback#1 success → fallback wins
// ============================================================
@Test
@DisplayName("C1: primary AUTH_ERROR triggers fallback chain (was: returned immediately, never tried fallback)")
void primaryAuthErrorFallsBackToHealthyProvider() {
ChatModel primary = errorModel(new RuntimeException("401 Unauthorized: Invalid API Key"));
ChatModel fallback = successModel("hello from fallback");
var helper = helper(primary, List.of(new FallbackEntry("dashscope", fallback)), "kimi");
var result = helper.streamCall(primary, smallPrompt(), "conv-c1", "reasoning");
assertEquals("hello from fallback", result.text(),
"fallback provider must succeed and its text must surface as the result");
assertEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType());
// Primary was tried exactly once (no same-model retries on AUTH_ERROR — fix verified)
verify(primary, times(1)).stream(any(Prompt.class));
verify(fallback, times(1)).stream(any(Prompt.class));
}
// ============================================================
// C2: primary 401 + fallback#1 401 + fallback#2 success
// ============================================================
@Test
@DisplayName("C2: chain walks past auth-failing fallback to the next healthy one")
void chainSkipsAuthFailingFallback() {
ChatModel primary = errorModel(new RuntimeException("401 Unauthorized"));
ChatModel fbBad = errorModel(new RuntimeException("401 Unauthorized: bad key"));
ChatModel fbGood = successModel("ok via 2nd fallback");
var helper = helper(primary, List.of(
new FallbackEntry("openai", fbBad),
new FallbackEntry("dashscope", fbGood)), "kimi");
var result = helper.streamCall(primary, smallPrompt(), "conv-c2", "reasoning");
assertEquals("ok via 2nd fallback", result.text());
assertEquals(NodeStreamingChatHelper.ErrorType.NONE, result.errorType());
verify(primary, times(1)).stream(any(Prompt.class));
verify(fbBad, times(1)).stream(any(Prompt.class));
verify(fbGood, times(1)).stream(any(Prompt.class));
}
// ============================================================
// C3: primary 401 + every fallback 401 → last AUTH_ERROR surfaces
// ============================================================
@Test
@DisplayName("C3: when entire chain is auth-failing, last AUTH_ERROR is surfaced (not silently dropped)")
void allChainAuthFailsSurfacesLastError() {
ChatModel primary = errorModel(new RuntimeException("401 Unauthorized — kimi"));
ChatModel fb1 = errorModel(new RuntimeException("401 Unauthorized — openai"));
ChatModel fb2 = errorModel(new RuntimeException("401 Unauthorized — dashscope"));
var helper = helper(primary, List.of(
new FallbackEntry("openai", fb1),
new FallbackEntry("dashscope", fb2)), "kimi");
var result = helper.streamCall(primary, smallPrompt(), "conv-c3", "reasoning");
assertNotNull(result, "result must not be null even when whole chain fails");
assertEquals(NodeStreamingChatHelper.ErrorType.AUTH_ERROR, result.errorType(),
"last seen AUTH_ERROR must propagate so callers can surface a real error");
// Each rung tried exactly once
verify(primary, times(1)).stream(any(Prompt.class));
verify(fb1, times(1)).stream(any(Prompt.class));
verify(fb2, times(1)).stream(any(Prompt.class));
// Health tracker should have recorded a failure against every fallback provider
var snap = healthTracker.snapshot();
assertTrue(snap.get("openai").consecutiveFailures() >= 1, "openai failure must be recorded");
assertTrue(snap.get("dashscope").consecutiveFailures() >= 1, "dashscope failure must be recorded");
}
// ============================================================
// C4 regression: BILLING still falls back unchanged
// ============================================================
@Test
@DisplayName("C4 (regression): primary BILLING still triggers fallback (unchanged P3.2)")
void billingStillFallsBack() {
ChatModel primary = errorModel(new RuntimeException("402 Payment Required: insufficient_quota"));
ChatModel fallback = successModel("recovered via fallback");
var helper = helper(primary, List.of(new FallbackEntry("dashscope", fallback)), "openai");
var result = helper.streamCall(primary, smallPrompt(), "conv-c4", "reasoning");
assertEquals("recovered via fallback", result.text());
verify(primary, times(1)).stream(any(Prompt.class));
verify(fallback, times(1)).stream(any(Prompt.class));
}
// ============================================================
// Bonus: confirm no infinite loop / regression on success path
// ============================================================
@Test
@DisplayName("Bonus: primary success path is unaffected — no fallback call")
void primarySuccessSkipsFallback() {
ChatModel primary = successModel("primary works fine");
AtomicInteger fallbackCalls = new AtomicInteger();
ChatModel fallback = mock(ChatModel.class);
when(fallback.stream(any(Prompt.class))).thenAnswer(inv -> {
fallbackCalls.incrementAndGet();
return Flux.just((ChatResponse) null);
});
var helper = helper(primary, List.of(new FallbackEntry("dashscope", fallback)), "openai");
var result = helper.streamCall(primary, smallPrompt(), "conv-bonus", "reasoning");
assertEquals("primary works fine", result.text());
assertEquals(0, fallbackCalls.get(), "primary success must not touch the fallback chain");
}
}