Skip to content

Commit 658ff23

Browse files
committed
fix(agents): fallback on upstream provider errors
1 parent a70b34a commit 658ff23

4 files changed

Lines changed: 78 additions & 6 deletions

File tree

src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
44
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../shared/assistant-error-format.js";
55
import {
66
BILLING_ERROR_USER_MESSAGE,
7+
classifyAssistantFailoverReason,
78
formatBillingErrorMessage,
89
formatAssistantErrorText,
910
formatUserFacingAssistantErrorText,
@@ -116,6 +117,21 @@ describe("formatAssistantErrorText", () => {
116117
);
117118
expect(formatAssistantErrorText(msg)).toBe("LLM error server_error: Something exploded");
118119
});
120+
it("classifies provider upstream_error payloads as server errors for fallback", () => {
121+
const msg = makeAssistantMessageFixture({
122+
errorMessage: "Upstream request failed",
123+
errorType: "upstream_error",
124+
});
125+
126+
expect(classifyAssistantFailoverReason(msg, { provider: "openai" })).toBe("server_error");
127+
expect(
128+
classifyAssistantFailoverReason(
129+
makeAssistantError(
130+
'{"error":{"message":"Upstream request failed","type":"upstream_error","param":"","code":null}}',
131+
),
132+
),
133+
).toBe("server_error");
134+
});
119135
it("uses generic user-facing copy for escaped structured provider messages", () => {
120136
// The internal formatter keeps detail for logs, while user-facing text must
121137
// not expose arbitrary provider-controlled structured payload content.

src/agents/embedded-agent-helpers/errors.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,19 @@ function classifyFailoverReasonFromCode(raw: string | undefined): FailoverReason
917917
}
918918
}
919919

920+
function classifyFailoverReasonFromErrorType(raw: string | undefined): FailoverReason | null {
921+
const normalized = normalizeOptionalLowercaseString(raw);
922+
switch (normalized) {
923+
case "server_error":
924+
case "upstream_error":
925+
return "server_error";
926+
case "overloaded_error":
927+
return "overloaded";
928+
default:
929+
return null;
930+
}
931+
}
932+
920933
function isProvider(provider: string | undefined, match: string): boolean {
921934
const normalized = normalizeOptionalLowercaseString(provider);
922935
return Boolean(normalized && normalized.includes(match));
@@ -1176,9 +1189,14 @@ export function classifyFailoverSignal(signal: FailoverSignal): FailoverClassifi
11761189
errorType: signal.errorType,
11771190
})
11781191
: null;
1192+
const errorTypeReason = classifyFailoverReasonFromErrorType(signal.errorType);
1193+
const genericErrorTypeClassification = errorTypeReason
1194+
? toReasonClassification(errorTypeReason)
1195+
: null;
11791196
const effectiveMessageClassification = providerPluginReason
11801197
? toReasonClassification(providerPluginReason)
1181-
: mergeMessageAndDetailClassification(messageClassification, detailClassification);
1198+
: (mergeMessageAndDetailClassification(messageClassification, detailClassification) ??
1199+
genericErrorTypeClassification);
11821200
const codeReason = classifyFailoverReasonFromCode(signal.code);
11831201
if (codeReason === "auth_permanent") {
11841202
return toReasonClassification(codeReason);
@@ -1657,8 +1675,17 @@ function isStructuredServerErrorMessage(raw: string): boolean {
16571675
if (!raw) {
16581676
return false;
16591677
}
1678+
const parsedType = normalizeOptionalLowercaseString(parseApiErrorInfo(raw)?.type);
1679+
if (parsedType === "server_error" || parsedType === "upstream_error") {
1680+
return true;
1681+
}
16601682
const value = normalizeLowercaseStringOrEmpty(raw);
1661-
return value.includes('"type":"server_error"') || value.includes('"code":"server_error"');
1683+
return (
1684+
value.includes('"type":"server_error"') ||
1685+
value.includes('"code":"server_error"') ||
1686+
value.includes('"type":"upstream_error"') ||
1687+
value.includes('"code":"upstream_error"')
1688+
);
16621689
}
16631690

16641691
export function parseImageDimensionError(raw: string): {

src/agents/embedded-agent-runner/result-fallback-classifier.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,34 @@ describe("classifyEmbeddedAgentRunResultForModelFallback", () => {
4949
});
5050
});
5151

52+
it("classifies structured provider upstream_error payloads as fallback-worthy", () => {
53+
const rawError =
54+
'{"error":{"message":"Upstream request failed","type":"upstream_error","param":"","code":null}}';
55+
56+
const result = classifyEmbeddedAgentRunResultForModelFallback({
57+
provider: "openai-compatible",
58+
model: "primary-model",
59+
result: {
60+
payloads: [
61+
{
62+
isError: true,
63+
text: rawError,
64+
},
65+
],
66+
meta: {
67+
durationMs: 42,
68+
},
69+
},
70+
});
71+
72+
expect(result).toEqual({
73+
message: `openai-compatible/primary-model ended with a provider error: ${rawError}`,
74+
reason: "server_error",
75+
code: "embedded_error_payload",
76+
rawError,
77+
});
78+
});
79+
5280
it("preserves hook block results with auth-like error payload text", () => {
5381
// Hook policy blocks are intentional local decisions, not provider failures
5482
// that should rotate models.

src/agents/embedded-agent-runner/result-fallback-classifier.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,11 @@ function classifyHarnessResult(params: {
103103
}
104104
}
105105

106-
/** Maps provider error payloads to fallback-safe business reasons. */
107-
function classifyBusinessDenialErrorPayloadReason(
106+
/** Maps provider error payloads to fallback-safe provider failure reasons. */
107+
function classifyProviderErrorPayloadReason(
108108
errorText: string,
109109
provider: string,
110-
): Extract<FailoverReason, "auth" | "auth_permanent" | "billing"> | null {
110+
): FailoverReason | null {
111111
if (!errorText.trim()) {
112112
return null;
113113
}
@@ -116,6 +116,7 @@ function classifyBusinessDenialErrorPayloadReason(
116116
case "auth":
117117
case "auth_permanent":
118118
case "billing":
119+
case "server_error":
119120
return failoverReason;
120121
default:
121122
return null;
@@ -189,7 +190,7 @@ export function classifyEmbeddedAgentRunResultForModelFallback(params: {
189190
.filter((payload) => payload?.isError === true)
190191
.map((payload) => (typeof payload.text === "string" ? payload.text : ""))
191192
.join("\n");
192-
const failoverReason = classifyBusinessDenialErrorPayloadReason(errorText, params.provider);
193+
const failoverReason = classifyProviderErrorPayloadReason(errorText, params.provider);
193194
if (failoverReason) {
194195
return {
195196
message: `${params.provider}/${params.model} ended with a provider error: ${errorText}`,

0 commit comments

Comments
 (0)