From 142afaf876e11309aed5ec570a5e8e87edff690b Mon Sep 17 00:00:00 2001 From: Vadym O Date: Sun, 18 Oct 2026 22:59:42 +0800 Subject: [PATCH 1/2] fix(combo): log the upstream refusal reason on combo target failure The combo hop warning printed only ` failed with 401 after Nms`, so a revoked credential or plan downgrade logged hundreds of identical lines that read like a provider outage. Append the upstream error code and parsed message (falling back to the normalized body text), collapsed to one line, redacted and capped at 200 characters via sanitizeLogMetadataString. Co-Authored-By: Claude Code --- src/server/responses/core-combo.ts | 10 +++++++++- .../responses-combo-exhausted-error.test.ts | 19 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index c96244d1718..4dba1cca44e 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -45,6 +45,7 @@ import { } from "../../combos"; import { formatErrorResponse } from "../../bridge"; import { SEND_BUDGET_EXHAUSTED_CODE } from "../../lib/errors"; +import { sanitizeLogMetadataString } from "../../lib/redact"; import { expandPreviousResponseInput, previousResponseReplayFailure, @@ -1195,8 +1196,15 @@ export async function executeComboResponses( } return lastFailure; } + // A bare status made a revoked credential read as a provider outage. Prefer the parsed + // message over the whole envelope, and keep one redacted line per failure. + const failureReason = sanitizeLogMetadataString( + [failure.upstreamCode, failure.upstreamMessage ?? failure.classificationText].filter(Boolean).join(": ").replace(/\s+/g, " "), + 200, + ); console.warn( - `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status} after ${Date.now() - started}ms`, + `[combo] ${comboId}: ${targetKey(pick.target)} failed with ${failure.response.status}` + + ` after ${Date.now() - started}ms${failureReason ? `: ${failureReason}` : ""}`, ); const failureNow = Date.now(); const attemptedTargets = pick.attempted; diff --git a/tests/responses/responses-combo-exhausted-error.test.ts b/tests/responses/responses-combo-exhausted-error.test.ts index c0c1410004c..c12cc17c409 100644 --- a/tests/responses/responses-combo-exhausted-error.test.ts +++ b/tests/responses/responses-combo-exhausted-error.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { clearComboSelectionState, clearComboTargetCooldowns, coolComboTarget, snapshotComboQuotaCooldowns } from "../../src/combos"; import { clearKeyCooldowns } from "../../src/providers/key-failover"; import { clearCachedProviderQuotas, setCachedProviderQuotaForTests } from "../../src/providers/quota-routing-cache"; @@ -284,6 +284,23 @@ describe("combo exhaustion reports the primary's quota refusal", () => { expect(logCtx.attempts).toHaveLength(2); }); + test("the hop warning names the upstream refusal on one redacted line", async () => { + const key = "sk-proj-" + "a1B2c3D4e5F6g7H8i9J0k1L2m3N4o5P6"; + globalThis.fetch = (async () => Response.json( + { error: { code: "invalid_api_key", message: `Incorrect API key provided: ${key}.\nRotate it.` } }, + { status: 401 }, + )) as unknown as typeof fetch; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await loggedCombo(config, { model: "", provider: "" }); + const line = warn.mock.calls.map(call => String(call[0])).find(text => text.includes("primary/model-primary failed with")); + expect(line).toMatch(/failed with 401 after \d+ms: invalid_api_key: Incorrect API key provided: .* Rotate it\.$/); + expect(line).not.toContain(key); + } finally { + warn.mockRestore(); + } + }); + test("a fallback 5xx is still the answer", async () => { upstream(502, "bad gateway"); const response = await handleResponses(request(), config, { model: "", provider: "" } as RequestLogContext); From e5eb961a387946bf21eaa74fc5a031ac721c78e6 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Mon, 19 Oct 2026 13:55:21 +0800 Subject: [PATCH 2/2] fix(combo): log only the parsed upstream code and message on a hop failure Drop the classificationText fallback so an unparsed upstream body never reaches the warning line; the log boundary is now the two parsed fields. Co-Authored-By: Claude Code --- src/server/responses/core-combo.ts | 6 +++--- .../responses-combo-exhausted-error.test.ts | 12 ++++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/server/responses/core-combo.ts b/src/server/responses/core-combo.ts index 4dba1cca44e..5637a1ecd88 100644 --- a/src/server/responses/core-combo.ts +++ b/src/server/responses/core-combo.ts @@ -1196,10 +1196,10 @@ export async function executeComboResponses( } return lastFailure; } - // A bare status made a revoked credential read as a provider outage. Prefer the parsed - // message over the whole envelope, and keep one redacted line per failure. + // A bare status made a revoked credential read as a provider outage. Log only the parsed + // code and message, never the raw envelope, as one redacted, bounded line. const failureReason = sanitizeLogMetadataString( - [failure.upstreamCode, failure.upstreamMessage ?? failure.classificationText].filter(Boolean).join(": ").replace(/\s+/g, " "), + [failure.upstreamCode, failure.upstreamMessage].filter(Boolean).join(": ").replace(/\s+/g, " "), 200, ); console.warn( diff --git a/tests/responses/responses-combo-exhausted-error.test.ts b/tests/responses/responses-combo-exhausted-error.test.ts index c12cc17c409..8a7a1a0e8c6 100644 --- a/tests/responses/responses-combo-exhausted-error.test.ts +++ b/tests/responses/responses-combo-exhausted-error.test.ts @@ -301,6 +301,18 @@ describe("combo exhaustion reports the primary's quota refusal", () => { } }); + test("the hop warning never logs an unparsed upstream body", async () => { + globalThis.fetch = (async () => new Response("upstream said: session=abc123 private detail", { status: 502 })) as unknown as typeof fetch; + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + await loggedCombo(config, { model: "", provider: "" }); + const line = warn.mock.calls.map(call => String(call[0])).find(text => text.includes("primary/model-primary failed with")); + expect(line).toMatch(/failed with 502 after \d+ms$/); + } finally { + warn.mockRestore(); + } + }); + test("a fallback 5xx is still the answer", async () => { upstream(502, "bad gateway"); const response = await handleResponses(request(), config, { model: "", provider: "" } as RequestLogContext);