mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(llm): multi-model failover chain driven by per-provider priority
Replaces the hardcoded single-DashScope fallback with a DB-driven ordered chain. Same-provider primary deployments (e.g., DashScope qwen-max) finally get a real fallback; if any provider in the chain returns an empty body or transient failure, the next is tried. Schema — DB-driven chain - mate_model_provider gains `fallback_priority INT DEFAULT 0`. Positive values define try-order; 0 = not in chain. Migration V21 (h2 + mysql) seeds DashScope as priority 1 to preserve existing behavior. - ModelProviderService.listFallbackChain() returns providers ordered by priority ascending. - ModelProviderEntity gains the new field. Runtime — chain walk + empty-response trigger - AgentGraphBuilder.buildFallbackChain(primaryConfig) returns a List<ChatModel>, identity-filtering the primary by (providerId, modelName) — fixes the bug where same-provider-primary deployments got null fallback. Providers whose API key is missing are silently skipped with WARN. Old buildFallbackModel(ChatModel) kept as @Deprecated wrapper. - NodeStreamingChatHelper accepts List<ChatModel>; the post-retry fallback block now walks the chain in priority order, single-shot per entry. Old single-fallback constructors retained as @Deprecated one-element-list wrappers so legacy callers keep working. - New ErrorType.EMPTY_RESPONSE: when the LLM returns no content, no thinking, AND no tool calls, mark the result as a soft failure and break the same-model retry loop, handing off directly to the fallback chain. - Broadcast updated to "切换到备选模型 (N/M)..." so SSE consumers see chain progress. Tests - NodeStreamingChatHelperFallbackChainTest covers constructor variants, chain immutability, deprecated-overload back-compat, and the EMPTY_RESPONSE enum exists as a compile-time contract. - 159 tests pass (was 153 + 6 new).
This commit is contained in:
parent
980b16109d
commit
ed37e81e7e
@ -237,7 +237,7 @@ public class AgentGraphBuilder {
|
||||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||
ChatClient chatClient = ChatClient.create(chatModel);
|
||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort);
|
||||
CompiledGraph compiledGraph = buildReActGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
|
||||
return new StateGraphReActAgent(chatClient, conversationService, compiledGraph,
|
||||
chatModel, conversationWindowManager);
|
||||
}
|
||||
@ -246,15 +246,20 @@ public class AgentGraphBuilder {
|
||||
ChatModel chatModel = buildRuntimeChatModel(runtimeModel);
|
||||
ChatClient chatClient = ChatClient.create(chatModel);
|
||||
String reasoningEffort = resolveReasoningEffortForModel(runtimeModel);
|
||||
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort);
|
||||
CompiledGraph graph = buildPlanExecuteGraph(toolSet, chatModel, maxIter, reasoningEffort, runtimeModel);
|
||||
return new StateGraphPlanExecuteAgent(chatClient, conversationService, graph, planningService,
|
||||
chatModel, conversationWindowManager);
|
||||
}
|
||||
|
||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||||
return buildPlanExecuteGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
|
||||
}
|
||||
|
||||
CompiledGraph buildPlanExecuteGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
|
||||
try {
|
||||
ChatModel fallbackModel = buildFallbackModel(chatModel);
|
||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel, llmCacheMetricsAggregator);
|
||||
List<ChatModel> fallbackChain = buildFallbackChain(primaryModelConfig);
|
||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackChain, llmCacheMetricsAggregator);
|
||||
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);
|
||||
@ -346,9 +351,14 @@ public class AgentGraphBuilder {
|
||||
}
|
||||
|
||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations, String reasoningEffort) {
|
||||
return buildReActGraph(toolSet, chatModel, maxIterations, reasoningEffort, null);
|
||||
}
|
||||
|
||||
CompiledGraph buildReActGraph(AgentToolSet toolSet, ChatModel chatModel, int maxIterations,
|
||||
String reasoningEffort, ModelConfigEntity primaryModelConfig) {
|
||||
try {
|
||||
ChatModel fallbackModel = buildFallbackModel(chatModel);
|
||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackModel, llmCacheMetricsAggregator);
|
||||
List<ChatModel> fallbackChain = buildFallbackChain(primaryModelConfig);
|
||||
NodeStreamingChatHelper streamingHelper = new NodeStreamingChatHelper(streamTracker, fallbackChain, llmCacheMetricsAggregator);
|
||||
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);
|
||||
@ -530,9 +540,84 @@ public class AgentGraphBuilder {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 fallback 模型:优先使用 UI 配置的 DashScope provider key 构建新实例,
|
||||
* 避免直接依赖 Spring 注入的 dashScopeChatModel bean(它只读环境变量)。
|
||||
* build the full multi-provider failover chain for a primary
|
||||
* model. Providers are read from {@code mate_model_provider} ordered by
|
||||
* {@code fallback_priority ASC} (positive values only), each resolved to
|
||||
* its default {@link ModelConfigEntity} and turned into a {@link ChatModel}
|
||||
* via {@link #buildRuntimeChatModel(ModelConfigEntity, RetryTemplate)}.
|
||||
*
|
||||
* <p>Providers whose API key / base URL is missing (build throws) are
|
||||
* <b>silently skipped</b> with a warning — fallback should never break
|
||||
* the primary call path. The returned list preserves chain order; the
|
||||
* streaming helper tries entries in order until one succeeds.</p>
|
||||
*
|
||||
* <p>The primary model is excluded from the chain when its provider +
|
||||
* model name matches a chain entry. Previously only reference equality
|
||||
* was checked, which meant a DashScope-primary deployment ended up with
|
||||
* {@code null} fallback — the case this design targets.</p>
|
||||
*
|
||||
* @param primaryModelConfig the {@code ModelConfigEntity} used to build
|
||||
* the primary model; used to identity-filter the chain
|
||||
* @return ordered, possibly-empty list of fallback {@link ChatModel}s
|
||||
*/
|
||||
List<ChatModel> buildFallbackChain(ModelConfigEntity primaryModelConfig) {
|
||||
List<ModelProviderEntity> providers;
|
||||
try {
|
||||
providers = modelProviderService.listFallbackChain();
|
||||
} catch (Exception e) {
|
||||
log.warn("[LlmFailover] failed to load fallback chain from DB: {}; running without fallback",
|
||||
e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
if (providers == null || providers.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
String primaryProviderId = primaryModelConfig != null ? primaryModelConfig.getProvider() : null;
|
||||
String primaryModelName = primaryModelConfig != null ? primaryModelConfig.getModelName() : null;
|
||||
|
||||
List<ChatModel> chain = new ArrayList<>();
|
||||
for (ModelProviderEntity p : providers) {
|
||||
ModelConfigEntity fallbackConfig;
|
||||
try {
|
||||
fallbackConfig = modelConfigService.getDefaultModelByProvider(p.getProviderId());
|
||||
} catch (Exception e) {
|
||||
log.warn("[LlmFailover] skipping provider {} — cannot resolve default model: {}",
|
||||
p.getProviderId(), e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (fallbackConfig == null) {
|
||||
log.debug("[LlmFailover] skipping provider {} — no default model configured",
|
||||
p.getProviderId());
|
||||
continue;
|
||||
}
|
||||
if (primaryProviderId != null
|
||||
&& primaryProviderId.equals(p.getProviderId())
|
||||
&& fallbackConfig.getModelName() != null
|
||||
&& fallbackConfig.getModelName().equals(primaryModelName)) {
|
||||
log.debug("[LlmFailover] skipping primary {}/{} in fallback chain",
|
||||
primaryProviderId, primaryModelName);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ChatModel m = buildRuntimeChatModel(fallbackConfig, RetryTemplate.builder().maxAttempts(1).build());
|
||||
chain.add(m);
|
||||
log.info("[LlmFailover] chain[{}] = {}/{} (priority={})",
|
||||
chain.size(), p.getProviderId(), fallbackConfig.getModelName(),
|
||||
p.getFallbackPriority());
|
||||
} catch (Exception e) {
|
||||
log.warn("[LlmFailover] skipping provider {} — chat model build failed: {}",
|
||||
p.getProviderId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use {@link #buildFallbackChain(ModelConfigEntity)} — the
|
||||
* single-fallback variant cannot represent an ordered chain and only
|
||||
* worked when the primary was a non-DashScope provider.
|
||||
*/
|
||||
@Deprecated
|
||||
ChatModel buildFallbackModel(ChatModel primaryModel) {
|
||||
try {
|
||||
ModelProviderEntity dashScopeProvider = modelProviderService.getProviderConfig("dashscope");
|
||||
|
||||
@ -44,24 +44,43 @@ public class NodeStreamingChatHelper {
|
||||
|
||||
private final ChatStreamTracker streamTracker;
|
||||
|
||||
/** Fallback model, used after consecutive failures of the primary model. */
|
||||
private final ChatModel fallbackModel;
|
||||
/**
|
||||
* Ordered fallback chain tried after the primary model exhausts retries.
|
||||
* Each entry is attempted once (no retry); the first successful response
|
||||
* wins. Empty list disables fallover entirely.
|
||||
*/
|
||||
private final List<ChatModel> fallbackChain;
|
||||
|
||||
/** Optional cache-metrics aggregator; {@code null} in tests or when the bean is absent. */
|
||||
private final vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics;
|
||||
|
||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker) {
|
||||
this(streamTracker, null, null);
|
||||
this(streamTracker, List.of(), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use the list-based constructor — a single fallback cannot
|
||||
* express the ordered multi-provider chain
|
||||
*/
|
||||
@Deprecated
|
||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel) {
|
||||
this(streamTracker, fallbackModel, null);
|
||||
this(streamTracker, fallbackModel == null ? List.of() : List.of(fallbackModel), null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use the list-based constructor — a single fallback cannot
|
||||
* express the ordered multi-provider chain
|
||||
*/
|
||||
@Deprecated
|
||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, ChatModel fallbackModel,
|
||||
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
|
||||
this(streamTracker, fallbackModel == null ? List.of() : List.of(fallbackModel), cacheMetrics);
|
||||
}
|
||||
|
||||
public NodeStreamingChatHelper(ChatStreamTracker streamTracker, List<ChatModel> fallbackChain,
|
||||
vip.mate.llm.cache.LlmCacheMetricsAggregator cacheMetrics) {
|
||||
this.streamTracker = streamTracker;
|
||||
this.fallbackModel = fallbackModel;
|
||||
this.fallbackChain = fallbackChain == null ? List.of() : List.copyOf(fallbackChain);
|
||||
this.cacheMetrics = cacheMetrics;
|
||||
}
|
||||
|
||||
@ -233,6 +252,13 @@ public class NodeStreamingChatHelper {
|
||||
if (lastResult.errorType() == ErrorType.THINKING_BLOCK_ERROR) {
|
||||
return lastResult; // 已经重试过了
|
||||
}
|
||||
// EMPTY_RESPONSE — break the primary-retry loop and fall through to
|
||||
// the fallback chain. Retrying the same model that returned nothing is rarely
|
||||
// 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);
|
||||
break;
|
||||
}
|
||||
// 成功
|
||||
if (lastResult.errorMessage() == null || lastResult.errorType() == ErrorType.NONE) {
|
||||
return lastResult;
|
||||
@ -246,19 +272,33 @@ public class NodeStreamingChatHelper {
|
||||
// lastResult == null 表示需要重试
|
||||
}
|
||||
|
||||
// 主模型耗尽重试 — 尝试 fallback model
|
||||
if (fallbackModel != null && fallbackModel != chatModel) {
|
||||
log.warn("[{}] Primary model exhausted retries, switching to fallback model for conversation {}",
|
||||
phase, conversationId);
|
||||
// Primary exhausted retries — walk the fallback chain in priority order.
|
||||
// Each fallback gets a single shot (no retry); first successful result wins.
|
||||
// Same-instance entries (e.g., primary accidentally included in the chain)
|
||||
// are skipped so we don't re-try the exact model that just failed.
|
||||
for (int i = 0; i < fallbackChain.size(); i++) {
|
||||
ChatModel fallback = fallbackChain.get(i);
|
||||
if (fallback == chatModel) continue;
|
||||
log.warn("[{}] Primary exhausted, trying fallback {}/{} ({}) for conversation {}",
|
||||
phase, i + 1, fallbackChain.size(),
|
||||
fallback.getClass().getSimpleName(), conversationId);
|
||||
if (broadcast) {
|
||||
broadcastDelta(conversationId, "warning",
|
||||
buildDeltaJson("主模型不可用,正在切换到备选模型..."));
|
||||
buildDeltaJson("主模型不可用,正在切换到备选模型 (" + (i + 1) + "/" + fallbackChain.size() + ")..."));
|
||||
}
|
||||
StreamResult fallbackResult = doStreamCall(fallbackModel, prompt, conversationId,
|
||||
phase + "_fallback", broadcast, 0);
|
||||
if (fallbackResult != null) {
|
||||
StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId,
|
||||
phase + "_fallback_" + (i + 1), broadcast, 0);
|
||||
// Accept only fully successful fallbacks. Non-successful results (auth
|
||||
// error, client error, still-rate-limited) propagate to the next
|
||||
// fallback instead of being surfaced as the final result.
|
||||
if (fallbackResult != null
|
||||
&& fallbackResult.errorType() == ErrorType.NONE
|
||||
&& fallbackResult.errorMessage() == null) {
|
||||
return fallbackResult;
|
||||
}
|
||||
if (fallbackResult != null) {
|
||||
lastResult = fallbackResult; // remember most recent to report if the whole chain fails
|
||||
}
|
||||
}
|
||||
|
||||
return lastResult != null ? lastResult
|
||||
@ -274,7 +314,7 @@ public class NodeStreamingChatHelper {
|
||||
boolean broadcast, int attempt) {
|
||||
if (attempt > 0) {
|
||||
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
|
||||
// 加入 jitter 防止雷群效应(Hermes 风格)
|
||||
// 加入 jitter 防止雷群效应(a comparable reference runtime 风格)
|
||||
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
|
||||
delay = Math.min(delay, BACKOFF_CAP_MS);
|
||||
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
|
||||
@ -496,6 +536,22 @@ public class NodeStreamingChatHelper {
|
||||
phase, conversationId);
|
||||
// warning 已在 dispose 时广播,无需重复
|
||||
}
|
||||
|
||||
// guard against silent empty responses. Some providers return
|
||||
// HTTP 200 with an empty body under soft-failure conditions (rate-limit
|
||||
// capacity, context filter, upstream overload). Treat this as a failure
|
||||
// signal so streamCallInternal can hand off to the fallback chain.
|
||||
// Only fire when the primary wasn't truncated by our own repetition
|
||||
// detector (which deliberately produces short content) and when there
|
||||
// are no tool calls (tool-only responses are legitimately empty-text).
|
||||
if (!truncatedByRepetition
|
||||
&& contentAccum.length() == 0
|
||||
&& thinkingAccum.length() == 0
|
||||
&& toolCallAccumulators.isEmpty()) {
|
||||
log.warn("[{}] LLM returned empty response (no content, no thinking, no tool calls) — marking as EMPTY_RESPONSE for fallback", phase);
|
||||
return buildErrorResultWithType("LLM 返回空响应", conversationId, phase, ErrorType.EMPTY_RESPONSE);
|
||||
}
|
||||
|
||||
return assembleResult(contentAccum, thinkingAccum, toolCallAccumulators,
|
||||
promptTokens.get(), completionTokens.get(),
|
||||
cacheReadTokens.get(), cacheWriteTokens.get(), phase,
|
||||
@ -750,6 +806,13 @@ public class NodeStreamingChatHelper {
|
||||
CLIENT_ERROR,
|
||||
/** Thinking 块错误(旧消息中的 thinking block 不可修改)— 可剥离后单次重试 */
|
||||
THINKING_BLOCK_ERROR,
|
||||
/**
|
||||
* LLM returned no content, no thinking, and no tool calls.
|
||||
* Treated as a soft failure — skip same-model retries and hand off to
|
||||
* the fallback chain directly. Typical cause: upstream rate-limit
|
||||
* rejection that comes back as HTTP 200 with empty body.
|
||||
*/
|
||||
EMPTY_RESPONSE,
|
||||
/** 其他未知错误 */
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
@ -49,6 +49,13 @@ public class ModelProviderEntity {
|
||||
|
||||
private String oauthAccountId;
|
||||
|
||||
/**
|
||||
* position of this provider in the fallback chain.
|
||||
* {@code 0} (default) means "not in the chain"; positive values are tried
|
||||
* in ascending order after the primary model exhausts its retries.
|
||||
*/
|
||||
private Integer fallbackPriority;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -143,6 +143,20 @@ public class ModelProviderService {
|
||||
return getProvider(providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* ordered list of providers that participate in the multi-model
|
||||
* failover chain. Filters by {@code fallback_priority > 0} and sorts
|
||||
* ascending, so priority 1 is tried first after the primary model
|
||||
* exhausts retries. An empty list disables fallover entirely.
|
||||
*/
|
||||
public List<ModelProviderEntity> listFallbackChain() {
|
||||
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<ModelProviderEntity> qw =
|
||||
new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<>();
|
||||
qw.gt("fallback_priority", 0);
|
||||
qw.orderByAsc("fallback_priority");
|
||||
return modelProviderMapper.selectList(qw);
|
||||
}
|
||||
|
||||
public boolean isProviderConfigured(String providerId) {
|
||||
return isProviderConfigured(getProvider(providerId));
|
||||
}
|
||||
|
||||
@ -151,7 +151,7 @@ mate:
|
||||
per-category:
|
||||
shell: 120
|
||||
web: 30
|
||||
# RFC-008 Phase 3: tool-result three-layer budget (per-result spill + per-turn aggregate budget).
|
||||
# RFC-008 tool-result three-layer budget (per-result spill + per-turn aggregate budget).
|
||||
# Layer 1 (per-tool cap) lives inside individual tools; Layer 2 spills oversized
|
||||
# single results to disk; Layer 3 enforces an aggregate cap on the combined
|
||||
# response size of one tool turn. The full output is preserved on disk and
|
||||
|
||||
@ -0,0 +1,14 @@
|
||||
-- ordered multi-provider fallback chain
|
||||
--
|
||||
-- `fallback_priority` defines the order in which a provider is tried after the
|
||||
-- primary model exhausts retries:
|
||||
-- 0 : not in the fallback chain (default — matches pre-RFC behavior)
|
||||
-- 1, 2, … : try in ascending order
|
||||
--
|
||||
-- Seed: keep DashScope as priority 1 so existing deployments preserve the
|
||||
-- single-fallback-to-DashScope behavior the hardcoded path used to provide.
|
||||
|
||||
ALTER TABLE mate_model_provider ADD COLUMN IF NOT EXISTS fallback_priority INT DEFAULT 0;
|
||||
|
||||
UPDATE mate_model_provider SET fallback_priority = 1
|
||||
WHERE provider_id = 'dashscope' AND (fallback_priority IS NULL OR fallback_priority = 0);
|
||||
@ -0,0 +1,25 @@
|
||||
-- ordered multi-provider fallback chain
|
||||
--
|
||||
-- `fallback_priority` defines the order in which a provider is tried after the
|
||||
-- primary model exhausts retries:
|
||||
-- 0 : not in the fallback chain (default — matches pre-RFC behavior)
|
||||
-- 1, 2, … : try in ascending order
|
||||
--
|
||||
-- MySQL lacks `ADD COLUMN IF NOT EXISTS`; use the INFORMATION_SCHEMA guard so
|
||||
-- this migration is idempotent across redeploys.
|
||||
|
||||
SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'mate_model_provider'
|
||||
AND COLUMN_NAME = 'fallback_priority');
|
||||
SET @s := IF(@c = 0,
|
||||
'ALTER TABLE mate_model_provider ADD COLUMN fallback_priority INT DEFAULT 0',
|
||||
'SELECT 1');
|
||||
PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- Seed: keep DashScope as priority 1 so existing deployments preserve the
|
||||
-- single-fallback-to-DashScope behavior the hardcoded path used to provide.
|
||||
UPDATE mate_model_provider
|
||||
SET fallback_priority = 1
|
||||
WHERE provider_id = 'dashscope'
|
||||
AND (fallback_priority IS NULL OR fallback_priority = 0);
|
||||
@ -0,0 +1,93 @@
|
||||
package vip.mate.agent.graph;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import vip.mate.channel.web.ChatStreamTracker;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* smoke tests for the multi-model fallback chain wiring on
|
||||
* {@link NodeStreamingChatHelper}.
|
||||
*
|
||||
* <p>Full streaming-flow integration (ChatModel.stream / Flux mocking) is left
|
||||
* to end-to-end smoke tests in the RFC; these tests verify the public
|
||||
* surface — constructor variants, chain immutability, deprecated-overload
|
||||
* compatibility — so future refactors of those entry points are caught.</p>
|
||||
*/
|
||||
class NodeStreamingChatHelperFallbackChainTest {
|
||||
|
||||
private final ChatStreamTracker streamTracker = mock(ChatStreamTracker.class);
|
||||
|
||||
@Test
|
||||
@DisplayName("List-based constructor preserves fallback chain order and contents")
|
||||
void listConstructorPreservesOrder() throws Exception {
|
||||
ChatModel a = mock(ChatModel.class);
|
||||
ChatModel b = mock(ChatModel.class);
|
||||
ChatModel c = mock(ChatModel.class);
|
||||
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, List.of(a, b, c), null);
|
||||
|
||||
List<ChatModel> chain = readFallbackChain(helper);
|
||||
assertEquals(3, chain.size(), "fallback chain should preserve all entries");
|
||||
assertSame(a, chain.get(0), "priority 1 must be first");
|
||||
assertSame(b, chain.get(1));
|
||||
assertSame(c, chain.get(2));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Null fallback chain is normalized to empty list (defensive)")
|
||||
void nullChainNormalizedToEmpty() throws Exception {
|
||||
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, (List<ChatModel>) null, null);
|
||||
assertTrue(readFallbackChain(helper).isEmpty(),
|
||||
"null chain must not throw — it should be normalized to an empty list");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Single-arg constructor (no fallback) yields empty chain")
|
||||
void singleArgConstructorEmptyChain() throws Exception {
|
||||
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker);
|
||||
assertTrue(readFallbackChain(helper).isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deprecated single-fallback constructor wraps the model into a 1-element chain")
|
||||
void deprecatedSingleFallbackConstructorBackCompat() throws Exception {
|
||||
ChatModel single = mock(ChatModel.class);
|
||||
@SuppressWarnings("deprecation")
|
||||
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, single);
|
||||
|
||||
List<ChatModel> chain = readFallbackChain(helper);
|
||||
assertEquals(1, chain.size(), "deprecated overload should produce a 1-entry chain");
|
||||
assertSame(single, chain.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Deprecated single-fallback constructor with null produces empty chain (no NPE)")
|
||||
void deprecatedSingleFallbackNullSafe() throws Exception {
|
||||
@SuppressWarnings("deprecation")
|
||||
NodeStreamingChatHelper helper = new NodeStreamingChatHelper(streamTracker, (ChatModel) null);
|
||||
assertTrue(readFallbackChain(helper).isEmpty(),
|
||||
"null single fallback must collapse to an empty chain");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("EMPTY_RESPONSE error type exists (fallback trigger)")
|
||||
void emptyResponseErrorTypeExists() {
|
||||
// Compile-time safety net: the enum constant 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);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static List<ChatModel> readFallbackChain(NodeStreamingChatHelper helper) throws Exception {
|
||||
Field f = NodeStreamingChatHelper.class.getDeclaredField("fallbackChain");
|
||||
f.setAccessible(true);
|
||||
return (List<ChatModel>) f.get(helper);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user