From b0475ecbfc5c0dde9c395d9e5bbd9ae2479e5e0f Mon Sep 17 00:00:00 2001 From: Dragon <52599892+DragonnZhang@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:04:56 +0800 Subject: [PATCH] fix(core): recover from rejected Responses encrypted reasoning --- .../responses-pipeline.test.ts | 115 +++++++++++++++++- .../responses-pipeline.ts | 27 ++-- .../responses-reasoning-rejection.test.ts | 50 ++++++++ .../responses-reasoning-rejection.ts | 33 +++++ 4 files changed, 216 insertions(+), 9 deletions(-) diff --git a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts index 62960fa7f1a..89b6f1d7c9b 100644 --- a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts +++ b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.test.ts @@ -13,7 +13,7 @@ import { beforeEach, afterEach, } from 'vitest'; -import type { GenerateContentParameters } from '@google/genai'; +import type { Content, GenerateContentParameters } from '@google/genai'; import { FunctionCallingConfigMode, FinishReason } from '@google/genai'; import { inspect } from 'node:util'; import { @@ -2047,6 +2047,119 @@ describe('ResponsesPipeline', () => { expect({ ...second, input: null }).toEqual({ ...first, input: null }); } + const encryptedError = { + error: { + type: 'invalid_request_error', + code: 'invalid_encrypted_content', + message: 'The encrypted content could not be decrypted or parsed.', + }, + }; + const encryptedBodies = [ + JSON.stringify(encryptedError), + 'data: ' + + JSON.stringify({ + routify_response: { + success: false, + status: 400, + error_detail: encryptedError, + }, + }) + + '\n\n', + ]; + + it.each(encryptedBodies)( + 'recovers once from rejected encrypted replay: %s', + async (body) => { + fetchMock.mockResolvedValueOnce(errorResponse(400, body)); + fetchMock.mockResolvedValueOnce(okResponse(COMPLETED)); + const request = replayRequest(); + const original = structuredClone(request); + const pipeline = new ResponsesPipeline( + makeGeneratorConfig(), + makeCliConfig(), + ); + expect(await drain(pipeline, request)).toBeUndefined(); + expectRetryDiffersOnlyByInput(ALL_REASONING_INPUT); + expect(request).toEqual(original); + }, + ); + + it('preserves tool call/result pairs when recovering encrypted replay', async () => { + fetchMock.mockResolvedValueOnce(errorResponse(400, encryptedBodies[0]!)); + fetchMock.mockResolvedValueOnce(okResponse(COMPLETED)); + const request = replayRequest(); + request.contents = [ + ...(request.contents as Content[]), + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_1', + name: 'lookup', + args: { value: 1 }, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_1', + name: 'lookup', + response: { output: 'found' }, + }, + }, + ], + }, + ]; + const pipeline = new ResponsesPipeline( + makeGeneratorConfig(), + makeCliConfig(), + ); + expect(await drain(pipeline, request)).toBeUndefined(); + const first = parsedCall(0); + const second = parsedCall(1); + expect(first.input.filter((i) => i.type === 'reasoning')).toHaveLength(3); + expect(second.input.filter((i) => i.type === 'reasoning')).toHaveLength( + 0, + ); + expect(second.input.slice(-2)).toEqual(first.input.slice(-2)); + expect(second.input.slice(-2).map((i) => i.type)).toEqual([ + 'function_call', + 'function_call_output', + ]); + }); + + it('surfaces the second encrypted rejection without looping', async () => { + fetchMock.mockResolvedValue(errorResponse(400, encryptedBodies[0]!)); + const pipeline = new ResponsesPipeline( + makeGeneratorConfig(), + makeCliConfig(), + ); + expect(await drain(pipeline, replayRequest())).toMatchObject({ + status: 400, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('does not retry encrypted rejection without reasoning in the request', async () => { + fetchMock.mockResolvedValue(errorResponse(400, encryptedBodies[0]!)); + const pipeline = new ResponsesPipeline( + makeGeneratorConfig(), + makeCliConfig(), + ); + expect( + await drain(pipeline, { + model: 'gpt-5', + contents: [{ role: 'user', parts: [{ text: 'hello' }] }], + }), + ).toMatchObject({ status: 400 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + // ── RED behaviors ──────────────────────────────────────────────────── it('downgrades every over-long reasoning id and retries when the endpoint reports a maximum', async () => { diff --git a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts index 28338b059c5..52aaafd9186 100644 --- a/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts +++ b/packages/core/src/core/openaiResponsesContentGenerator/responses-pipeline.ts @@ -23,6 +23,8 @@ import { } from './responses-converter.js'; import { countReasoningItems, + downgradeEncryptedReasoningItems, + isEncryptedReasoningRejection, downgradeRejectedReasoningItems, parseReasoningIdRejection, } from './responses-reasoning-rejection.js'; @@ -285,8 +287,8 @@ export class ResponsesPipeline { /** * Connect, and if the endpoint explicitly refuses a replayed reasoning item - * id, send the same request once more with those items downgraded (issue - * #9452). + * id or encrypted content, send the same request once more with those items + * downgraded (issue #9452). * * The first request always goes out exactly as built. Recovery matters * because the offending ids live in persisted history: without it, every @@ -311,7 +313,7 @@ export class ResponsesPipeline { } /** - * The retry request, or undefined when the error is not a reasoning-id + * The retry request, or undefined when the error is not a reasoning replay * rejection or nothing in this body would change. Builds a new request * object -- the caller's request, the input array, and every item it holds * are left untouched. @@ -323,17 +325,21 @@ export class ResponsesPipeline { if (typeof error !== 'object' || error === null) return undefined; const { reasoningIdRejection: rejection } = error as Partial; - if (!rejection) return undefined; - - const input = downgradeRejectedReasoningItems(apiRequest.input, rejection); + const encryptedRejected = (error as Partial) + .encryptedReasoningRejected; + const input = rejection + ? downgradeRejectedReasoningItems(apiRequest.input, rejection) + : encryptedRejected + ? downgradeEncryptedReasoningItems(apiRequest.input) + : apiRequest.input; if (input === apiRequest.input) return undefined; const reasoningItems = countReasoningItems(apiRequest.input); // Metadata only: no id, no encrypted content, no endpoint. debugLogger.debug( 'Retrying once with downgraded reasoning replay', - `namedIndex=${rejection.namedIndex}`, - `maxLengthReported=${rejection.maxLength !== null}`, + `namedIndex=${rejection?.namedIndex ?? 'all'}`, + `maxLengthReported=${rejection?.maxLength != null}`, `reasoningItems=${reasoningItems}`, `downgradedItems=${reasoningItems - countReasoningItems(input)}`, ); @@ -701,6 +707,10 @@ export class ResponsesPipeline { ) as ResponsesApiError; err.status = response.status; err.reasoningIdRejection = rejection; + err.encryptedReasoningRejected = isEncryptedReasoningRejection( + response.status, + errBody, + ); throw redactProxyError(err); } @@ -1096,6 +1106,7 @@ function sanitizePromptCacheKey(key: string): string { interface ResponsesApiError extends Error { status: number; reasoningIdRejection?: ReasoningIdRejection; + encryptedReasoningRejected?: boolean; } export function mergeStreamResponses( diff --git a/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.test.ts b/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.test.ts index 2e7670eafff..e12e6e4a13f 100644 --- a/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.test.ts +++ b/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect } from 'vitest'; import { countReasoningItems, + isEncryptedReasoningRejection, downgradeRejectedReasoningItems, parseReasoningIdRejection, } from './responses-reasoning-rejection.js'; @@ -534,3 +535,52 @@ describe('downgradeRejectedReasoningItems', () => { expect(countReasoningItems([])).toBe(0); }); }); + +describe('isEncryptedReasoningRejection', () => { + const error = { + code: 'invalid_encrypted_content', + type: 'invalid_request_error', + }; + const direct = JSON.stringify({ error }); + const gateway = JSON.stringify({ + routify_response: { success: false, status: 400, error_detail: { error } }, + }); + + it.each([direct, gateway, `data: ${gateway}\n\n`])( + 'recognizes an explicit encrypted-content rejection: %s', + (body) => { + expect(isEncryptedReasoningRejection(400, body)).toBe(true); + }, + ); + + it.each([ + ['wrong status', direct, 500], + [ + 'unknown code', + JSON.stringify({ + error: { + code: 'invalid_request_error', + message: 'invalid_encrypted_content', + }, + }), + 400, + ], + ['quoted request', JSON.stringify({ debug: { error } }), 400], + ['contradictory gateway', gateway.replace('false', 'true'), 400], + ['wrong gateway status', gateway.replace('400', '500'), 400], + [ + 'duplicate error key', + `{"error":{},"error":${JSON.stringify(error)}}`, + 400, + ], + ['oversized', direct.padEnd(64001, ' '), 400], + ['trailing garbage', direct + 'garbage', 400], + ['multiple frames', `data: ${gateway}\n\ndata: ${gateway}\n\n`, 400], + ['malformed', '{', 400], + ['array', `[${direct}]`, 400], + ])('does not recover on %s', (_label, body, status) => { + expect(isEncryptedReasoningRejection(Number(status), String(body))).toBe( + false, + ); + }); +}); diff --git a/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.ts b/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.ts index b764c015299..5a6da8eca86 100644 --- a/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.ts +++ b/packages/core/src/core/openaiResponsesContentGenerator/responses-reasoning-rejection.ts @@ -477,3 +477,36 @@ function readStringLiteral( export function countReasoningItems(items: ResponsesApiInputItem[]): number { return items.filter(isReasoningItem).length; } + +export function isEncryptedReasoningRejection( + status: number, + responseBody: string, +): boolean { + if (status !== 400 || responseBody.length > MAX_BODY_CHARS) return false; + // Some gateways return one SSE data frame even on a non-2xx response. + const text = toEnvelopeText(responseBody.replace(/^data: ?/, '')); + if (!text) return false; + const envelope = readEnvelope(text, MAX_OBJECT_CANDIDATES, false); + if (!envelope || !isPlainObject(envelope.value)) return false; + const direct = readErrorMember(envelope.value); + if (direct) return direct['code'] === 'invalid_encrypted_content'; + + const gateway = envelope.value['routify_response']; + if ( + !isPlainObject(gateway) || + gateway['success'] !== false || + gateway['status'] !== 400 + ) + return false; + const error = readErrorMember(gateway['error_detail']); + return error?.['code'] === 'invalid_encrypted_content'; +} + +export function downgradeEncryptedReasoningItems( + items: ResponsesApiInputItem[], +): ResponsesApiInputItem[] { + return downgradeRejectedReasoningItems(items, { + namedIndex: items.findIndex(isReasoningItem), + maxLength: null, + }); +}