From 11378be705e74eb96bbf9001355eeaa17d2653bb Mon Sep 17 00:00:00 2001 From: matevip Date: Tue, 3 Nov 2026 03:05:12 -0500 Subject: [PATCH] fix(llm): use fallback model identity and provider telemetry (#676) --- .../agent/graph/NodeStreamingChatHelper.java | 18 +++++++++---- .../NodeStreamingChatHelperFailoverTest.java | 25 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) 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 53b88d5b..fd7554b0 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 @@ -1002,7 +1002,7 @@ public class NodeStreamingChatHelper { } failoverCount++; llmCallCount++; - StreamResult fallbackResult = doStreamCall(fallback, prompt, conversationId, + StreamResult fallbackResult = doStreamCall(fallback, promptForFallback(prompt, fallback), conversationId, phase + "_fallback_" + (i + 1), publicationMode, 0, false, new AtomicReference<>(), new AtomicReference<>(0L), entry.providerId()); if (fallbackResult == null || !fallbackResult.stopped()) throwIfCancelled(conversationId); @@ -1029,6 +1029,14 @@ public class NodeStreamingChatHelper { : buildErrorResult("当前没有可用模型,请检查供应商状态或稍后重试", conversationId, phase, publicationMode); } + /** Rebase only model identity; preserve per-call budgets, tool restrictions and other controls. */ + private static Prompt promptForFallback(Prompt prompt, ChatModel fallback) { + if (prompt.getOptions() == null) return prompt; + var defaults = ChatModelDefaults.optionsFor(fallback); + String model = defaults == null ? null : defaults.getModel(); + return new Prompt(prompt.getInstructions(), prompt.getOptions().mutate().model(model).build()); + } + /** D-6: log a structured performance summary for the LLM call phase. */ private void logPerfSummary(String phase, String conversationId, long startMs, int llmCallCount, int retryCount, int failoverCount) { @@ -1111,7 +1119,7 @@ public class NodeStreamingChatHelper { return buildErrorResultWithType("供应商冷却中,请稍后重试", conversationId, phase, ErrorType.PROVIDER_BUSY, publicationMode); StreamResult result = doStreamCallInner(chatModel, outbound, conversationId, phase, - publicationMode, attempt, primaryCall, retryTypeRef, retryHintRef); + publicationMode, attempt, primaryCall, retryTypeRef, retryHintRef, providerId); // Publish completion state BEFORE releasing capacity. Otherwise a queued // request can slip between permit release and a newly established cooldown. if (result != null) { @@ -1164,7 +1172,7 @@ public class NodeStreamingChatHelper { String conversationId, String phase, PublicationMode publicationMode, int attempt, boolean primaryCall, AtomicReference retryTypeRef, - AtomicReference retryHintRef) { + AtomicReference retryHintRef, String providerId) { boolean broadcast = publicationMode != PublicationMode.SILENT; throwIfCancelled(conversationId); if (attempt > 0) { @@ -1281,9 +1289,9 @@ public class NodeStreamingChatHelper { "timestamp", System.currentTimeMillis() )); String modelId = identifyModel(chatModel); - String providerId = primaryProviderId != null ? primaryProviderId : ""; + String actualProviderId = providerId != null ? providerId : ""; streamTracker.broadcastObject(conversationId, "llm_request_sent", Map.of( - "provider", providerId, + "provider", actualProviderId, "model", modelId != null ? modelId : "", "phase", phase != null ? phase : "", "timestamp", System.currentTimeMillis() diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java index 71723b93..e67ad7af 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/NodeStreamingChatHelperFailoverTest.java @@ -34,6 +34,31 @@ import static org.mockito.Mockito.*; * MODEL_NOT_FOUND already behave.

*/ class NodeStreamingChatHelperFailoverTest { + @Test + void fallbackUsesOwnModelAndProviderWithoutChangingRequestControls() throws Exception { + var primary = errorModel(new RuntimeException("401 Unauthorized")); + var fallback = successModel("fallback ok"); + when(fallback.getOptions()).thenReturn(org.springframework.ai.chat.prompt.ChatOptions.builder() + .model("backup-model").build()); + var options = org.springframework.ai.openai.OpenAiChatOptions.builder() + .model("primary-only-model").maxTokens(256).toolChoice("none").build(); + var helper = helper(primary, List.of(new FallbackEntry("backup-provider", fallback)), "primary-provider"); + assertEquals("fallback ok", helper.streamCall(primary, new Prompt("hi", options), + "different-models", "reasoning").text()); + var sent = org.mockito.ArgumentCaptor.forClass(Prompt.class); + verify(fallback).stream(sent.capture()); + assertEquals("backup-model", sent.getValue().getOptions().getModel()); + assertEquals(256, sent.getValue().getOptions().getMaxTokens()); + assertEquals("none", ((org.springframework.ai.openai.OpenAiChatOptions) + sent.getValue().getOptions()).getToolChoice()); + assertEquals("primary-only-model", options.getModel(), "Original prompt must remain unchanged"); + var events = org.mockito.ArgumentCaptor.forClass(Object.class); + verify(streamTracker, atLeastOnce()).broadcastObject(eq("different-models"), + eq("llm_request_sent"), events.capture()); + var json = new com.fasterxml.jackson.databind.ObjectMapper(); + assertEquals("backup-provider", json.valueToTree(events.getAllValues().getLast()).path("provider").asText()); + } + private ChatStreamTracker streamTracker; private ProviderHealthTracker healthTracker;