Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import {
} from './responses-converter.js';
import {
countReasoningItems,
downgradeEncryptedReasoningItems,
isEncryptedReasoningRejection,
downgradeRejectedReasoningItems,
parseReasoningIdRejection,
} from './responses-reasoning-rejection.js';
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -323,17 +325,21 @@ export class ResponsesPipeline {
if (typeof error !== 'object' || error === null) return undefined;
const { reasoningIdRejection: rejection } =
error as Partial<ResponsesApiError>;
if (!rejection) return undefined;

const input = downgradeRejectedReasoningItems(apiRequest.input, rejection);
const encryptedRejected = (error as Partial<ResponsesApiError>)
.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)}`,
);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -1096,6 +1106,7 @@ function sanitizePromptCacheKey(key: string): string {
interface ResponsesApiError extends Error {
status: number;
reasoningIdRejection?: ReasoningIdRejection;
encryptedReasoningRejected?: boolean;
}

export function mergeStreamResponses(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest';
import {
countReasoningItems,
isEncryptedReasoningRejection,
downgradeRejectedReasoningItems,
parseReasoningIdRejection,
} from './responses-reasoning-rejection.js';
Expand Down Expand Up @@ -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,
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
Loading