diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index 03df9766863..76993b3ea82 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -116,6 +116,80 @@ export function isReasoningBlobCallerMismatchMessage(message: string): boolean { } +function liteLlmEmbeddedErrorPayload(message: string): unknown { + if (!message.startsWith("litellm.BadRequestError:")) return undefined; + const marker = "OpenAIException - "; + const markerIndex = message.indexOf(marker); + if (markerIndex < 0) return undefined; + const start = message.indexOf("{", markerIndex + marker.length); + if (start < 0) return undefined; + + let depth = 0; + let inString = false; + let escaped = false; + for (let index = start; index < message.length; index += 1) { + const character = message[index]!; + if (inString) { + if (escaped) escaped = false; + else if (character === "\\") escaped = true; + else if (character === '"') inString = false; + continue; + } + if (character === '"') { inString = true; continue; } + if (character === "{") depth += 1; + else if (character === "}") { + depth -= 1; + if (depth === 0) { + try { return JSON.parse(message.slice(start, index + 1)) as unknown; } catch { return undefined; } + } + } + } + return undefined; +} + + +function isOpaqueBlobErrorPayload(payload: unknown, allowLiteLlmWrapper: boolean): boolean { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + const record = payload as { code?: unknown; type?: unknown; message?: unknown; error?: unknown }; + + if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) { + const error = record.error as { type?: unknown; code?: unknown; message?: unknown }; + if (error.type === "invalid_request_error") { + if (error.code === "invalid_encrypted_content") return true; + if ( + (error.code === null || error.code === undefined) + && typeof error.message === "string" + && error.message.startsWith("The encrypted content ") + && error.message.endsWith( + " could not be verified. Reason: Encrypted content could not be decrypted or parsed.", + ) + ) return true; + // #4469: the caller-mismatch wording arrives without a dedicated code, so the + // message itself is the identity. It is not gated on code being null — the upstream + // may attach a generic code — because the anchored phrase is already specific. + if (typeof error.message === "string" && isReasoningBlobCallerMismatchMessage(error.message)) { + return true; + } + } + if (allowLiteLlmWrapper && typeof error.message === "string") { + return isOpaqueBlobErrorPayload(liteLlmEmbeddedErrorPayload(error.message), false); + } + } + + // The flat stream-error envelope carries type/message at the top level rather than under + // an error object; the same anchored identity applies there. + if ( + record.type === "invalid_request_error" + && typeof record.message === "string" + && isReasoningBlobCallerMismatchMessage(record.message) + ) return true; + + if (record.code !== "invalid-argument" || typeof record.error !== "string") return false; + return record.error.startsWith("Could not decode the compaction blob") + || record.error.startsWith("Could not decrypt the provided encrypted_content"); +} + + export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { if (isEncryptedFunctionOutputRejection(bodyText)) return true; try { @@ -127,41 +201,7 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean { } try { const payload = JSON.parse(bodyText) as unknown; - if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; - const record = payload as { code?: unknown; type?: unknown; message?: unknown; error?: unknown }; - - if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) { - const error = record.error as { type?: unknown; code?: unknown; message?: unknown }; - if (error.type === "invalid_request_error") { - if (error.code === "invalid_encrypted_content") return true; - if ( - (error.code === null || error.code === undefined) - && typeof error.message === "string" - && error.message.startsWith("The encrypted content ") - && error.message.endsWith( - " could not be verified. Reason: Encrypted content could not be decrypted or parsed.", - ) - ) return true; - // #4469: the caller-mismatch wording arrives without a dedicated code, so the - // message itself is the identity. It is not gated on code being null — the upstream - // may attach a generic code — because the anchored phrase is already specific. - if (typeof error.message === "string" && isReasoningBlobCallerMismatchMessage(error.message)) { - return true; - } - } - } - - // The flat stream-error envelope carries type/message at the top level rather than under - // an error object; the same anchored identity applies there. - if ( - record.type === "invalid_request_error" - && typeof record.message === "string" - && isReasoningBlobCallerMismatchMessage(record.message) - ) return true; - - if (record.code !== "invalid-argument" || typeof record.error !== "string") return false; - return record.error.startsWith("Could not decode the compaction blob") - || record.error.startsWith("Could not decrypt the provided encrypted_content"); + return isOpaqueBlobErrorPayload(payload, true); } catch { return false; } diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 09bf0d1ccb7..94e317cf238 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -332,6 +332,9 @@ turn and the item outlives the failure in the client transcript, repeating on ev including the compaction turn the proxy itself drives. With `store: false`, request sanitization strips ids from every input item, including compact-wire items, matching codex-rs (`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side field backfill. +If LiteLLM wraps an upstream OpenAI error as prose in its outer `error.message`, reactive recovery +parses only the bounded `OpenAIException - {JSON}` payload and applies the existing recognized +opaque-rejection identities to that inner payload; other LiteLLM 400 responses do not retry. For replayed `encrypted_content` slots whose minting provenance is unavailable after a restart or full-history resend, the plaintext-compatibility boundary requires canonical key-independent Fernet diff --git a/tests/responses/responses-opaque-blob-recovery.test.ts b/tests/responses/responses-opaque-blob-recovery.test.ts index 1fcf62d7ab4..ba3868a1490 100644 --- a/tests/responses/responses-opaque-blob-recovery.test.ts +++ b/tests/responses/responses-opaque-blob-recovery.test.ts @@ -38,6 +38,15 @@ const OPENAI_BLOB_ERROR = JSON.stringify({ code: "invalid_encrypted_content", }, }); +const LITELLM_BLOB_ERROR = JSON.stringify({ + error: { + message: "litellm.BadRequestError: OpenAIException - " + OPENAI_BLOB_ERROR + + " This error occurs when load balancing Responses API across deployments with different API keys.", + type: "invalid_request_error", + param: null, + code: "400", + }, +}); const CHATGPT_UNVERIFIABLE_BLOB_ERROR = JSON.stringify({ error: { message: "The encrypted content 6871-test-ef-0 could not be verified. Reason: Encrypted content could not be decrypted or parsed.", @@ -430,8 +439,9 @@ describe("opaque blob recovery trigger", () => { alreadyAttempted: false, }; - test("accepts OpenAI and both xAI opaque-state rejection identities", () => { + test("accepts OpenAI, LiteLLM-wrapped OpenAI, and both xAI rejection identities", () => { expect(shouldAttemptOpaqueBlobRecovery(base)).toBe(true); + expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: LITELLM_BLOB_ERROR })).toBe(true); expect(shouldAttemptOpaqueBlobRecovery({ ...base, errorBody: CHATGPT_UNVERIFIABLE_BLOB_ERROR, @@ -447,6 +457,18 @@ describe("opaque blob recovery trigger", () => { error: { type: "invalid_request_error", code: "unknown_parameter", message: "Unknown parameter" }, }), })).toBe(false); + expect(shouldAttemptOpaqueBlobRecovery({ + ...base, + errorBody: JSON.stringify({ + error: { + type: "invalid_request_error", + code: "400", + message: "litellm.BadRequestError: OpenAIException - " + JSON.stringify({ + error: { type: "invalid_request_error", code: "unknown_parameter", message: "Unknown parameter" }, + }), + }, + }), + })).toBe(false); expect(shouldAttemptOpaqueBlobRecovery({ ...base, status: 500 })).toBe(false); expect(shouldAttemptOpaqueBlobRecovery({ ...base, @@ -596,6 +618,25 @@ describe("opaque blob recovery through /v1/responses", () => { }]); }); + test("recovers LiteLLM-wrapped invalid encrypted content with one sanitized resend", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 ? rejection(LITELLM_BLOB_ERROR) : success("resp-litellm-recovered"); + }) as typeof fetch; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(), config(), logCtx); + expect(response.status).toBe(200); + await response.text(); + + expect(outbound).toHaveLength(2); + expect(hasBlob(outbound[0]!)).toBe(true); + expect(hasBlob(outbound[1]!)).toBe(false); + expect(logCtx.activeAttempt?.sendCount).toBe(2); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + }); + test("recovers a zero-output streamed function-output decrypt failure before client relay", async () => { const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {