fix: preserve fallback error details

This commit is contained in:
Peter Steinberger
2026-04-07 22:07:12 +01:00
parent b9e972e174
commit 5fb6aeaf86
3 changed files with 46 additions and 1 deletions

View File

@@ -56,6 +56,8 @@ export function logModelFallbackDecision(params: {
: "none";
const reasonText = params.reason ?? "unknown";
const observedError = buildErrorObservationFields(params.error);
const detailText = observedError.providerErrorMessagePreview ?? observedError.errorPreview;
const detailSuffix = detailText ? ` detail=${sanitizeForLog(detailText)}` : "";
decisionLog.warn("model fallback decision", {
event: "model_fallback_decision",
tags: ["error_handling", "model_fallback", params.decision],
@@ -88,6 +90,6 @@ export function logModelFallbackDecision(params: {
})),
consoleMessage:
`model fallback decision: decision=${params.decision} requested=${sanitizeForLog(params.requestedProvider)}/${sanitizeForLog(params.requestedModel)} ` +
`candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText} next=${nextText}`,
`candidate=${sanitizeForLog(params.candidate.provider)}/${sanitizeForLog(params.candidate.model)} reason=${reasonText} next=${nextText}${detailSuffix}`,
});
}

View File

@@ -78,6 +78,20 @@ describe("fallback-state", () => {
expect(resolved.reasonSummary).toBe("rate limit burst");
});
it("prefers formatted transient error details over generic rate-limit labels", () => {
const resolved = resolveDemoFallbackTransition({
attempts: [
{
...baseAttempt,
error: "429 Too Many Requests: Claude Max usage limit reached, try again in 6 minutes.",
},
],
});
expect(resolved.reasonSummary).toContain("HTTP 429: Too Many Requests");
expect(resolved.reasonSummary).toContain("Claude Max usage limit reached");
});
it("refreshes reason when fallback remains active with same model pair", () => {
const resolved = resolveDemoFallbackTransition({
attempts: [{ ...baseAttempt, reason: "timeout" }],

View File

@@ -1,9 +1,13 @@
import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js";
import type { SessionEntry } from "../config/sessions.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { formatProviderModelRef } from "./model-runtime.js";
import type { RuntimeFallbackAttempt } from "./reply/agent-runner-execution.js";
const FALLBACK_REASON_PART_MAX = 80;
const TRANSIENT_FALLBACK_REASONS = new Set(["rate_limit", "overloaded", "timeout"]);
const TRANSIENT_ERROR_DETAIL_HINT_RE =
/\b(?:429|5\d\d|too many requests|usage limit|quota|try again in|retry[- ]after|seconds?|minutes?|hours?|temporarily unavailable|overloaded|service unavailable|throttl)\b/i;
export type FallbackNoticeState = Pick<
SessionEntry,
@@ -20,7 +24,32 @@ function truncateFallbackReasonPart(value: string, max = FALLBACK_REASON_PART_MA
return `${text.slice(0, Math.max(0, max - 1)).trimEnd()}`;
}
function formatFallbackAttemptErrorPreview(attempt: RuntimeFallbackAttempt): string | undefined {
const rawError = attempt.error?.trim();
if (!rawError) {
return undefined;
}
if (!attempt.reason || !TRANSIENT_FALLBACK_REASONS.has(attempt.reason)) {
return undefined;
}
if (!TRANSIENT_ERROR_DETAIL_HINT_RE.test(rawError)) {
return undefined;
}
const formatted = formatRawAssistantErrorForUi(rawError)
.replace(/^⚠️\s*/, "")
.replace(/\s+/g, " ")
.trim();
if (!formatted || /unknown error/i.test(formatted)) {
return undefined;
}
return formatted;
}
export function formatFallbackAttemptReason(attempt: RuntimeFallbackAttempt): string {
const errorPreview = formatFallbackAttemptErrorPreview(attempt);
if (errorPreview) {
return errorPreview;
}
const reason = attempt.reason?.trim();
if (reason) {
return reason.replace(/_/g, " ");