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.
This commit is contained in:
matevip 2026-04-19 17:45:15 +08:00
parent 7ba8fe602b
commit 3b11a3def6
22 changed files with 1648 additions and 52 deletions

View File

@ -131,6 +131,7 @@ public class AgentGraphBuilder {
private final vip.mate.tool.ToolConcurrencyRegistry toolConcurrencyRegistry;
private final vip.mate.i18n.I18nService i18nService;
private final vip.mate.llm.failover.ProviderHealthTracker providerHealthTracker;
private final vip.mate.llm.chatmodel.ProviderChatModelFactory chatModelFactory;
/**
* 根据 AgentEntity 构建完整的 Agent 实例
@ -501,47 +502,13 @@ public class AgentGraphBuilder {
* 本参数对它们无效它们各自有内部重试或直通
*/
public ChatModel buildRuntimeChatModel(ModelConfigEntity runtimeModel, RetryTemplate retryOverride) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(runtimeModel.getProvider());
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
if (protocol == ModelProtocol.DASHSCOPE_NATIVE) {
DashScopeApi api = buildDashScopeApi(provider);
DashScopeChatOptions options = buildDashScopeOptions(runtimeModel, provider);
return dashScopeChatModel.mutate()
.dashScopeApi(api)
.defaultOptions(options)
.build();
}
if (protocol == ModelProtocol.OPENAI_CHATGPT) {
Double temp = runtimeModel.getTemperature() != null ? runtimeModel.getTemperature() : 0.7;
return new vip.mate.llm.chatgpt.ChatGPTChatModel(
chatGPTResponsesClient, runtimeModel.getModelName(), temp);
}
if (protocol == ModelProtocol.OPENAI_COMPATIBLE) {
OpenAiApi api = buildOpenAiApi(provider);
OpenAiChatOptions options = buildOpenAiOptions(runtimeModel, provider);
return OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retryOverride)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
}
if (protocol == ModelProtocol.ANTHROPIC_MESSAGES) {
AnthropicApi api = buildAnthropicApi(provider);
AnthropicChatOptions options = buildAnthropicOptions(runtimeModel);
return AnthropicChatModel.builder()
.anthropicApi(api)
.defaultOptions(options)
.retryTemplate(retryOverride)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
}
throw new MateClawException("err.agent.protocol_limited", "StateGraph 当前仅支持 DashScope 原生协议、OpenAI-compatible 协议和 Anthropic Messages 协议: " + protocol.getId());
// PR-0 (RFC-009 Phase 4 prelude): protocol switch extracted to
// ProviderChatModelFactory + per-protocol ChatModelBuilder strategies.
// Per-protocol builders (DashScope / OpenAI-compatible / Anthropic /
// ChatGPT-Responses) live in vip.mate.agent.chatmodel + vip.mate.llm.chatmodel.
// See RFC-009 Phase 4 plan for the rationale (circular-dep break for
// ProviderInitProbe + AgentGraphBuilder slimming).
return chatModelFactory.buildFor(runtimeModel, retryOverride);
}
/**
@ -787,7 +754,8 @@ public class AgentGraphBuilder {
// ==================== 模型选项构建 ====================
private DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public DashScopeChatOptions buildDashScopeOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
DashScopeChatOptions.DashScopeChatOptionsBuilder builder = DashScopeChatOptions.builder();
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
@ -821,7 +789,8 @@ public class AgentGraphBuilder {
return builder.build();
}
private OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public OpenAiChatOptions buildOpenAiOptions(ModelConfigEntity runtimeModel, ModelProviderEntity provider) {
OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder();
Map<String, Object> kwargs = modelProviderService.readProviderGenerateKwargs(provider);
String modelName = runtimeModel.getModelName();
@ -903,7 +872,8 @@ public class AgentGraphBuilder {
// ==================== OpenAI API 构建 ====================
OpenAiApi buildOpenAiApi(ModelProviderEntity provider) {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public OpenAiApi buildOpenAiApi(ModelProviderEntity provider) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.provider_not_configured", "Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
@ -995,7 +965,8 @@ public class AgentGraphBuilder {
// ==================== DashScope API 构建 ====================
private DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public DashScopeApi buildDashScopeApi(ModelProviderEntity provider) {
DashScopeApi.Builder builder = DashScopeApi.builder();
// API Key 回落链provider UI 配置 环境变量/application.yml 默认 bean 反射
@ -1028,7 +999,8 @@ public class AgentGraphBuilder {
// ==================== Anthropic API 构建 ====================
private AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public AnthropicApi buildAnthropicApi(ModelProviderEntity provider) {
if (provider == null || !modelProviderService.isProviderConfigured(provider.getProviderId())) {
throw new MateClawException("err.agent.anthropic_not_configured", "Anthropic Provider 未完成配置,请在模型设置中填写有效的 API Key 和 Base URL");
}
@ -1051,7 +1023,8 @@ public class AgentGraphBuilder {
return builder.build();
}
private AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
/** Transitional public visibility for {@code chatmodel} sub-package builders; will move into the builder in PR-0b. */
public AnthropicChatOptions buildAnthropicOptions(ModelConfigEntity runtimeModel) {
AnthropicChatOptions.Builder builder = AnthropicChatOptions.builder();
if (StringUtils.hasText(runtimeModel.getModelName())) {
builder.model(runtimeModel.getModelName());

View File

@ -0,0 +1,51 @@
package vip.mate.agent.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.anthropic.AnthropicChatModel;
import org.springframework.ai.anthropic.AnthropicChatOptions;
import org.springframework.ai.anthropic.api.AnthropicApi;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Thin strategy adapter for {@link ModelProtocol#ANTHROPIC_MESSAGES}.
* See {@link AgentDashScopeChatModelBuilder} for the delegate-pattern rationale.
*/
@Component
public class AgentAnthropicChatModelBuilder implements ChatModelBuilder {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public AgentAnthropicChatModelBuilder(
@Lazy AgentGraphBuilder agentGraphBuilder,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.agentGraphBuilder = agentGraphBuilder;
this.observationRegistryProvider = observationRegistryProvider;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.ANTHROPIC_MESSAGES;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
AnthropicApi api = agentGraphBuilder.buildAnthropicApi(provider);
AnthropicChatOptions options = agentGraphBuilder.buildAnthropicOptions(model);
return AnthropicChatModel.builder()
.anthropicApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
}
}

View File

@ -0,0 +1,62 @@
package vip.mate.agent.chatmodel;
import com.alibaba.cloud.ai.dashscope.api.DashScopeApi;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatModel;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.context.annotation.Lazy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Thin strategy adapter for {@link ModelProtocol#DASHSCOPE_NATIVE}.
*
* <p>This is a deliberate <b>delegating</b> implementation: it calls back into
* {@link AgentGraphBuilder}'s package-private helpers ({@code buildDashScopeApi},
* {@code buildDashScopeOptions}) rather than owning the build logic itself.
* The goal of this PR is to install the strategy seam (so
* {@code ProviderChatModelFactory} can route requests without circular
* dependencies) without taking on the risk of relocating ~600 lines of
* provider-specific helpers in a single change.</p>
*
* <p>A follow-up PR (PR-0b in the plan) moves the helpers into this class so
* {@code AgentGraphBuilder} can shed the protocol-specific code entirely.</p>
*
* <p>{@code @Lazy} on the {@link AgentGraphBuilder} dependency breaks the
* factory builder AgentGraphBuilder bean-creation cycle:
* AgentGraphBuilder constructs ProviderChatModelFactory, the factory needs
* builders, and this builder needs AgentGraphBuilder back. The lazy proxy
* defers AgentGraphBuilder resolution until the first {@link #build} call.</p>
*/
@Component
public class AgentDashScopeChatModelBuilder implements ChatModelBuilder {
private final AgentGraphBuilder agentGraphBuilder;
private final DashScopeChatModel dashScopeChatModel;
public AgentDashScopeChatModelBuilder(@Lazy AgentGraphBuilder agentGraphBuilder,
DashScopeChatModel dashScopeChatModel) {
this.agentGraphBuilder = agentGraphBuilder;
this.dashScopeChatModel = dashScopeChatModel;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.DASHSCOPE_NATIVE;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
DashScopeApi api = agentGraphBuilder.buildDashScopeApi(provider);
DashScopeChatOptions options = agentGraphBuilder.buildDashScopeOptions(model, provider);
return dashScopeChatModel.mutate()
.dashScopeApi(api)
.defaultOptions(options)
.build();
}
}

View File

@ -0,0 +1,53 @@
package vip.mate.agent.chatmodel;
import io.micrometer.observation.ObservationRegistry;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.context.annotation.Lazy;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.agent.AgentGraphBuilder;
import vip.mate.llm.chatmodel.ChatModelBuilder;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Thin strategy adapter for {@link ModelProtocol#OPENAI_COMPATIBLE}.
* Delegates to {@link AgentGraphBuilder}'s helpers; see
* {@link AgentDashScopeChatModelBuilder} for the rationale of the delegate
* pattern and the {@code @Lazy} cycle break.
*/
@Component
public class AgentOpenAiCompatibleChatModelBuilder implements ChatModelBuilder {
private final AgentGraphBuilder agentGraphBuilder;
private final ObjectProvider<ObservationRegistry> observationRegistryProvider;
public AgentOpenAiCompatibleChatModelBuilder(
@Lazy AgentGraphBuilder agentGraphBuilder,
ObjectProvider<ObservationRegistry> observationRegistryProvider) {
this.agentGraphBuilder = agentGraphBuilder;
this.observationRegistryProvider = observationRegistryProvider;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_COMPATIBLE;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
OpenAiApi api = agentGraphBuilder.buildOpenAiApi(provider);
OpenAiChatOptions options = agentGraphBuilder.buildOpenAiOptions(model, provider);
return OpenAiChatModel.builder()
.openAiApi(api)
.defaultOptions(options)
.retryTemplate(retry)
.observationRegistry(observationRegistryProvider.getIfAvailable(() -> ObservationRegistry.NOOP))
.build();
}
}

View File

@ -326,10 +326,15 @@ public class NodeStreamingChatHelper {
if (lastResult.errorType() == ErrorType.PROMPT_TOO_LONG) {
return lastResult;
}
// AUTH: 不重试 但要记账auth 不会自愈连续 N 次后冷却避免每轮都撞
// AUTH: primary key 失效不会自愈跳过同模型重试交给 fallback chain
// 其它 provider key 可能仍然可用 BILLING / MODEL_NOT_FOUND 同策略
// recordPrimary(false) 仍记一次失败用于 healthTracker 冷却累计
// fallback chain 全部 401walker 末尾会把最后一次 AUTH_ERROR 透出
// 不会静默吞错
if (lastResult.errorType() == ErrorType.AUTH_ERROR) {
log.warn("[{}] Primary auth failed — skipping same-model retries, handing off to fallback chain", phase);
recordPrimary(false);
return lastResult;
break;
}
// BILLING / MODEL_NOT_FOUND provider-side hard failures
// that won't change on retry. Skip to fallback chain (a different

View File

@ -0,0 +1,38 @@
package vip.mate.llm.chatmodel;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.llm.chatgpt.ChatGPTChatModel;
import vip.mate.llm.chatgpt.ChatGPTResponsesClient;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* ChatGPT Responses API (the "/codex/responses" endpoint reached via OAuth)
* doesn't go through Spring AI's standard ChatModel builders it has its own
* {@link ChatGPTChatModel} wrapper around {@link ChatGPTResponsesClient}.
* The {@link RetryTemplate} parameter is ignored because retry happens inside
* the OAuth-aware client itself.
*/
@Component
public class ChatGPTResponsesChatModelBuilder implements ChatModelBuilder {
private final ChatGPTResponsesClient chatGPTResponsesClient;
public ChatGPTResponsesChatModelBuilder(ChatGPTResponsesClient chatGPTResponsesClient) {
this.chatGPTResponsesClient = chatGPTResponsesClient;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_CHATGPT;
}
@Override
public ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry) {
Double temp = model.getTemperature() != null ? model.getTemperature() : 0.7;
return new ChatGPTChatModel(chatGPTResponsesClient, model.getModelName(), temp);
}
}

View File

@ -0,0 +1,45 @@
package vip.mate.llm.chatmodel;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.retry.support.RetryTemplate;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Strategy contract for assembling a Spring AI {@link ChatModel} from a
* persisted {@link ModelConfigEntity} + {@link ModelProviderEntity} pair.
*
* <p>One implementation per {@link ModelProtocol}. New provider protocols
* are added by registering a new {@code @Component} that implements this
* interface no edits to {@link ProviderChatModelFactory} or
* {@code AgentGraphBuilder} required.</p>
*
* <p>This interface lives in {@code vip.mate.llm.chatmodel} (under the {@code llm}
* package) so that {@code llm.failover.ProviderInitProbe} can build a
* {@link ChatModel} for health-probing without depending on the {@code agent}
* package which would create the circular dependency
* {@code agent llm agent}. Prior to this extraction, all model-building
* logic lived inside {@code AgentGraphBuilder}.</p>
*/
public interface ChatModelBuilder {
/** The protocol this builder handles; the factory routes by this key. */
ModelProtocol supportedProtocol();
/**
* Build a fresh {@link ChatModel} for the given runtime configuration.
* Implementations must be stateless callers may invoke this many times
* for the same model id and expect equivalent (but not necessarily ==)
* results.
*
* @param model runtime model row with temperature / max tokens / etc.
* @param provider provider row supplying API key, base URL, and provider-level
* generate kwargs
* @param retry retry template to wire into the underlying Spring AI client
* where supported. Implementations whose protocols don't
* expose a Spring AI {@code RetryTemplate} hook (DashScope
* native, ChatGPT Responses) may ignore this parameter.
*/
ChatModel build(ModelConfigEntity model, ModelProviderEntity provider, RetryTemplate retry);
}

View File

@ -0,0 +1,74 @@
package vip.mate.llm.chatmodel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.stereotype.Component;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelConfigEntity;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* Routes {@link ChatModel} construction to the appropriate
* {@link ChatModelBuilder} based on the provider's declared protocol.
*
* <p>Resolution order:</p>
* <ol>
* <li>Look up the {@link ModelProviderEntity} for {@code model.getProvider()}</li>
* <li>Map its {@code chatModel} string to a {@link ModelProtocol}</li>
* <li>Dispatch to the matching {@link ChatModelBuilder}; throw if none registered</li>
* </ol>
*
* <p>This factory is the single seam that {@code AgentGraphBuilder} and
* {@code ProviderInitProbe} share for building Spring AI clients. Keeping it
* in the {@code llm} package preserves the dependency direction
* {@code agent llm} and lets the failover layer probe providers without
* pulling in the {@code agent} package.</p>
*/
@Slf4j
@Component
public class ProviderChatModelFactory {
private final Map<ModelProtocol, ChatModelBuilder> builders;
private final ModelProviderService modelProviderService;
public ProviderChatModelFactory(List<ChatModelBuilder> allBuilders,
ModelProviderService modelProviderService) {
this.modelProviderService = modelProviderService;
Map<ModelProtocol, ChatModelBuilder> map = new EnumMap<>(ModelProtocol.class);
for (ChatModelBuilder b : allBuilders) {
ChatModelBuilder previous = map.put(b.supportedProtocol(), b);
if (previous != null) {
throw new IllegalStateException(
"Two ChatModelBuilders registered for the same protocol "
+ b.supportedProtocol() + ": "
+ previous.getClass().getName() + " vs " + b.getClass().getName());
}
}
this.builders = Map.copyOf(map);
log.info("[ProviderChatModelFactory] registered builders for protocols: {}", builders.keySet());
}
/**
* Build a {@link ChatModel} for the given runtime model, looking up the
* provider on the fly. Throws {@link MateClawException} when no builder
* is registered for the resolved protocol callers should treat this as
* a configuration error, not a transient failure.
*/
public ChatModel buildFor(ModelConfigEntity model, RetryTemplate retry) {
ModelProviderEntity provider = modelProviderService.getProviderConfig(model.getProvider());
ModelProtocol protocol = ModelProtocol.fromChatModel(provider.getChatModel());
ChatModelBuilder builder = builders.get(protocol);
if (builder == null) {
throw new MateClawException("err.agent.protocol_limited",
"No ChatModelBuilder registered for protocol: " + protocol.getId());
}
return builder.build(model, provider, retry);
}
}

View File

@ -0,0 +1,122 @@
package vip.mate.llm.failover;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory registry of providers currently considered usable for new chat
* requests. Membership is the gate the failover walker checks: a provider not
* in this pool is skipped entirely without attempting an LLM call.
*
* <p>Two state transitions:</p>
* <ul>
* <li><b>Add</b> at startup ({@code ProviderInitProbe}), on user-triggered
* reprobe, or after a {@code ModelConfigChangedEvent}.</li>
* <li><b>Remove</b> when a request hits a HARD error (AUTH_ERROR /
* BILLING / MODEL_NOT_FOUND) these don't self-heal, so retrying on
* every subsequent call wastes the user's time. SOFT errors
* (RATE_LIMIT / SERVER_ERROR / EMPTY_RESPONSE) keep the provider in
* the pool and are handled by {@link ProviderHealthTracker}'s short
* cooldown instead.</li>
* </ul>
*
* <p>State is process-local; a restart re-runs the init probe. That's
* intentional full distributed coordination is out of scope for v1
* (single-node and desktop deployments are the primary targets).</p>
*/
@Slf4j
@Component
public class AvailableProviderPool {
/** Membership: providers currently usable. Add-on-success / remove-on-HARD. */
private final Set<String> members = ConcurrentHashMap.newKeySet();
/**
* Last removal reason per provider id. Cleared when the provider is
* re-added. Useful for the UI to explain "why is this provider down?"
* and for diagnostics.
*/
private final Map<String, RemovalReason> removalReasons = new ConcurrentHashMap<>();
/** Add (or re-add) a provider to the pool. Clears any prior removal reason. */
public void add(String providerId) {
if (providerId == null || providerId.isEmpty()) return;
boolean newlyAdded = members.add(providerId);
RemovalReason previous = removalReasons.remove(providerId);
if (newlyAdded && previous != null) {
log.info("[Pool] re-adding provider={} (previous removal: {})", providerId, previous);
} else if (newlyAdded) {
log.info("[Pool] adding provider={}", providerId);
}
}
/**
* Remove a provider from the pool with a reason. Idempotent calling
* twice updates the reason (so the latest cause wins) but doesn't double-log.
*/
public void remove(String providerId, RemovalSource source, String message) {
if (providerId == null || providerId.isEmpty()) return;
boolean wasMember = members.remove(providerId);
RemovalReason reason = new RemovalReason(source, message, Instant.now().toEpochMilli());
removalReasons.put(providerId, reason);
if (wasMember) {
log.warn("[Pool] removing provider={} due to {} ({})", providerId, source, message);
} else {
log.debug("[Pool] removal reason updated for already-out provider={}: {} ({})",
providerId, source, message);
}
}
/** Membership check — the walker / primary short-circuit consults this on every entry. */
public boolean contains(String providerId) {
return providerId != null && members.contains(providerId);
}
/**
* Diagnostic snapshot for admin endpoints / tests. Returns providerId
* either {@code null} (in pool) or the latest {@link RemovalReason}.
* The returned map is a stable copy; in-pool entries are present with
* {@code null} value so callers can iterate the union of in/out.
*/
public Map<String, RemovalReason> snapshot() {
Map<String, RemovalReason> out = new LinkedHashMap<>();
for (String id : members) out.put(id, null);
removalReasons.forEach((id, reason) -> {
if (!out.containsKey(id)) out.put(id, reason);
});
return out;
}
/** Clear everything. Intended for tests; no production code path calls this. */
void reset() {
members.clear();
removalReasons.clear();
}
// ============================================================
// Records
// ============================================================
/** Why a provider was removed from the pool. {@code removedAtMs} is epoch milliseconds. */
public record RemovalReason(RemovalSource source, String message, long removedAtMs) {}
/**
* Categorical source of a pool removal. Mirrors the HARD error types from
* {@code NodeStreamingChatHelper.ErrorType} plus {@link #INIT_PROBE} for
* startup probe failures. SOFT errors (RATE_LIMIT / SERVER_ERROR) never
* appear here they're handled by {@link ProviderHealthTracker} cooldown.
*/
public enum RemovalSource {
AUTH_ERROR,
BILLING,
MODEL_NOT_FOUND,
INIT_PROBE,
MANUAL
}
}

View File

@ -0,0 +1,19 @@
package vip.mate.llm.failover;
/**
* Outcome of a single provider health probe.
*
* @param success true if the provider responded usable
* @param latencyMs wall-clock latency of the probe in milliseconds; {@code 0} if it threw before measuring
* @param errorMessage human-readable failure detail; {@code null} on success
*/
public record ProbeResult(boolean success, long latencyMs, String errorMessage) {
public static ProbeResult ok(long latencyMs) {
return new ProbeResult(true, latencyMs, null);
}
public static ProbeResult fail(long latencyMs, String message) {
return new ProbeResult(false, latencyMs, message);
}
}

View File

@ -0,0 +1,197 @@
package vip.mate.llm.failover;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.repository.ModelProviderMapper;
import vip.mate.llm.service.ModelProviderService;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.*;
/**
* RFC-009 Phase 4 startup-time provider liveness check.
*
* <p>On {@link ApplicationReadyEvent} (after Flyway, all beans, and
* {@link ModelProviderService} are ready), enumerates every configured
* provider and probes it in parallel via the protocol-specific
* {@link ProviderProbeStrategy} bean. Healthy providers are added to
* {@link AvailableProviderPool}; failed ones are removed with
* {@link AvailableProviderPool.RemovalSource#INIT_PROBE}.</p>
*
* <p><b>Fail-open semantics</b> the whole batch is bounded by
* {@link #BATCH_TIMEOUT_MS} (10 s). Any provider whose probe is still in
* flight at that point is added to the pool by default; the chat path
* will validate it on first request. This avoids an SSL hang on one
* provider gating user-visible chat.</p>
*
* <p><b>What's NOT done here</b> periodic re-probing. The plan calls
* for re-add triggers only on (a) restart (this class), (b) a
* {@code ModelConfigChangedEvent} (PR-1e), or (c) the manual reprobe
* REST endpoint (PR-1e). Once a provider is HARD-removed at runtime,
* nothing in this class brings it back automatically.</p>
*/
@Slf4j
@Component
public class ProviderInitProbe {
/** Hard ceiling on the parallel batch — stalled probes don't block startup beyond this. */
private static final long BATCH_TIMEOUT_MS = 10_000L;
private final ModelProviderMapper providerMapper;
private final ModelProviderService providerService;
private final AvailableProviderPool pool;
private final Map<ModelProtocol, ProviderProbeStrategy> strategies;
public ProviderInitProbe(ModelProviderMapper providerMapper,
ModelProviderService providerService,
AvailableProviderPool pool,
List<ProviderProbeStrategy> probeStrategies) {
this.providerMapper = providerMapper;
this.providerService = providerService;
this.pool = pool;
Map<ModelProtocol, ProviderProbeStrategy> map = new EnumMap<>(ModelProtocol.class);
for (ProviderProbeStrategy s : probeStrategies) {
ProviderProbeStrategy prev = map.put(s.supportedProtocol(), s);
if (prev != null) {
throw new IllegalStateException("Duplicate ProviderProbeStrategy for protocol "
+ s.supportedProtocol() + ": " + prev.getClass().getSimpleName()
+ " vs " + s.getClass().getSimpleName());
}
}
this.strategies = map;
}
@EventListener(ApplicationReadyEvent.class)
public void onApplicationReady() {
probeAllConfigured();
}
/**
* Probe every configured provider in parallel. Public so PR-1e's manual
* reprobe / config-changed listener can trigger a full refresh, not just
* a single-provider one.
*/
public void probeAllConfigured() {
List<ModelProviderEntity> providers = listConfiguredProviders();
if (providers.isEmpty()) {
log.info("[ProviderInitProbe] no configured providers — nothing to probe");
return;
}
log.info("[ProviderInitProbe] probing {} configured provider(s)...", providers.size());
ExecutorService executor = Executors.newFixedThreadPool(
Math.min(providers.size(), 8),
r -> {
Thread t = new Thread(r, "provider-init-probe");
t.setDaemon(true);
return t;
});
Map<String, Future<ProbeResult>> futures = new ConcurrentHashMap<>();
try {
for (ModelProviderEntity provider : providers) {
ProviderProbeStrategy strategy = strategies.get(resolveProtocol(provider));
if (strategy == null) {
// No probe strategy registered for this protocol fail-open: assume usable.
log.debug("[ProviderInitProbe] no probe strategy for {} (protocol={}), defaulting to in-pool",
provider.getProviderId(), provider.getChatModel());
pool.add(provider.getProviderId());
continue;
}
futures.put(provider.getProviderId(),
executor.submit(() -> strategy.probe(provider)));
}
long deadline = System.currentTimeMillis() + BATCH_TIMEOUT_MS;
int passed = 0, failed = 0, deferred = 0;
for (Map.Entry<String, Future<ProbeResult>> e : futures.entrySet()) {
String id = e.getKey();
long remaining = deadline - System.currentTimeMillis();
ProbeResult result = null;
if (remaining > 0) {
try {
result = e.getValue().get(remaining, TimeUnit.MILLISECONDS);
} catch (TimeoutException te) {
// fall through to deferred
} catch (Exception ex) {
result = ProbeResult.fail(0, "probe threw: " + ex.getClass().getSimpleName());
}
}
if (result == null) {
// Fail-open: didn't finish within the batch budget; assume usable so chat
// isn't gated on a slow probe. First real request will validate.
pool.add(id);
deferred++;
log.warn("[ProviderInitProbe] provider={} probe deferred (still running at {}ms cap) — fail-open into pool",
id, BATCH_TIMEOUT_MS);
} else if (result.success()) {
pool.add(id);
passed++;
log.info("[ProviderInitProbe] provider={} OK ({} ms)", id, result.latencyMs());
} else {
pool.remove(id, AvailableProviderPool.RemovalSource.INIT_PROBE,
"init probe failed: " + result.errorMessage());
failed++;
log.warn("[ProviderInitProbe] provider={} FAIL ({} ms): {}",
id, result.latencyMs(), result.errorMessage());
}
}
log.info("[ProviderInitProbe] done — passed={}, failed={}, deferred={}, pool size={}",
passed, failed, deferred, pool.snapshot().size());
} finally {
// Outstanding futures are interrupted; threads are daemon and won't hold shutdown.
executor.shutdownNow();
}
}
/**
* Probe one provider on demand. Used by PR-1e's manual reprobe endpoint
* and {@code ModelConfigChangedEvent} listener.
*
* @return the {@link ProbeResult}; pool state is also updated as a side effect.
*/
public ProbeResult probeOne(String providerId) {
if (!StringUtils.hasText(providerId)) {
return ProbeResult.fail(0, "providerId is blank");
}
ModelProviderEntity provider = providerMapper.selectById(providerId);
if (provider == null) {
return ProbeResult.fail(0, "provider not found: " + providerId);
}
if (!providerService.isProviderConfigured(providerId)) {
ProbeResult r = ProbeResult.fail(0, "provider not configured");
pool.remove(providerId, AvailableProviderPool.RemovalSource.INIT_PROBE, r.errorMessage());
return r;
}
ProviderProbeStrategy strategy = strategies.get(resolveProtocol(provider));
if (strategy == null) {
// No strategy for this protocol fail-open: assume usable.
pool.add(providerId);
return ProbeResult.ok(0);
}
ProbeResult result = strategy.probe(provider);
if (result.success()) {
pool.add(providerId);
} else {
pool.remove(providerId, AvailableProviderPool.RemovalSource.INIT_PROBE,
"reprobe failed: " + result.errorMessage());
}
return result;
}
private List<ModelProviderEntity> listConfiguredProviders() {
return providerMapper.selectList(null).stream()
.filter(p -> providerService.isProviderConfigured(p.getProviderId()))
.toList();
}
private static ModelProtocol resolveProtocol(ModelProviderEntity provider) {
return ModelProtocol.fromChatModel(provider.getChatModel());
}
}

View File

@ -0,0 +1,34 @@
package vip.mate.llm.failover;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
/**
* Strategy contract for actively probing one provider's reachability.
*
* <p>One implementation per {@link ModelProtocol}. {@code ProviderInitProbe}
* dispatches by {@link #supportedProtocol()}. New protocols add a new
* {@code @Component} implementing this interface no edits to
* {@code ProviderInitProbe} required.</p>
*
* <p>Implementations should:</p>
* <ul>
* <li>Prefer free endpoints (e.g., {@code GET /v1/models} for OpenAI / Anthropic)
* over chat completions whenever possible keeps probe cost at zero.</li>
* <li>Use a short HTTP timeout (~5s) so a stalled probe doesn't block
* the parallel batch.</li>
* <li>Never throw wrap exceptions into {@link ProbeResult#fail}.</li>
* </ul>
*/
public interface ProviderProbeStrategy {
/** The protocol this probe handles; {@code ProviderInitProbe} routes by this key. */
ModelProtocol supportedProtocol();
/**
* Probe the given provider. Must complete (success or fail) within the
* caller's timeout budget. Implementations are expected to honor a
* conservative HTTP timeout (~5s) internally.
*/
ProbeResult probe(ModelProviderEntity provider);
}

View File

@ -0,0 +1,76 @@
package vip.mate.llm.failover.probe;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
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.RestClient;
import vip.mate.llm.failover.ProbeResult;
import vip.mate.llm.failover.ProviderProbeStrategy;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import java.net.http.HttpClient;
import java.time.Duration;
/**
* Probes Anthropic by calling {@code GET /v1/models} free, validates the
* x-api-key header in one round-trip. Default base URL is
* {@code https://api.anthropic.com} when the provider doesn't override it.
*/
@Slf4j
@Component
public class AnthropicListModelsProbe implements ProviderProbeStrategy {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final String DEFAULT_BASE = "https://api.anthropic.com";
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.ANTHROPIC_MESSAGES;
}
@Override
public ProbeResult probe(ModelProviderEntity provider) {
if (provider == null || !StringUtils.hasText(provider.getApiKey())) {
return ProbeResult.fail(0, "API key not configured");
}
String baseUrl = StringUtils.hasText(provider.getBaseUrl())
? normalizeBaseUrl(provider.getBaseUrl())
: DEFAULT_BASE;
long start = System.currentTimeMillis();
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
RestClient client = RestClient.builder()
.baseUrl(baseUrl)
.requestFactory(new JdkClientHttpRequestFactory(httpClient))
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader("x-api-key", provider.getApiKey().trim())
.defaultHeader("anthropic-version", "2023-06-01")
.build();
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 (Exception e) {
long latency = System.currentTimeMillis() - start;
log.debug("[Probe] anthropic /v1/models failed: {}", e.getMessage());
return ProbeResult.fail(latency, shortMessage(e));
}
}
private static String normalizeBaseUrl(String url) {
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
private static String shortMessage(Throwable t) {
String m = t.getMessage();
if (m == null) m = t.getClass().getSimpleName();
return m.length() > 200 ? m.substring(0, 200) + "..." : m;
}
}

View File

@ -0,0 +1,67 @@
package vip.mate.llm.failover.probe;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.llm.failover.ProbeResult;
import vip.mate.llm.failover.ProviderProbeStrategy;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.oauth.OpenAIOAuthService;
/**
* Probes the ChatGPT Responses provider by validating its OAuth credentials.
* No HTTP call: chatgpt.com/backend-api has no free liveness endpoint, and a
* real chat completion costs tokens so we ping nothing and instead verify
* the OAuth state stored locally:
* <ol>
* <li>access token present (and refreshable if near expiry, via
* {@link OpenAIOAuthService#ensureValidAccessToken()}),</li>
* <li>account id resolvable (header required by every Responses call).</li>
* </ol>
* If both succeed without throwing, the credentials are usable. A real auth
* outage is then surfaced reactively on first chat (HARD-removed by
* {@code NodeStreamingChatHelper}).
*/
@Slf4j
@Component
public class ChatGPTOAuthStatusProbe implements ProviderProbeStrategy {
private final OpenAIOAuthService oauthService;
public ChatGPTOAuthStatusProbe(OpenAIOAuthService oauthService) {
this.oauthService = oauthService;
}
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_CHATGPT;
}
@Override
public ProbeResult probe(ModelProviderEntity provider) {
long start = System.currentTimeMillis();
try {
String token = oauthService.ensureValidAccessToken();
if (!StringUtils.hasText(token)) {
return ProbeResult.fail(System.currentTimeMillis() - start, "OAuth access token missing");
}
String accountId = oauthService.getAccountId();
if (!StringUtils.hasText(accountId)) {
return ProbeResult.fail(System.currentTimeMillis() - start,
"OAuth account id missing — re-login required");
}
return ProbeResult.ok(System.currentTimeMillis() - start);
} catch (Exception e) {
long latency = System.currentTimeMillis() - start;
log.debug("[Probe] chatgpt OAuth status failed: {}", e.getMessage());
return ProbeResult.fail(latency, shortMessage(e));
}
}
private static String shortMessage(Throwable t) {
String m = t.getMessage();
if (m == null) m = t.getClass().getSimpleName();
return m.length() > 200 ? m.substring(0, 200) + "..." : m;
}
}

View File

@ -0,0 +1,71 @@
package vip.mate.llm.failover.probe;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
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.RestClient;
import vip.mate.llm.failover.ProbeResult;
import vip.mate.llm.failover.ProviderProbeStrategy;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import java.net.http.HttpClient;
import java.time.Duration;
/**
* Probes DashScope by hitting its OpenAI-compatible {@code /v1/models}
* endpoint at {@code https://dashscope.aliyuncs.com/compatible-mode}.
* The native DashScope protocol has no equivalent free endpoint, but the
* compatible-mode listing is free and authenticates the same API key. The
* runtime native chat path still uses the native endpoint; this is purely
* a liveness probe.
*/
@Slf4j
@Component
public class DashScopeListModelsProbe implements ProviderProbeStrategy {
private static final Duration TIMEOUT = Duration.ofSeconds(5);
private static final String COMPATIBLE_BASE = "https://dashscope.aliyuncs.com/compatible-mode";
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.DASHSCOPE_NATIVE;
}
@Override
public ProbeResult probe(ModelProviderEntity provider) {
if (provider == null || !StringUtils.hasText(provider.getApiKey())) {
return ProbeResult.fail(0, "API key not configured");
}
long start = System.currentTimeMillis();
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
RestClient client = RestClient.builder()
.baseUrl(COMPATIBLE_BASE)
.requestFactory(new JdkClientHttpRequestFactory(httpClient))
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + provider.getApiKey().trim())
.build();
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 (Exception e) {
long latency = System.currentTimeMillis() - start;
log.debug("[Probe] dashscope /v1/models failed: {}", e.getMessage());
return ProbeResult.fail(latency, shortMessage(e));
}
}
private static String shortMessage(Throwable t) {
String m = t.getMessage();
if (m == null) m = t.getClass().getSimpleName();
return m.length() > 200 ? m.substring(0, 200) + "..." : m;
}
}

View File

@ -0,0 +1,81 @@
package vip.mate.llm.failover.probe;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
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.RestClient;
import vip.mate.llm.failover.ProbeResult;
import vip.mate.llm.failover.ProviderProbeStrategy;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import java.net.http.HttpClient;
import java.time.Duration;
/**
* 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.
*/
@Slf4j
@Component
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);
@Override
public ModelProtocol supportedProtocol() {
return ModelProtocol.OPENAI_COMPATIBLE;
}
@Override
public ProbeResult probe(ModelProviderEntity provider) {
if (provider == null || !StringUtils.hasText(provider.getBaseUrl())) {
return ProbeResult.fail(0, "base URL not configured");
}
long start = System.currentTimeMillis();
try {
HttpClient httpClient = HttpClient.newBuilder().connectTimeout(TIMEOUT).build();
RestClient client = RestClient.builder()
.baseUrl(normalizeBaseUrl(provider.getBaseUrl()))
.requestFactory(new JdkClientHttpRequestFactory(httpClient))
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
RestClient.RequestHeadersSpec<?> spec = client.get().uri("/v1/models");
String apiKey = provider.getApiKey();
if (StringUtils.hasText(apiKey)) {
spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey.trim());
}
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");
}
return ProbeResult.ok(latency);
} catch (Exception e) {
long latency = System.currentTimeMillis() - start;
log.debug("[Probe] {} /v1/models failed: {}", provider.getProviderId(), 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.
return url.endsWith("/") ? url.substring(0, url.length() - 1) : url;
}
private static String shortMessage(Throwable t) {
String m = t.getMessage();
if (m == null) m = t.getClass().getSimpleName();
return m.length() > 200 ? m.substring(0, 200) + "..." : m;
}
}

View File

@ -0,0 +1,190 @@
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");
}
}

View File

@ -0,0 +1,153 @@
package vip.mate.llm.failover;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import static vip.mate.llm.failover.AvailableProviderPool.RemovalSource;
/**
* Unit tests for {@link AvailableProviderPool} the membership data structure
* that gates the failover walker.
*/
class AvailableProviderPoolTest {
private AvailableProviderPool pool;
@BeforeEach
void setUp() {
pool = new AvailableProviderPool();
}
@Test
@DisplayName("New pool: nothing is in it")
void newPoolEmpty() {
assertFalse(pool.contains("openai"));
assertTrue(pool.snapshot().isEmpty());
}
@Test
@DisplayName("add then contains")
void addThenContains() {
pool.add("openai");
assertTrue(pool.contains("openai"));
assertFalse(pool.contains("anthropic"));
}
@Test
@DisplayName("Adding twice is idempotent")
void addIdempotent() {
pool.add("openai");
pool.add("openai");
assertTrue(pool.contains("openai"));
assertEquals(1, pool.snapshot().size());
}
@Test
@DisplayName("Remove after add: pool no longer contains, snapshot exposes reason")
void removeAfterAdd() {
pool.add("openai");
pool.remove("openai", RemovalSource.AUTH_ERROR, "401 Unauthorized");
assertFalse(pool.contains("openai"));
var snap = pool.snapshot();
assertEquals(1, snap.size());
assertNotNull(snap.get("openai"));
assertEquals(RemovalSource.AUTH_ERROR, snap.get("openai").source());
assertEquals("401 Unauthorized", snap.get("openai").message());
assertTrue(snap.get("openai").removedAtMs() > 0, "removedAtMs must be set");
}
@Test
@DisplayName("Remove without prior add still records reason (idempotent removal)")
void removeWithoutAddIsIdempotent() {
pool.remove("openai", RemovalSource.INIT_PROBE, "init failed");
assertFalse(pool.contains("openai"));
assertNotNull(pool.snapshot().get("openai"));
}
@Test
@DisplayName("Re-add after remove: contains true, removal reason cleared")
void readdClearsRemovalReason() {
pool.add("openai");
pool.remove("openai", RemovalSource.AUTH_ERROR, "bad key");
assertNotNull(pool.snapshot().get("openai"));
pool.add("openai");
assertTrue(pool.contains("openai"));
// Snapshot now shows openai in pool (value null), no stale reason
assertNull(pool.snapshot().get("openai"),
"re-adding a provider must clear its prior removal reason");
}
@Test
@DisplayName("Snapshot mixes in-pool (value=null) and removed (value=reason) entries")
void snapshotMixedView() {
pool.add("openai");
pool.add("dashscope");
pool.remove("anthropic", RemovalSource.MODEL_NOT_FOUND, "model claude-99 not found");
var snap = pool.snapshot();
assertEquals(3, snap.size());
assertNull(snap.get("openai"), "in-pool members appear with null value");
assertNull(snap.get("dashscope"));
assertNotNull(snap.get("anthropic"));
assertEquals(RemovalSource.MODEL_NOT_FOUND, snap.get("anthropic").source());
}
@Test
@DisplayName("Null/empty providerId is a no-op (defensive)")
void nullEmptySafe() {
pool.add(null);
pool.add("");
pool.remove(null, RemovalSource.AUTH_ERROR, "x");
pool.remove("", RemovalSource.AUTH_ERROR, "x");
assertFalse(pool.contains(null));
assertFalse(pool.contains(""));
assertTrue(pool.snapshot().isEmpty(),
"null/empty inputs must not pollute the snapshot");
}
@Test
@DisplayName("Concurrent add + remove + contains is thread-safe")
void concurrentAccess() throws Exception {
int threads = 16;
int opsPerThread = 5_000;
ExecutorService pool2 = Executors.newFixedThreadPool(threads);
CountDownLatch start = new CountDownLatch(1);
CountDownLatch done = new CountDownLatch(threads);
for (int t = 0; t < threads; t++) {
int worker = t;
pool2.submit(() -> {
try {
start.await();
for (int i = 0; i < opsPerThread; i++) {
String id = "p" + (worker * 10 + (i % 10)); // shared id space
if (i % 3 == 0) pool.add(id);
else if (i % 3 == 1) pool.remove(id, RemovalSource.AUTH_ERROR, "race");
else pool.contains(id);
}
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt();
} finally {
done.countDown();
}
});
}
start.countDown();
assertTrue(done.await(30, TimeUnit.SECONDS), "concurrent workload must complete in 30s");
pool2.shutdown();
// Internal state must remain consistent each id is either in members OR has a removal reason
// (or both the union is also fine), and snapshot doesn't NPE.
var snap = pool.snapshot();
assertNotNull(snap);
}
}

View File

@ -0,0 +1,261 @@
package vip.mate.llm.failover;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.llm.model.ModelProtocol;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.repository.ModelProviderMapper;
import vip.mate.llm.service.ModelProviderService;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Function;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Verifies the startup-probe orchestration:
* <ul>
* <li>Healthy probes provider added to pool.</li>
* <li>Failed probes provider removed with INIT_PROBE source.</li>
* <li>Slow probe fail-open (in-pool) so chat isn't gated by a stalled probe.</li>
* <li>Missing strategy fail-open (in-pool).</li>
* <li>{@code probeOne} updates pool state on demand.</li>
* <li>Duplicate strategies for the same protocol fail-fast at construction.</li>
* </ul>
*
* <p>Strategies are real test-double instances (not Mockito mocks) so we can
* inject latency or throw cheaply; the mapper / service collaborators are
* stock Mockito mocks because they're MyBatis-Plus / Spring beans.</p>
*/
class ProviderInitProbeTest {
private ModelProviderMapper mapper;
private ModelProviderService providerService;
private AvailableProviderPool pool;
@BeforeEach
void setUp() {
mapper = mock(ModelProviderMapper.class);
providerService = mock(ModelProviderService.class);
pool = new AvailableProviderPool();
}
@Test
@DisplayName("All strategies pass: every configured provider lands in the pool")
void allHealthy() {
ModelProviderEntity openai = provider("openai", ModelProtocol.OPENAI_COMPATIBLE);
ModelProviderEntity anthropic = provider("anthropic", ModelProtocol.ANTHROPIC_MESSAGES);
ModelProviderEntity dashscope = provider("dashscope", ModelProtocol.DASHSCOPE_NATIVE);
configure(List.of(openai, anthropic, dashscope), id -> true);
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.ok(10)),
stub(ModelProtocol.ANTHROPIC_MESSAGES, p -> ProbeResult.ok(20)),
stub(ModelProtocol.DASHSCOPE_NATIVE, p -> ProbeResult.ok(30))));
probe.probeAllConfigured();
assertTrue(pool.contains("openai"));
assertTrue(pool.contains("anthropic"));
assertTrue(pool.contains("dashscope"));
}
@Test
@DisplayName("Failed probe removes provider with INIT_PROBE source and the error message")
void failurePathRemovesWithReason() {
configure(List.of(provider("openai", ModelProtocol.OPENAI_COMPATIBLE)), id -> true);
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.fail(50, "401 Unauthorized"))));
probe.probeAllConfigured();
assertFalse(pool.contains("openai"));
var reason = pool.snapshot().get("openai");
assertNotNull(reason);
assertEquals(AvailableProviderPool.RemovalSource.INIT_PROBE, reason.source());
assertTrue(reason.message().contains("401 Unauthorized"),
"removal message must surface the underlying probe error");
}
@Test
@DisplayName("Mixed batch: pass + fail in one run leaves correct pool state")
void mixedBatch() {
configure(List.of(
provider("openai", ModelProtocol.OPENAI_COMPATIBLE),
provider("anthropic", ModelProtocol.ANTHROPIC_MESSAGES)), id -> true);
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.ok(10)),
stub(ModelProtocol.ANTHROPIC_MESSAGES, p -> ProbeResult.fail(15, "auth"))));
probe.probeAllConfigured();
assertTrue(pool.contains("openai"));
assertFalse(pool.contains("anthropic"));
assertEquals(AvailableProviderPool.RemovalSource.INIT_PROBE,
pool.snapshot().get("anthropic").source());
}
@Test
@DisplayName("Strategy throwing is treated as a probe failure (no startup crash)")
void strategyThrowsHandledAsFailure() {
configure(List.of(provider("openai", ModelProtocol.OPENAI_COMPATIBLE)), id -> true);
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> {
throw new RuntimeException("network down");
})));
probe.probeAllConfigured();
assertFalse(pool.contains("openai"),
"a throwing strategy must not leave the provider falsely in-pool");
assertNotNull(pool.snapshot().get("openai"));
}
@Test
@DisplayName("No strategy registered for protocol: fail-open (provider stays in pool)")
void missingStrategyFailsOpen() {
configure(List.of(provider("gemini", ModelProtocol.GEMINI_NATIVE)), id -> true);
// Empty strategy list no GEMINI_NATIVE handler.
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of());
probe.probeAllConfigured();
assertTrue(pool.contains("gemini"),
"without a probe strategy we must default to in-pool, not block chat");
}
@Test
@DisplayName("No configured providers: probe is a no-op, pool stays empty")
void emptyConfigurationIsNoOp() {
configure(List.of(), id -> false);
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.ok(0))));
probe.probeAllConfigured();
assertTrue(pool.snapshot().isEmpty());
}
@Test
@DisplayName("Unconfigured providers are skipped (not probed and not added)")
void unconfiguredSkipped() {
ModelProviderEntity openai = provider("openai", ModelProtocol.OPENAI_COMPATIBLE);
ModelProviderEntity anthropic = provider("anthropic", ModelProtocol.ANTHROPIC_MESSAGES);
// mapper returns both, but only openai is "configured"
when(mapper.selectList(any())).thenReturn(List.of(openai, anthropic));
when(providerService.isProviderConfigured("openai")).thenReturn(true);
when(providerService.isProviderConfigured("anthropic")).thenReturn(false);
AtomicInteger anthropicCalls = new AtomicInteger();
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.ok(0)),
stub(ModelProtocol.ANTHROPIC_MESSAGES, p -> {
anthropicCalls.incrementAndGet();
return ProbeResult.ok(0);
})));
probe.probeAllConfigured();
assertTrue(pool.contains("openai"));
assertFalse(pool.contains("anthropic"));
assertEquals(0, anthropicCalls.get(),
"unconfigured providers must not even be probed");
}
@Test
@DisplayName("probeOne(unknown) returns failure and does not pollute pool")
void probeOneUnknownProvider() {
when(mapper.selectById(anyString())).thenReturn(null);
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of());
ProbeResult r = probe.probeOne("ghost");
assertFalse(r.success());
assertTrue(pool.snapshot().isEmpty());
}
@Test
@DisplayName("probeOne(unconfigured) HARD-removes from pool")
void probeOneUnconfigured() {
when(mapper.selectById("openai")).thenReturn(provider("openai", ModelProtocol.OPENAI_COMPATIBLE));
when(providerService.isProviderConfigured("openai")).thenReturn(false);
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of());
ProbeResult r = probe.probeOne("openai");
assertFalse(r.success());
assertFalse(pool.contains("openai"));
assertEquals(AvailableProviderPool.RemovalSource.INIT_PROBE,
pool.snapshot().get("openai").source());
}
@Test
@DisplayName("probeOne(healthy) re-adds previously-removed provider to pool")
void probeOneRecoversRemovedProvider() {
when(mapper.selectById("openai")).thenReturn(provider("openai", ModelProtocol.OPENAI_COMPATIBLE));
when(providerService.isProviderConfigured("openai")).thenReturn(true);
// Pre-remove openai to simulate a HARD-error eviction.
pool.remove("openai", AvailableProviderPool.RemovalSource.AUTH_ERROR, "401");
assertFalse(pool.contains("openai"));
ProviderInitProbe probe = new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.ok(5))));
ProbeResult r = probe.probeOne("openai");
assertTrue(r.success());
assertTrue(pool.contains("openai"),
"a successful reprobe must rehabilitate a previously removed provider");
}
@Test
@DisplayName("Duplicate strategy for same protocol fails-fast at construction")
void duplicateStrategyRejected() {
IllegalStateException ex = assertThrows(IllegalStateException.class, () ->
new ProviderInitProbe(mapper, providerService, pool, List.of(
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.ok(0)),
stub(ModelProtocol.OPENAI_COMPATIBLE, p -> ProbeResult.ok(0)))));
assertTrue(ex.getMessage().contains("OPENAI_COMPATIBLE"));
}
// ============================================================
// Helpers
// ============================================================
private static ModelProviderEntity provider(String id, ModelProtocol protocol) {
ModelProviderEntity p = new ModelProviderEntity();
p.setProviderId(id);
p.setName(id);
p.setChatModel(protocol.getChatModelClass());
p.setApiKey("sk-test");
p.setBaseUrl("https://example.com");
return p;
}
/** Wires the mapper and service so {@code listConfiguredProviders()} returns the given list,
* filtered through {@code configuredPredicate}. */
private void configure(List<ModelProviderEntity> all, Function<String, Boolean> configuredPredicate) {
when(mapper.selectList(any())).thenReturn(all);
Map<String, Boolean> map = new HashMap<>();
for (ModelProviderEntity p : all) {
map.put(p.getProviderId(), configuredPredicate.apply(p.getProviderId()));
}
when(providerService.isProviderConfigured(anyString()))
.thenAnswer(inv -> map.getOrDefault(inv.<String>getArgument(0), false));
}
/** Lambda-driven fake of {@link ProviderProbeStrategy}. */
private static ProviderProbeStrategy stub(ModelProtocol protocol,
Function<ModelProviderEntity, ProbeResult> body) {
return new ProviderProbeStrategy() {
@Override public ModelProtocol supportedProtocol() { return protocol; }
@Override public ProbeResult probe(ModelProviderEntity provider) { return body.apply(provider); }
};
}
}

View File

@ -73,6 +73,11 @@ export default {
description: 'Your login session has expired and you need to sign in again.',
action: 'You will be redirected to the login page.',
},
provider_auth_error: {
title: 'Model authentication failed',
description: 'The current model\'s API key is invalid or has expired. This is unrelated to your login session.',
action: 'Open Settings → Models to check and update the API key.',
},
forbidden: {
title: 'Access denied',
description: 'You don\'t have permission to perform this action.',

View File

@ -73,6 +73,11 @@ export default {
description: '您的登录凭证已失效,需要重新登录。',
action: '页面将自动跳转到登录页。',
},
provider_auth_error: {
title: '模型认证失败',
description: '当前模型的 API Key 无效或已失效,与你的登录状态无关。',
action: '请到「设置 → 模型」中检查并更新该模型的 API Key。',
},
forbidden: {
title: '没有权限',
description: '您没有执行此操作的权限。',

View File

@ -5,7 +5,8 @@
export type ChatErrorCategory =
| 'rate_limit' // 429
| 'auth_expired' // 401
| 'auth_expired' // user-side session expired (our backend 401) — triggers /login redirect
| 'provider_auth_error' // LLM provider 401 (e.g. invalid Kimi/OpenAI API key) — unrelated to user login
| 'forbidden' // 403
| 'bad_request' // 400
| 'server_error' // 500
@ -62,7 +63,11 @@ const BACKEND_ERROR_TYPE_MAP: Record<string, { category: ChatErrorCategory; retr
RATE_LIMIT: { category: 'rate_limit', retryable: true },
SERVER_ERROR: { category: 'server_error', retryable: true },
PROMPT_TOO_LONG: { category: 'bad_request', retryable: false },
AUTH_ERROR: { category: 'auth_expired', retryable: false },
// RFC fix: backend AUTH_ERROR comes from the LLM provider (e.g. Kimi 401),
// NOT from the user's own session expiring. Map to provider_auth_error so
// the UI shows "model authentication failed" instead of "session expired /
// redirecting to login".
AUTH_ERROR: { category: 'provider_auth_error', retryable: false },
// 后端 ErrorType.CLIENT_ERROR 对应 HTTP 400 类错误(比如模型不支持 tools、参数格式错误
// 归类到 bad_request配合 MessageBubble 优先展示 rawMessage
// 让后端 extractUserFriendlyError 返回的具体中文提示能真正显示出来。
@ -94,9 +99,18 @@ export function classifyBackendError(data: {
* "[错误] LLM 调用失败: 请求频率过高,请稍后重试"
* errorInfo
*/
// Order matters: the narrow auth_expired pattern MUST come before the broader
// provider_auth_error pattern, otherwise legitimate session-expiry messages
// would be misclassified as a model auth issue.
//
// auth_expired = our own backend's session expired (token invalid, will redirect to /login)
// provider_auth_error = LLM provider returned 401 (e.g. Kimi API key invalid; user stays logged in)
const ERROR_TEXT_PATTERNS: Array<{ pattern: RegExp; category: ChatErrorCategory; retryable: boolean }> = [
{ pattern: /频率|rate.?limit|too.?many|quota|429/i, category: 'rate_limit', retryable: true },
{ pattern: /认证|auth|unauthorized|401/i, category: 'auth_expired', retryable: false },
// Narrow: only fire on explicit signals that the user's own session is gone.
{ pattern: /HTTP 401|登录已过期|session.?expired|凭证.*失效/i, category: 'auth_expired', retryable: false },
// Broad: any other 401-ish wording is treated as a model-side auth failure.
{ pattern: /unauthorized|401|invalid.?api.?key|api.?key.*expired|认证失败/i, category: 'provider_auth_error', retryable: false },
{ pattern: /权限|forbidden|403/i, category: 'forbidden', retryable: false },
{ pattern: /过长|too.?long|context.?length|prompt/i, category: 'bad_request', retryable: false },
{ pattern: /超时|timeout/i, category: 'timeout', retryable: true },