fix(llm): use fallback model identity and provider telemetry (#676)

This commit is contained in:
matevip 2026-11-03 03:05:12 -05:00
parent 082b3b0f88
commit 11378be705
2 changed files with 38 additions and 5 deletions

View File

@ -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<ErrorType> retryTypeRef,
AtomicReference<Long> retryHintRef) {
AtomicReference<Long> 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()

View File

@ -34,6 +34,31 @@ import static org.mockito.Mockito.*;
* MODEL_NOT_FOUND already behave.</p>
*/
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;