diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 07e2560..930cf7b 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -1826,7 +1826,7 @@ describe('chat proxy stream behavior', () => { expect(targetUrl).toContain('/v1/responses'); }); - it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without an explicit include list', async () => { + it('prefers native /v1/responses for claude-family /v1/responses requests that opt into reasoning without injecting a generic default include', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, @@ -1870,6 +1870,96 @@ describe('chat proxy stream behavior', () => { }, }); + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/responses'); + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.include).toBeUndefined(); + }); + + it('keeps generic claude-family /v1/responses requests on the default messages-first order when codex headers are absent', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_default_messages_first', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'messages endpoint selected by default' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + payload: { + model: 'claude-haiku-4-5-20251001', + input: 'hello', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + }); + + it('defaults encrypted reasoning include and prefers native /v1/responses for claude-family codex-surface requests even without reasoning config', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'resp_reasoning_default', + object: 'response', + model: 'upstream-gpt', + output_text: 'hello from responses upstream', + output: [ + { + id: 'msg_reasoning_default', + type: 'message', + role: 'assistant', + status: 'completed', + content: [{ type: 'output_text', text: 'hello from responses upstream' }], + }, + ], + status: 'completed', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'openai-beta': 'responses-2025-03-11', + 'x-stainless-lang': 'typescript', + originator: 'codex_cli_rs', + }, + payload: { + model: 'claude-haiku-4-5-20251001', + input: 'hello', + }, + }); + expect(response.statusCode).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(1); const [targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; @@ -1878,6 +1968,100 @@ describe('chat proxy stream behavior', () => { expect(forwardedBody.include).toEqual(['reasoning.encrypted_content']); }); + it('keeps explicit empty include on claude-family codex-surface responses requests and stays on the default messages-first order', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_explicit_empty_include', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'messages endpoint selected because include stayed empty' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'openai-beta': 'responses-2025-03-11', + 'x-stainless-lang': 'typescript', + originator: 'codex_cli_rs', + }, + payload: { + model: 'claude-haiku-4-5-20251001', + input: 'hello', + reasoning: { + effort: 'high', + summary: 'auto', + }, + include: [], + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + }); + + it('keeps explicit custom include on claude-family codex-surface responses requests and stays on the default messages-first order', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'generic-site', url: 'https://upstream.example.com', platform: 'new-api' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-demo', + actualModel: 'upstream-gpt', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'msg_explicit_custom_include', + type: 'message', + model: 'upstream-gpt', + content: [{ type: 'text', text: 'messages endpoint selected because custom include stayed explicit' }], + stop_reason: 'end_turn', + usage: { input_tokens: 7, output_tokens: 3, total_tokens: 10 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/responses', + headers: { + 'openai-beta': 'responses-2025-03-11', + 'x-stainless-lang': 'typescript', + originator: 'codex_cli_rs', + }, + payload: { + model: 'claude-haiku-4-5-20251001', + input: 'hello', + reasoning: { + effort: 'high', + summary: 'auto', + }, + include: ['message.input_image.image_url'], + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [targetUrl] = fetchMock.mock.calls[0] as [string, any]; + expect(targetUrl).toContain('/v1/messages'); + }); + it('forces anyrouter platform to prefer /v1/messages even when catalog says openai', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 20f3bff..690ad69 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -26,7 +26,6 @@ import { getProxyResourceOwner } from '../../middleware/auth.js'; import { ProxyInputFileResolutionError, hasNonImageFileInputInOpenAiBody, - resolveOpenAiBodyInputFiles, resolveResponsesBodyInputFiles, } from '../../services/proxyInputFileResolver.js'; @@ -36,6 +35,21 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object'; } +function normalizeIncludeList(value: unknown): string[] { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? [trimmed] : []; + } + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === 'string' ? item.trim() : '')) + .filter((item) => item.length > 0); +} + +function hasExplicitInclude(body: Record): boolean { + return Object.prototype.hasOwnProperty.call(body, 'include'); +} + function hasResponsesReasoningRequest(value: unknown): boolean { if (!isRecord(value)) return false; const relevantKeys = ['effort', 'budget_tokens', 'budgetTokens', 'max_tokens', 'maxTokens', 'summary']; @@ -46,17 +60,79 @@ function hasResponsesReasoningRequest(value: unknown): boolean { }); } +function carriesResponsesReasoningContinuity(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some((item) => carriesResponsesReasoningContinuity(item)); + } + if (!isRecord(value)) return false; + + const type = typeof value.type === 'string' ? value.type.trim().toLowerCase() : ''; + if (type === 'reasoning') { + if (typeof value.encrypted_content === 'string' && value.encrypted_content.trim()) { + return true; + } + if (Array.isArray(value.summary) && value.summary.length > 0) { + return true; + } + } + + if (typeof value.reasoning_signature === 'string' && value.reasoning_signature.trim()) { + return true; + } + + return carriesResponsesReasoningContinuity(value.input) + || carriesResponsesReasoningContinuity(value.content); +} + +function isCodexResponsesSurface(headers?: Record): boolean { + if (!headers) return false; + + const normalizeHeaderValue = (value: unknown): string => { + if (typeof value === 'string') return value.trim(); + if (Array.isArray(value)) { + return value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .find((item) => item.length > 0) || ''; + } + return ''; + }; + + let sawOpenAiBeta = false; + let sawStainless = false; + + for (const [rawKey, rawValue] of Object.entries(headers)) { + const key = rawKey.trim().toLowerCase(); + const value = normalizeHeaderValue(rawValue); + if (!key || !value) continue; + + if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') { + return true; + } + if (key === 'openai-beta') { + sawOpenAiBeta = true; + } + if (key.startsWith('x-stainless-')) { + sawStainless = true; + } + } + + return sawOpenAiBeta || sawStainless; +} + function wantsNativeResponsesReasoning(body: unknown): boolean { if (!isRecord(body)) return false; - - const include = Array.isArray(body.include) - ? body.include - : (typeof body.include === 'string' ? [body.include] : []); - - return include.some((item) => ( - typeof item === 'string' - && item.trim().toLowerCase() === 'reasoning.encrypted_content' - )) || hasResponsesReasoningRequest(body.reasoning); + const include = normalizeIncludeList(body.include); + if (include.some((item) => item.toLowerCase() === 'reasoning.encrypted_content')) { + return true; + } + if (carriesResponsesReasoningContinuity(body.input)) { + return true; + } + if (hasExplicitInclude(body)) { + return false; + } + return hasResponsesReasoningRequest(body.reasoning); } type UsageSummary = ReturnType; @@ -103,14 +179,19 @@ export async function responsesProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); const modelName = selected.actualModel || requestedModel; - const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody(body, modelName, isStream); const owner = getProxyResourceOwner(request); - let resolvedOpenAiBody = openAiBody; - let resolvedResponsesBody = body; + const defaultEncryptedReasoningInclude = isCodexResponsesSurface( + request.headers as Record, + ); + let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody( + body, + modelName, + isStream, + { defaultEncryptedReasoningInclude }, + ); if (owner) { try { - resolvedOpenAiBody = await resolveOpenAiBodyInputFiles(openAiBody, owner); - resolvedResponsesBody = await resolveResponsesBodyInputFiles(body, owner); + normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner); } catch (error) { if (error instanceof ProxyInputFileResolutionError) { return reply.code(error.statusCode).send(error.payload); @@ -118,8 +199,14 @@ export async function responsesProxyRoute(app: FastifyInstance) { throw error; } } - const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(resolvedOpenAiBody); - const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(resolvedResponsesBody); + const openAiBody = openAiResponsesTransformer.inbound.toOpenAiBody( + normalizedResponsesBody, + modelName, + isStream, + { defaultEncryptedReasoningInclude }, + ); + const hasNonImageFileInput = hasNonImageFileInputInOpenAiBody(openAiBody); + const prefersNativeResponsesReasoning = wantsNativeResponsesReasoning(normalizedResponsesBody); const endpointCandidates = await resolveUpstreamEndpointCandidates( { site: selected.site, @@ -152,9 +239,9 @@ export async function responsesProxyRoute(app: FastifyInstance) { tokenValue: selected.tokenValue, sitePlatform: selected.site.platform, siteUrl: selected.site.url, - openaiBody: resolvedOpenAiBody, + openaiBody: openAiBody, downstreamFormat: 'responses', - responsesOriginalBody: resolvedResponsesBody, + responsesOriginalBody: normalizedResponsesBody, downstreamHeaders: request.headers as Record, }); const upstreamPath = ( diff --git a/src/server/transformers/anthropic/messages/conversion.test.ts b/src/server/transformers/anthropic/messages/conversion.test.ts index 4030ff8..c765dbb 100644 --- a/src/server/transformers/anthropic/messages/conversion.test.ts +++ b/src/server/transformers/anthropic/messages/conversion.test.ts @@ -358,6 +358,41 @@ describe('convertOpenAiBodyToAnthropicMessagesBody', () => { ]); }); + it('preserves top-level reasoning_signature when rebuilding assistant thinking blocks for messages fallback', () => { + const body = convertOpenAiBodyToAnthropicMessagesBody( + { + model: 'gpt-5', + messages: [ + { + role: 'assistant', + content: 'final answer', + reasoning_content: 'internal only', + reasoning_signature: 'metapi:anthropic-signature:sig-top-level-1', + }, + ], + }, + 'claude-opus-4-6', + false, + ); + + expect(body.messages).toEqual([ + { + role: 'assistant', + content: [ + { + type: 'thinking', + thinking: 'internal only', + signature: 'sig-top-level-1', + }, + { + type: 'text', + text: 'final answer', + }, + ], + }, + ]); + }); + it('strips cache_control from thinking blocks and empty text blocks', () => { const body = convertOpenAiBodyToAnthropicMessagesBody( { diff --git a/src/server/transformers/anthropic/messages/conversion.ts b/src/server/transformers/anthropic/messages/conversion.ts index 70b11e1..c240e2e 100644 --- a/src/server/transformers/anthropic/messages/conversion.ts +++ b/src/server/transformers/anthropic/messages/conversion.ts @@ -301,9 +301,10 @@ function sanitizeAnthropicContentBlock(item: Record): Record = { ...item }; if (type === 'redacted_thinking') { next.type = 'redacted_thinking'; @@ -772,6 +773,15 @@ export function convertOpenAiBodyToAnthropicMessagesBody( if (role !== 'assistant') return; const contentBlocks = convertOpenAiContentToAnthropicBlocks(item.content); + const reasoningCarrier = sanitizeAnthropicContentBlock({ + type: 'thinking', + thinking: asTrimmedString(item.reasoning_content ?? item.reasoning), + reasoning_signature: item.reasoning_signature, + signature: item.signature, + }); + if (reasoningCarrier) { + contentBlocks.unshift(reasoningCarrier); + } const rawToolCalls = Array.isArray(item.tool_calls) ? item.tool_calls : []; for (let index = 0; index < rawToolCalls.length; index += 1) { diff --git a/src/server/transformers/gemini/generate-content/aggregator-outbound-usage.test.ts b/src/server/transformers/gemini/generate-content/aggregator-outbound-usage.test.ts index 52868b7..6c650ec 100644 --- a/src/server/transformers/gemini/generate-content/aggregator-outbound-usage.test.ts +++ b/src/server/transformers/gemini/generate-content/aggregator-outbound-usage.test.ts @@ -5,6 +5,7 @@ import { createGeminiGenerateContentAggregateState, } from './aggregator.js'; import { + extractTransformerMetadata, extractResponseMetadata, serializeGeminiAggregateResponse, } from './outbound.js'; @@ -240,6 +241,60 @@ describe('Gemini aggregate/outbound/usage', () => { }); }); + it('projects Gemini richer metadata into the shared transformer metadata contract', () => { + const state = createGeminiGenerateContentAggregateState(); + applyGeminiGenerateContentAggregate(state, { + responseId: 'resp-shared-meta', + modelVersion: 'gemini-3.1-pro-preview', + candidates: [ + { + index: 0, + finishReason: 'STOP', + groundingMetadata: { webSearchQueries: ['cats'] }, + citationMetadata: { citations: [{ uri: 'https://example.com/cats' }] }, + content: { + parts: [ + { text: 'reasoning', thought: true, thoughtSignature: 'sig-final' }, + { text: 'answer' }, + ], + }, + }, + ], + usageMetadata: { + promptTokenCount: 15, + candidatesTokenCount: 5, + totalTokenCount: 24, + }, + }); + + const metadata = extractTransformerMetadata(state, { + cachedContent: 'cached/item-1', + safetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }], + toolConfig: { functionCallingConfig: { mode: 'ANY' } }, + generationConfig: { + imageConfig: { aspectRatio: '16:9' }, + }, + }); + + expect(metadata).toEqual({ + citations: [{ citations: [{ uri: 'https://example.com/cats' }] }], + groundingMetadata: [{ webSearchQueries: ['cats'] }], + thoughtSignature: 'sig-final', + thoughtSignatures: ['sig-final'], + usageMetadata: { + promptTokenCount: 15, + candidatesTokenCount: 5, + totalTokenCount: 24, + }, + geminiSafetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }], + geminiImageConfig: { aspectRatio: '16:9' }, + passthrough: { + cachedContent: 'cached/item-1', + toolConfig: { functionCallingConfig: { mode: 'ANY' } }, + }, + }); + }); + it('merges split candidate metadata and emits top-level metadata in candidate order', () => { const state = createGeminiGenerateContentAggregateState(); applyGeminiGenerateContentAggregate(state, [ diff --git a/src/server/transformers/gemini/generate-content/outbound.ts b/src/server/transformers/gemini/generate-content/outbound.ts index 6d40db0..2a9da9f 100644 --- a/src/server/transformers/gemini/generate-content/outbound.ts +++ b/src/server/transformers/gemini/generate-content/outbound.ts @@ -3,6 +3,10 @@ import { createGeminiGenerateContentAggregateState, type GeminiGenerateContentAggregateState, } from './aggregator.js'; +import { + toTransformerMetadataRecord, + type TransformerMetadata, +} from '../../shared/normalized.js'; function normalizeBaseUrl(baseUrl: string): string { return (baseUrl || '').replace(/\/+$/, ''); @@ -176,14 +180,28 @@ function extractOrderedThoughtSignatures( }; } -function extractRequestSemantics(requestPayload: unknown): GeminiRecord { +function ensurePassthrough(metadata: TransformerMetadata): Record { + if (!metadata.passthrough) metadata.passthrough = {}; + return metadata.passthrough; +} + +function extractRequestSemantics(requestPayload: unknown): TransformerMetadata { if (!isRecord(requestPayload)) return {}; - const metadata: GeminiRecord = {}; - if (requestPayload.systemInstruction !== undefined) metadata.systemInstruction = cloneJsonValue(requestPayload.systemInstruction); - if (requestPayload.cachedContent !== undefined) metadata.cachedContent = cloneJsonValue(requestPayload.cachedContent); - if (requestPayload.safetySettings !== undefined) metadata.safetySettings = cloneJsonValue(requestPayload.safetySettings); - if (requestPayload.toolConfig !== undefined) metadata.toolConfig = cloneJsonValue(requestPayload.toolConfig); + const metadata: TransformerMetadata = {}; + const passthrough = ensurePassthrough(metadata); + if (requestPayload.systemInstruction !== undefined) { + passthrough.systemInstruction = cloneJsonValue(requestPayload.systemInstruction); + } + if (requestPayload.cachedContent !== undefined) { + passthrough.cachedContent = cloneJsonValue(requestPayload.cachedContent); + } + if (requestPayload.safetySettings !== undefined) { + metadata.geminiSafetySettings = cloneJsonValue(requestPayload.safetySettings); + } + if (requestPayload.toolConfig !== undefined) { + passthrough.toolConfig = cloneJsonValue(requestPayload.toolConfig); + } const generationConfig = isRecord(requestPayload.generationConfig) ? requestPayload.generationConfig : null; if (generationConfig) { @@ -207,7 +225,11 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord { ]; for (const key of preservedKeys) { if (generationConfig[key] !== undefined) { - metadata[key] = cloneJsonValue(generationConfig[key]); + if (key === 'imageConfig') { + metadata.geminiImageConfig = cloneJsonValue(generationConfig[key]); + } else { + passthrough[key] = cloneJsonValue(generationConfig[key]); + } } } } @@ -226,10 +248,14 @@ function extractRequestSemantics(requestPayload: unknown): GeminiRecord { .filter((item) => Object.keys(item).length > 0); if (requestTools.length > 0) { - metadata.tools = requestTools; + passthrough.tools = requestTools; } } + if (metadata.passthrough && Object.keys(metadata.passthrough).length <= 0) { + delete metadata.passthrough; + } + return metadata; } @@ -251,9 +277,9 @@ export function serializeGeminiAggregateResponse( return response; } -export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord { +export function extractTransformerMetadata(payload: unknown, requestPayload?: unknown): TransformerMetadata { const state = ensureAggregateState(payload); - const metadata: GeminiRecord = extractRequestSemantics(requestPayload); + const metadata = extractRequestSemantics(requestPayload); const citations = extractOrderedCandidateMetadata(state, 'citationMetadata'); const groundingMetadata = extractOrderedCandidateMetadata(state, 'groundingMetadata'); @@ -273,10 +299,15 @@ export function extractResponseMetadata(payload: unknown, requestPayload?: unkno return metadata; } +export function extractResponseMetadata(payload: unknown, requestPayload?: unknown): GeminiRecord { + return toTransformerMetadataRecord(extractTransformerMetadata(payload, requestPayload)) ?? {}; +} + export const geminiGenerateContentOutbound = { resolveBaseUrl, resolveModelsUrl, resolveActionUrl, + extractTransformerMetadata, extractResponseMetadata, serializeAggregateResponse: serializeGeminiAggregateResponse, }; diff --git a/src/server/transformers/openai/chat/helpers.ts b/src/server/transformers/openai/chat/helpers.ts index 10ba632..cf70d27 100644 --- a/src/server/transformers/openai/chat/helpers.ts +++ b/src/server/transformers/openai/chat/helpers.ts @@ -7,6 +7,7 @@ import type { OpenAiChatToolCall, OpenAiChatUsageDetails, } from './model.js'; +import { fromTransformerMetadataRecord } from '../../shared/normalized.js'; function isRecord(value: unknown): value is Record { 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); diff --git a/src/server/transformers/openai/chat/index.test.ts b/src/server/transformers/openai/chat/index.test.ts index 642db76..2349701 100644 --- a/src/server/transformers/openai/chat/index.test.ts +++ b/src/server/transformers/openai/chat/index.test.ts @@ -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> { @@ -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', diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index ecfe0b7..ba1371b 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -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( { diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts index 268ea75..b61f986 100644 --- a/src/server/transformers/openai/responses/conversion.ts +++ b/src/server/transformers/openai/responses/conversion.ts @@ -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): boolean { + return Object.prototype.hasOwnProperty.call(body, 'include'); } -function ensureEncryptedReasoningInclude(body: Record): 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): 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, - options?: { verbositySource?: unknown }, + options?: { + verbositySource?: unknown; + defaultEncryptedReasoningInclude?: boolean; + }, ): Record { const normalized: Record = { ...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, modelName: string, stream: boolean, + options?: { defaultEncryptedReasoningInclude?: boolean }, ): Record { let normalized = normalizeResponsesBodyForCompatibility({ ...body, @@ -401,6 +394,7 @@ export function sanitizeResponsesBodyForProxy( normalized = normalizeResponsesRequestFieldParity(normalized, { verbositySource: body.verbosity, + defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude, }); const sanitized: Record = {}; @@ -717,8 +711,11 @@ export function convertResponsesBodyToOpenAiBody( body: Record, modelName: string, stream: boolean, + options?: { defaultEncryptedReasoningInclude?: boolean }, ): Record { - const normalizedBody = normalizeResponsesRequestFieldParity(body); + const normalizedBody = normalizeResponsesRequestFieldParity(body, { + defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude, + }); const messages: Array> = []; const input = normalizedBody.input; let functionCallIndex = 0; diff --git a/src/server/transformers/shared/normalized.test.ts b/src/server/transformers/shared/normalized.test.ts index faf8579..74069c2 100644 --- a/src/server/transformers/shared/normalized.test.ts +++ b/src/server/transformers/shared/normalized.test.ts @@ -2,11 +2,14 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { + fromTransformerMetadataRecord, normalizeStopReason, normalizeUpstreamFinalResponse, pullSseEventsWithDone, serializeFinalResponse, + toTransformerMetadataRecord, type NormalizedFinalResponse, + type TransformerMetadata, } from './normalized.js'; describe('shared normalized helpers', () => { @@ -143,6 +146,27 @@ describe('shared normalized helpers', () => { }); }); + it('round-trips shared transformer metadata through transport-safe records', () => { + const metadata: TransformerMetadata = { + promptCacheKey: 'cache-key', + truncation: 'auto', + serviceTier: 'priority', + citations: [{ uri: 'https://example.com/citation' }], + thoughtSignature: 'sig-final', + thoughtSignatures: ['sig-tool', 'sig-final'], + geminiSafetySettings: [{ category: 'SAFE', threshold: 'BLOCK_NONE' }], + geminiImageConfig: { aspectRatio: '16:9' }, + groundingMetadata: [{ webSearchQueries: ['cats'] }], + usageMetadata: { totalTokenCount: 42 }, + passthrough: { + cachedContent: 'cached/item-1', + toolConfig: { functionCallingConfig: { mode: 'ANY' } }, + }, + }; + + expect(fromTransformerMetadataRecord(toTransformerMetadataRecord(metadata))).toEqual(metadata); + }); + it('normalizes known stop reasons', () => { expect(normalizeStopReason('max_output_tokens')).toBe('length'); expect(normalizeStopReason('tool_use')).toBe('tool_calls'); diff --git a/src/server/transformers/shared/normalized.ts b/src/server/transformers/shared/normalized.ts index 7a731d5..c40caa7 100644 --- a/src/server/transformers/shared/normalized.ts +++ b/src/server/transformers/shared/normalized.ts @@ -78,6 +78,8 @@ export type TransformerMetadata = { includeObfuscation?: boolean | null; citations?: unknown; annotations?: unknown; + groundingMetadata?: unknown; + usageMetadata?: unknown; geminiSafetySettings?: unknown; geminiImageConfig?: unknown; thoughtSignature?: string | null; @@ -147,3 +149,133 @@ export function mergeNormalizedUsage( rejectedPredictionTokens: (merged.rejectedPredictionTokens || 0) + (incoming.rejectedPredictionTokens || 0), }; } + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function cloneJsonValue(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => cloneJsonValue(item)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]), + ) as T; + } + return value; +} + +const TRANSFORMER_METADATA_PASSTHROUGH_KEYS = new Set([ + 'systemInstruction', + 'cachedContent', + 'toolConfig', + 'tools', + 'stopSequences', + 'responseModalities', + 'responseMimeType', + 'responseSchema', + 'candidateCount', + 'maxOutputTokens', + 'temperature', + 'topP', + 'topK', + 'presencePenalty', + 'frequencyPenalty', + 'seed', + 'responseLogprobs', + 'logprobs', + 'thinkingConfig', +]); + +export function toTransformerMetadataRecord( + metadata: TransformerMetadata | undefined, +): Record | undefined { + if (!metadata) return undefined; + + const record: Record = {}; + if (metadata.include !== undefined) record.include = cloneJsonValue(metadata.include); + if (metadata.maxToolCalls !== undefined) record.maxToolCalls = metadata.maxToolCalls; + if (metadata.promptCacheKey !== undefined) record.promptCacheKey = cloneJsonValue(metadata.promptCacheKey); + if (metadata.promptCacheRetention !== undefined) { + record.promptCacheRetention = cloneJsonValue(metadata.promptCacheRetention); + } + if (metadata.truncation !== undefined) record.truncation = cloneJsonValue(metadata.truncation); + if (metadata.serviceTier !== undefined) record.serviceTier = cloneJsonValue(metadata.serviceTier); + if (metadata.includeObfuscation !== undefined) record.includeObfuscation = metadata.includeObfuscation; + if (metadata.citations !== undefined) record.citations = cloneJsonValue(metadata.citations); + if (metadata.annotations !== undefined) record.annotations = cloneJsonValue(metadata.annotations); + if (metadata.groundingMetadata !== undefined) { + record.groundingMetadata = cloneJsonValue(metadata.groundingMetadata); + } + if (metadata.usageMetadata !== undefined) { + record.usageMetadata = cloneJsonValue(metadata.usageMetadata); + } + if (metadata.geminiSafetySettings !== undefined) { + record.safetySettings = cloneJsonValue(metadata.geminiSafetySettings); + } + if (metadata.geminiImageConfig !== undefined) { + record.imageConfig = cloneJsonValue(metadata.geminiImageConfig); + } + if (metadata.thoughtSignature !== undefined) record.thoughtSignature = metadata.thoughtSignature; + if (metadata.thoughtSignatures !== undefined) { + record.thoughtSignatures = cloneJsonValue(metadata.thoughtSignatures); + } + if (isRecord(metadata.passthrough)) { + for (const [key, value] of Object.entries(metadata.passthrough)) { + if (record[key] === undefined) { + record[key] = cloneJsonValue(value); + } + } + } + + return Object.keys(record).length > 0 ? record : undefined; +} + +export function fromTransformerMetadataRecord( + value: unknown, +): TransformerMetadata | undefined { + if (!isRecord(value)) return undefined; + + const metadata: TransformerMetadata = {}; + if (value.include !== undefined) metadata.include = cloneJsonValue(value.include); + if (typeof value.maxToolCalls === 'number' && Number.isFinite(value.maxToolCalls)) { + metadata.maxToolCalls = value.maxToolCalls; + } + if (value.promptCacheKey !== undefined) metadata.promptCacheKey = cloneJsonValue(value.promptCacheKey); + if (value.promptCacheRetention !== undefined) { + metadata.promptCacheRetention = cloneJsonValue(value.promptCacheRetention); + } + if (value.truncation !== undefined) metadata.truncation = cloneJsonValue(value.truncation); + if (value.serviceTier !== undefined) metadata.serviceTier = cloneJsonValue(value.serviceTier); + if (typeof value.includeObfuscation === 'boolean') metadata.includeObfuscation = value.includeObfuscation; + if (value.citations !== undefined) metadata.citations = cloneJsonValue(value.citations); + if (value.annotations !== undefined) metadata.annotations = cloneJsonValue(value.annotations); + if (value.groundingMetadata !== undefined) { + metadata.groundingMetadata = cloneJsonValue(value.groundingMetadata); + } + if (value.usageMetadata !== undefined) { + metadata.usageMetadata = cloneJsonValue(value.usageMetadata); + } + if (value.safetySettings !== undefined) { + metadata.geminiSafetySettings = cloneJsonValue(value.safetySettings); + } + if (value.imageConfig !== undefined) { + metadata.geminiImageConfig = cloneJsonValue(value.imageConfig); + } + if (typeof value.thoughtSignature === 'string') metadata.thoughtSignature = value.thoughtSignature; + if (Array.isArray(value.thoughtSignatures)) { + metadata.thoughtSignatures = value.thoughtSignatures + .filter((item): item is string => typeof item === 'string'); + } + + const passthroughEntries = Object.entries(value) + .filter(([key]) => TRANSFORMER_METADATA_PASSTHROUGH_KEYS.has(key)); + if (passthroughEntries.length > 0) { + metadata.passthrough = Object.fromEntries( + passthroughEntries.map(([key, entry]) => [key, cloneJsonValue(entry)]), + ); + } + + return Object.keys(metadata).length > 0 ? metadata : undefined; +}