Refactor reasoning handling and metadata management in responses

- Updated chat stream tests to clarify reasoning behavior and ensure correct handling of include lists.
- Enhanced responses proxy route to normalize include lists and apply default reasoning includes based on request context.
- Improved transformer metadata handling, ensuring proper serialization and deserialization of metadata records.
- Added tests for preserving reasoning signatures and managing citations in OpenAI-compatible responses.
- Refined the logic for injecting default includes in responses and ensured compatibility with codex surface defaults.
This commit is contained in:
cita
2026-03-08 23:03:33 +08:00
parent 7bd3114ea0
commit 3dcf45851f
12 changed files with 830 additions and 56 deletions
@@ -7,6 +7,7 @@ import type {
OpenAiChatToolCall,
OpenAiChatUsageDetails,
} from './model.js';
import { fromTransformerMetadataRecord } from '../../shared/normalized.js';
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
@@ -345,7 +346,9 @@ function extractCitationsFromPayload(
citations.add(citation);
}
const transformerMetadata = asRecord(payload.transformer_metadata) ?? asRecord(payload.transformerMetadata);
const transformerMetadata = fromTransformerMetadataRecord(
payload.transformer_metadata ?? payload.transformerMetadata,
);
if (transformerMetadata) {
for (const citation of dedupeCitations(transformerMetadata.citations)) {
citations.add(citation);
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { createClaudeDownstreamContext } from '../../shared/normalized.js';
import { toTransformerMetadataRecord } from '../../shared/normalized.js';
import { openAiChatTransformer } from './index.js';
function parseSsePayloads(lines: string[]): Array<Record<string, unknown>> {
@@ -154,6 +155,32 @@ describe('openAiChatTransformer.outbound', () => {
});
});
it('consumes shared transformer metadata citations on openai-compatible final responses', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-shared-metadata',
model: 'gpt-5',
created: 123,
transformer_metadata: toTransformerMetadataRecord({
citations: [{ uri: 'https://shared.example/citation' }],
thoughtSignature: 'sig-final',
thoughtSignatures: ['sig-tool', 'sig-final'],
passthrough: {
cachedContent: 'cached/item-1',
},
}),
choices: [{
index: 0,
finish_reason: 'stop',
message: {
role: 'assistant',
content: 'hello',
},
}],
}, 'gpt-5');
expect((normalized as any).citations).toEqual(['https://shared.example/citation']);
});
it('serializes multi-choice chat responses with per-choice annotations, reasoning, tool calls, and shared citations', () => {
const normalized = openAiChatTransformer.transformFinalResponse({
id: 'chatcmpl-multi',
@@ -81,7 +81,31 @@ describe('sanitizeResponsesBodyForProxy', () => {
});
});
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list', () => {
it('defaults encrypted reasoning include when reasoning is requested without an explicit include list on the codex responses surface', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
summary: 'auto',
},
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
reasoning: {
effort: 'high',
summary: 'auto',
},
});
});
it('does not inject default include on generic responses requests when the codex surface default is disabled', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
@@ -95,8 +119,68 @@ describe('sanitizeResponsesBodyForProxy', () => {
false,
);
expect(result.include).toBeUndefined();
});
it('defaults encrypted reasoning include on codex responses requests even without explicit reasoning config', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
});
});
it('respects an explicit empty include list when the codex responses default is enabled', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
summary: 'auto',
},
include: [],
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: [],
reasoning: {
effort: 'high',
summary: 'auto',
},
});
});
it('respects an explicit custom include list when the codex responses default is enabled', () => {
const result = sanitizeResponsesBodyForProxy(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
summary: 'auto',
},
include: ['message.input_image.image_url'],
},
'gpt-5',
false,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['message.input_image.image_url'],
reasoning: {
effort: 'high',
summary: 'auto',
@@ -296,6 +380,19 @@ describe('convertOpenAiBodyToResponsesBody', () => {
});
});
it('does not inject default include when converting non-responses OpenAI input into Responses bodies', () => {
const result = convertOpenAiBodyToResponsesBody(
{
model: 'gpt-5',
messages: [{ role: 'user', content: 'hello' }],
},
'gpt-5',
true,
);
expect(result.include).toBeUndefined();
});
it('maps OpenAI file-style content blocks into Responses input_file blocks', () => {
const result = convertOpenAiBodyToResponsesBody(
{
@@ -557,7 +654,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
});
});
it('adds encrypted reasoning include for OpenAI-compatible fallback when reasoning options are present', () => {
it('does not inject default include for generic OpenAI-compatible fallback when reasoning options are present', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
@@ -570,6 +667,23 @@ describe('convertResponsesBodyToOpenAiBody', () => {
true,
);
expect(result.include).toBeUndefined();
});
it('adds encrypted reasoning include for OpenAI-compatible fallback when the codex surface default is enabled', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
reasoning: {
@@ -578,6 +692,81 @@ describe('convertResponsesBodyToOpenAiBody', () => {
});
});
it('does not inject default include for generic OpenAI-compatible fallback when responses input omits include and reasoning', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
},
'gpt-5',
true,
);
expect(result.include).toBeUndefined();
});
it('adds encrypted reasoning include for OpenAI-compatible fallback when the codex surface default is enabled even without explicit reasoning config', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['reasoning.encrypted_content'],
});
});
it('respects an explicit empty include list for OpenAI-compatible fallback when the codex surface default is enabled', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
include: [],
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: [],
reasoning: {
effort: 'high',
},
});
});
it('respects an explicit custom include list for OpenAI-compatible fallback when the codex surface default is enabled', () => {
const result = convertResponsesBodyToOpenAiBody(
{
model: 'gpt-5',
input: 'hello',
reasoning: {
effort: 'high',
},
include: ['message.input_image.image_url'],
},
'gpt-5',
true,
{ defaultEncryptedReasoningInclude: true },
);
expect(result).toMatchObject({
include: ['message.input_image.image_url'],
reasoning: {
effort: 'high',
},
});
});
it('keeps Responses input_file items when converting back to OpenAI-compatible bodies', () => {
const result = convertResponsesBodyToOpenAiBody(
{
@@ -82,30 +82,17 @@ function normalizeIncludeList(value: unknown): unknown {
.filter((item) => item.length > 0);
}
function hasReasoningRequest(value: unknown): boolean {
if (!isRecord(value)) return false;
const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary'];
return relevantKeys.some((key) => {
const entry = value[key];
if (typeof entry === 'string') return entry.trim().length > 0;
return entry !== undefined && entry !== null;
});
function hasExplicitInclude(body: Record<string, unknown>): boolean {
return Object.prototype.hasOwnProperty.call(body, 'include');
}
function ensureEncryptedReasoningInclude(body: Record<string, unknown>): void {
if (!hasReasoningRequest(body.reasoning)) return;
const normalizedInclude = normalizeIncludeList(body.include);
const includeList = Array.isArray(normalizedInclude) ? normalizedInclude : [];
const alreadyRequested = includeList.some((item) => (
typeof item === 'string' && item.trim().toLowerCase() === 'reasoning.encrypted_content'
));
if (alreadyRequested) {
body.include = includeList;
function applyDefaultResponsesInclude(body: Record<string, unknown>): void {
if (hasExplicitInclude(body)) {
body.include = normalizeIncludeList(body.include);
return;
}
body.include = [...includeList, 'reasoning.encrypted_content'];
body.include = ['reasoning.encrypted_content'];
}
function normalizeTextConfig(
@@ -137,7 +124,10 @@ function normalizeStreamOptions(rawStreamOptions: unknown): unknown {
function normalizeResponsesRequestFieldParity(
body: Record<string, unknown>,
options?: { verbositySource?: unknown },
options?: {
verbositySource?: unknown;
defaultEncryptedReasoningInclude?: boolean;
},
): Record<string, unknown> {
const normalized: Record<string, unknown> = { ...body };
@@ -174,7 +164,9 @@ function normalizeResponsesRequestFieldParity(
if (normalized.include !== undefined) {
normalized.include = normalizeIncludeList(normalized.include);
}
ensureEncryptedReasoningInclude(normalized);
if (options?.defaultEncryptedReasoningInclude) {
applyDefaultResponsesInclude(normalized);
}
if (normalized.stream_options !== undefined) {
normalized.stream_options = normalizeStreamOptions(normalized.stream_options);
@@ -376,6 +368,7 @@ export function sanitizeResponsesBodyForProxy(
body: Record<string, unknown>,
modelName: string,
stream: boolean,
options?: { defaultEncryptedReasoningInclude?: boolean },
): Record<string, unknown> {
let normalized = normalizeResponsesBodyForCompatibility({
...body,
@@ -401,6 +394,7 @@ export function sanitizeResponsesBodyForProxy(
normalized = normalizeResponsesRequestFieldParity(normalized, {
verbositySource: body.verbosity,
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
});
const sanitized: Record<string, unknown> = {};
@@ -717,8 +711,11 @@ export function convertResponsesBodyToOpenAiBody(
body: Record<string, unknown>,
modelName: string,
stream: boolean,
options?: { defaultEncryptedReasoningInclude?: boolean },
): Record<string, unknown> {
const normalizedBody = normalizeResponsesRequestFieldParity(body);
const normalizedBody = normalizeResponsesRequestFieldParity(body, {
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
});
const messages: Array<Record<string, unknown>> = [];
const input = normalizedBody.input;
let functionCallIndex = 0;