diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 2144c873..742cda7f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -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 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 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)}. + * + *

Providers whose API key / base URL is missing (build throws) are + * silently skipped 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.

+ * + *

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.

+ * + * @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 buildFallbackChain(ModelConfigEntity primaryModelConfig) { + List 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 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"); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java index 34a713fb..9a533cec 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/NodeStreamingChatHelper.java @@ -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 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 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 } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java index 5ab1e9a6..ebdab08f 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelProviderEntity.java @@ -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; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java index 621eed9b..74da3e61 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelProviderService.java @@ -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 listFallbackChain() { + com.baomidou.mybatisplus.core.conditions.query.QueryWrapper 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)); } diff --git a/mateclaw-server/src/main/resources/application.yml b/mateclaw-server/src/main/resources/application.yml index 1d5ca662..12adf66b 100644 --- a/mateclaw-server/src/main/resources/application.yml +++ b/mateclaw-server/src/main/resources/application.yml @@ -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 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql b/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql new file mode 100644 index 00000000..9ef9d51e --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V21__provider_fallback_priority.sql @@ -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); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql new file mode 100644 index 00000000..0a336863 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V21__provider_fallback_priority.sql @@ -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); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFallbackChainTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFallbackChainTest.java new file mode 100644 index 00000000..f9fb59b5 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFallbackChainTest.java @@ -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}. + * + *

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.

+ */ +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 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) 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 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 readFallbackChain(NodeStreamingChatHelper helper) throws Exception { + Field f = NodeStreamingChatHelper.class.getDeclaredField("fallbackChain"); + f.setAccessible(true); + return (List) f.get(helper); + } +}