diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index a4393fe..01678e9 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -3190,6 +3190,87 @@ describe('chat proxy stream behavior', () => { expect(toolMessage.content).toContain('matches'); }); + it('maps claude tool config and thinking budget before routing claude downstream requests to openai chat endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 11, routeId: 22 }, + site: { name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 33, username: 'demo-user' }, + tokenName: 'default', + tokenValue: 'sk-openai', + actualModel: 'gpt-4o-mini', + }); + + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-config-mapped', + object: 'chat.completion', + created: 1_706_000_007, + model: 'gpt-4o-mini', + choices: [{ + index: 0, + message: { role: 'assistant', content: 'tool config mapped' }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 9, completion_tokens: 3, total_tokens: 12 }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'gpt-4o-mini', + max_tokens: 256, + metadata: { user_id: 'user-1' }, + thinking: { type: 'enabled', budget_tokens: 1024 }, + tools: [{ + name: 'Glob', + description: 'Search files', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }], + tool_choice: { + type: 'tool', + name: 'Glob', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + }); + + expect(response.statusCode).toBe(200); + + const [_targetUrl, options] = fetchMock.mock.calls[0] as [string, any]; + const forwardedBody = JSON.parse(options.body); + expect(forwardedBody.metadata).toEqual({ user_id: 'user-1' }); + expect(forwardedBody.reasoning_budget).toBe(1024); + expect(forwardedBody.tools).toEqual([{ + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }]); + expect(forwardedBody.tool_choice).toEqual({ + type: 'function', + function: { + name: 'Glob', + }, + }); + }); + it('forces claude platform to use /v1/messages with x-api-key auth for openai downstream requests', async () => { selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, diff --git a/src/server/routes/proxy/responses.websocket.test.ts b/src/server/routes/proxy/responses.websocket.test.ts index 878a663..376ea5c 100644 --- a/src/server/routes/proxy/responses.websocket.test.ts +++ b/src/server/routes/proxy/responses.websocket.test.ts @@ -194,6 +194,35 @@ function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 100 }); } +function waitForSocketMessageMatching( + socket: WebSocket, + predicate: (message: any) => boolean, + timeoutMs = 1000, +) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.off('message', onMessage); + socket.off('error', onError); + reject(new Error('Timed out waiting for matching websocket message')); + }, timeoutMs); + const onMessage = (payload: WebSocket.RawData) => { + const message = JSON.parse(String(payload)); + if (!predicate(message)) return; + clearTimeout(timeout); + socket.off('message', onMessage); + socket.off('error', onError); + resolve(message); + }; + const onError = (error: Error) => { + clearTimeout(timeout); + socket.off('message', onMessage); + reject(error); + }; + socket.on('message', onMessage); + socket.once('error', onError); + }); +} + function createDeferred() { let resolve!: (value: T | PromiseLike) => void; let reject!: (reason?: unknown) => void; @@ -559,6 +588,78 @@ describe('responses websocket transport', () => { expect(messages[0]?.response?.id).toBe('resp_http_fallback_401'); }); + it('preserves previous_response_id when websocket upgrade fallback uses HTTP on incremental-capable upstreams', async () => { + rejectedUpgradeStatus = 426; + rejectedUpgradeStatusText = 'Upgrade Required'; + rejectedUpgradeBody = JSON.stringify({ + error: { + message: 'upgrade required', + type: 'invalid_request_error', + }, + }); + + const selectedChannel = createSelectedChannel({ + siteUrl: rejectedUpgradeSiteUrl, + }); + selectChannelMock.mockReturnValue(selectedChannel); + previewSelectedChannelMock.mockResolvedValue(selectedChannel); + fetchMock + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_fallback_1","model":"gpt-5.4","status":"completed","output":[{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"first"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n', + 'data: [DONE]\n\n', + ])) + .mockResolvedValueOnce(createSseResponse([ + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_http_fallback_2","model":"gpt-5.4","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"second"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const socket = createClientSocket(baseUrl); + await waitForSocketOpen(socket); + const firstResponsePromise = waitForSocketMessages(socket, 1); + + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + instructions: 'be helpful', + input: [], + })); + + const firstMessages = await firstResponsePromise; + const secondResponsePromise = waitForSocketMessages(socket, 1); + socket.send(JSON.stringify({ + type: 'response.create', + model: 'gpt-5.4', + previous_response_id: 'resp_http_fallback_1', + input: [ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ], + })); + + await secondResponsePromise; + socket.close(); + + expect(firstMessages[0]?.type).toBe('response.completed'); + expect(fetchMock).toHaveBeenCalledTimes(2); + const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; + const secondBody = JSON.parse(String(secondOptions.body)); + expect(secondBody.previous_response_id).toBe('resp_http_fallback_1'); + expect(secondBody.input).toEqual([ + { + id: 'tool_out_1', + type: 'function_call_output', + call_id: 'call_1', + output: 'tool result', + }, + ]); + }); + it('preserves query parameter auth when websocket transport falls back to the HTTP responses route', async () => { rejectedUpgradeStatus = 401; rejectedUpgradeStatusText = 'Unauthorized'; @@ -793,7 +894,10 @@ describe('responses websocket transport', () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(fetchMock).toHaveBeenCalledTimes(1); - const messagesPromise = waitForSocketMessages(socket, 4); + const secondTurnPromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_ws_2', + ); firstResponseGate.resolve(createSseResponse([ 'event: response.output_item.done\n', 'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n', @@ -804,16 +908,20 @@ describe('responses websocket transport', () => { 'data: [DONE]\n\n', ])); - const messages = await messagesPromise; + while (fetchMock.mock.calls.length < 2) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + const secondTurnMessage = await secondTurnPromise; socket.close(); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(messages.map((message) => message.type)).toEqual([ - 'response.output_item.done', - 'response.output_item.done', - 'response.completed', - 'response.completed', - ]); + expect(secondTurnMessage).toMatchObject({ + type: 'response.completed', + response: { + id: 'resp_ws_2', + }, + }); const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit]; const secondBody = JSON.parse(String(secondOptions.body)); expect(secondBody.input).toEqual([ @@ -916,7 +1024,10 @@ describe('responses websocket transport', () => { })); await firstResponsePromise; - const secondResponsePromise = waitForSocketMessages(socket, 1); + const secondResponsePromise = waitForSocketMessageMatching( + socket, + (message) => message?.type === 'response.completed' && message?.response?.id === 'resp_ws_2', + ); socket.send(JSON.stringify({ type: 'response.create', previous_response_id: 'resp_ws_1', @@ -929,10 +1040,15 @@ describe('responses websocket transport', () => { }, ], })); - await secondResponsePromise; + const secondResponse = await secondResponsePromise; socket.close(); - expect(fetchMock).toHaveBeenCalledTimes(0); + expect(secondResponse).toMatchObject({ + type: 'response.completed', + response: { + id: 'resp_ws_2', + }, + }); expect(upstreamConnectionCount).toBe(1); expect(upstreamRequests).toHaveLength(2); expect(upstreamRequests[0]).toMatchObject({ diff --git a/src/server/routes/proxy/responsesWebsocket.ts b/src/server/routes/proxy/responsesWebsocket.ts index 28dae3a..f068c86 100644 --- a/src/server/routes/proxy/responsesWebsocket.ts +++ b/src/server/routes/proxy/responsesWebsocket.ts @@ -602,7 +602,7 @@ async function handleResponsesWebsocketConnection( socket, request, payload: normalized.request, - preserveIncrementalMode: false, + preserveIncrementalMode: supportsIncrementalInput, authToken: authContext.token, }); if (fallbackOutput) { diff --git a/src/server/transformers/anthropic/messages/outbound.test.ts b/src/server/transformers/anthropic/messages/outbound.test.ts index 436dcd4..6ba6672 100644 --- a/src/server/transformers/anthropic/messages/outbound.test.ts +++ b/src/server/transformers/anthropic/messages/outbound.test.ts @@ -149,4 +149,94 @@ describe('anthropicMessagesOutbound.serializeFinal', () => { }, }); }); + + it('cleans tagged signatures and preserves redacted_thinking for generic normalized finals', () => { + const payload = anthropicMessagesOutbound.serializeFinal({ + id: 'chatcmpl-redacted-1', + model: 'claude-test', + created: 456, + content: 'visible text', + reasoningContent: 'plan quietly', + reasoningSignature: 'metapi:anthropic-signature:sig-clean', + redactedReasoningContent: 'ciphertext', + finishReason: 'stop', + toolCalls: [], + } as any, { + promptTokens: 9, + completionTokens: 3, + totalTokens: 12, + }); + + expect(payload.content).toEqual([ + { + type: 'thinking', + thinking: 'plan quietly', + signature: 'sig-clean', + }, + { + type: 'redacted_thinking', + data: 'ciphertext', + }, + { + type: 'text', + text: 'visible text', + }, + ]); + }); + + it('cleans tagged native thinking signatures while preserving native anthropic block order', () => { + const sourcePayload = { + id: 'msg_native-tagged-1', + type: 'message', + role: 'assistant', + model: 'claude-test', + content: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'metapi:anthropic-signature:sig-native', + }, + { + type: 'tool_use', + id: 'toolu_lookup', + name: 'lookup_weather', + input: { city: 'Paris' }, + }, + { + type: 'text', + text: 'done', + }, + ], + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 30, + }, + }; + + const normalized = anthropicMessagesOutbound.normalizeFinal(sourcePayload, 'claude-test'); + const payload = anthropicMessagesOutbound.serializeFinal( + normalized, + anthropicMessagesUsage.fromPayload(sourcePayload), + ); + + expect(payload.content).toEqual([ + { + type: 'thinking', + thinking: 'plan first', + signature: 'sig-native', + }, + { + type: 'tool_use', + id: 'toolu_lookup', + name: 'lookup_weather', + input: { city: 'Paris' }, + }, + { + type: 'text', + text: 'done', + }, + ]); + }); }); diff --git a/src/server/transformers/anthropic/messages/outbound.ts b/src/server/transformers/anthropic/messages/outbound.ts index df71412..d052410 100644 --- a/src/server/transformers/anthropic/messages/outbound.ts +++ b/src/server/transformers/anthropic/messages/outbound.ts @@ -1,4 +1,5 @@ import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js'; +import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; import { toAnthropicUsagePayload } from './usage.js'; type AnthropicRecord = Record; @@ -25,6 +26,10 @@ function cloneJsonValue(value: T): T { return value; } +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + function parseJsonLike(raw: string): unknown { const trimmed = raw.trim(); if (!trimmed) return {}; @@ -41,17 +46,46 @@ function buildClaudeMessageId(sourceId: string): string { return `msg_${sanitized || Date.now()}`; } +function cleanAnthropicReasoningSignature(value: unknown): string | null { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) return null; + const decoded = decodeAnthropicReasoningSignature(raw); + if (decoded) return decoded; + if (raw.startsWith('metapi:')) return null; + return raw; +} + function buildAnthropicContent(normalized: NormalizedFinalResponse): Array> { const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse; if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { - return anthropicNormalized.nativeContent.map((block) => cloneJsonValue(block)); + return anthropicNormalized.nativeContent.map((block) => { + const cloned = cloneJsonValue(block); + const blockType = asTrimmedString(cloned.type).toLowerCase(); + if (blockType === 'thinking') { + const signature = cleanAnthropicReasoningSignature(cloned.signature); + if (signature) cloned.signature = signature; + else delete cloned.signature; + } + return cloned; + }); } const contentBlocks: Array> = []; - if (normalized.reasoningContent) { - contentBlocks.push({ + const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature); + if (normalized.reasoningContent || cleanSignature) { + const thinkingBlock: Record = { type: 'thinking', - thinking: normalized.reasoningContent, + thinking: normalized.reasoningContent || '', + }; + if (cleanSignature) { + thinkingBlock.signature = cleanSignature; + } + contentBlocks.push(thinkingBlock); + } + if (normalized.redactedReasoningContent) { + contentBlocks.push({ + type: 'redacted_thinking', + data: normalized.redactedReasoningContent, }); } if (normalized.content) { diff --git a/src/server/transformers/anthropic/messages/stream.test.ts b/src/server/transformers/anthropic/messages/stream.test.ts index 80ffe04..615bc69 100644 --- a/src/server/transformers/anthropic/messages/stream.test.ts +++ b/src/server/transformers/anthropic/messages/stream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { anthropicMessagesTransformer } from './index.js'; +import { anthropicMessagesTransformer, serializeAnthropicFinalAsStream } from './index.js'; type SerializedAnthropicEvent = { event: string; @@ -304,3 +304,142 @@ describe('anthropicMessagesStream.serializeEvent', () => { expect(stopEvents[0]?.payload.index).toBe(1); }); }); + +describe('serializeAnthropicFinalAsStream', () => { + it('emits thinking signature and tool_use blocks for normalized final payloads', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const serialized = serializeAnthropicFinalAsStream({ + id: 'resp_final_stream', + model: 'claude-opus-4-6', + created: 1700000000, + content: '', + reasoningContent: 'plan quietly', + reasoningSignature: 'sig-final', + finishReason: 'tool_calls', + toolCalls: [{ + id: 'call_1', + name: 'lookup_city', + arguments: '{"city":"Paris"}', + }], + }, streamContext, downstreamContext).join(''); + + const events = anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({ + event: item.event, + payload: JSON.parse(item.data), + })); + + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'signature_delta' + && item.payload.delta?.signature === 'sig-final', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_start' + && item.payload.content_block?.type === 'tool_use' + && item.payload.content_block?.id === 'call_1' + && item.payload.content_block?.name === 'lookup_city', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'input_json_delta' + && item.payload.delta?.partial_json === '{"city":"Paris"}', + )).toBe(true); + }); + + it('preserves native anthropic block order and cleans tagged signatures when serializing fallback streams', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const serialized = serializeAnthropicFinalAsStream({ + id: 'resp_native_order_1', + model: 'claude-opus-4-6', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'tool_calls', + toolCalls: [], + nativeContent: [ + { + type: 'thinking', + thinking: 'plan first', + signature: 'metapi:anthropic-signature:sig-native', + }, + { + type: 'tool_use', + id: 'toolu_lookup', + name: 'lookup_city', + input: { city: 'Paris' }, + }, + { + type: 'text', + text: 'done', + }, + ], + } as any, streamContext, downstreamContext).join(''); + + const events = anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({ + event: item.event, + payload: JSON.parse(item.data), + })); + + expect(events + .filter((item) => item.payload.type === 'content_block_start') + .map((item) => item.payload.content_block?.type)).toEqual([ + 'thinking', + 'tool_use', + 'text', + ]); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'signature_delta' + && item.payload.delta?.signature === 'sig-native', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'input_json_delta' + && item.payload.delta?.partial_json === '{"city":"Paris"}', + )).toBe(true); + }); + + it('preserves redacted_thinking in generic normalized final fallback streams', () => { + const streamContext = anthropicMessagesTransformer.createStreamContext('claude-opus-4-6'); + const downstreamContext = anthropicMessagesTransformer.createDownstreamContext(); + + const serialized = serializeAnthropicFinalAsStream({ + id: 'resp_redacted_1', + model: 'claude-opus-4-6', + created: 1700000000, + content: 'visible text', + reasoningContent: 'plan quietly', + reasoningSignature: 'metapi:anthropic-signature:sig-redacted', + redactedReasoningContent: 'ciphertext', + finishReason: 'stop', + toolCalls: [], + } as any, streamContext, downstreamContext).join(''); + + const events = anthropicMessagesTransformer.pullSseEvents(serialized).events.map((item) => ({ + event: item.event, + payload: JSON.parse(item.data), + })); + + expect(events + .filter((item) => item.payload.type === 'content_block_start') + .map((item) => item.payload.content_block?.type)).toEqual([ + 'thinking', + 'redacted_thinking', + 'text', + ]); + expect(events.some((item) => + item.payload.type === 'content_block_start' + && item.payload.content_block?.type === 'redacted_thinking' + && item.payload.content_block?.data === 'ciphertext', + )).toBe(true); + expect(events.some((item) => + item.payload.type === 'content_block_delta' + && item.payload.delta?.type === 'signature_delta' + && item.payload.delta?.signature === 'sig-redacted', + )).toBe(true); + }); +}); diff --git a/src/server/transformers/anthropic/messages/stream.ts b/src/server/transformers/anthropic/messages/stream.ts index 700e9e3..579b88d 100644 --- a/src/server/transformers/anthropic/messages/stream.ts +++ b/src/server/transformers/anthropic/messages/stream.ts @@ -9,9 +9,13 @@ import { type NormalizedStreamEvent, type StreamTransformContext, } from '../../shared/normalized.js'; +import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js'; import { type AnthropicExtendedStreamEvent } from './aggregator.js'; type AnthropicStreamPayload = Record; +type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & { + nativeContent?: AnthropicStreamPayload[]; +}; type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking'; @@ -57,6 +61,106 @@ function serializeSse(event: string, payload: Record): string { return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`; } +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; +} + +function cleanAnthropicReasoningSignature(value: unknown): string | null { + const raw = typeof value === 'string' ? value.trim() : ''; + if (!raw) return null; + const decoded = decodeAnthropicReasoningSignature(raw); + if (decoded) return decoded; + if (raw.startsWith('metapi:')) return null; + return raw; +} + +function buildAnthropicFinalContentBlocks( + normalizedFinal: NormalizedFinalResponse, +): AnthropicStreamPayload[] { + const anthropicNormalized = normalizedFinal as AnthropicMessagesNormalizedFinalResponse; + if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) { + return anthropicNormalized.nativeContent + .filter((block): block is AnthropicStreamPayload => isRecord(block)) + .map((block) => { + const cloned = cloneJsonValue(block); + const blockType = asTrimmedString(cloned.type).toLowerCase(); + if (blockType === 'thinking') { + const signature = cleanAnthropicReasoningSignature(cloned.signature); + if (signature) cloned.signature = signature; + else delete cloned.signature; + } + return cloned; + }); + } + + const contentBlocks: AnthropicStreamPayload[] = []; + const cleanSignature = cleanAnthropicReasoningSignature(normalizedFinal.reasoningSignature); + if (normalizedFinal.reasoningContent || cleanSignature) { + const thinkingBlock: AnthropicStreamPayload = { + type: 'thinking', + thinking: normalizedFinal.reasoningContent || '', + }; + if (cleanSignature) thinkingBlock.signature = cleanSignature; + contentBlocks.push(thinkingBlock); + } + if (normalizedFinal.redactedReasoningContent) { + contentBlocks.push({ + type: 'redacted_thinking', + data: normalizedFinal.redactedReasoningContent, + }); + } + if (normalizedFinal.content) { + contentBlocks.push({ + type: 'text', + text: normalizedFinal.content, + }); + } + if (Array.isArray(normalizedFinal.toolCalls)) { + for (let index = 0; index < normalizedFinal.toolCalls.length; index += 1) { + const toolCall = normalizedFinal.toolCalls[index]; + contentBlocks.push({ + type: 'tool_use', + id: toolCall.id || `toolu_${index}`, + name: toolCall.name || `tool_${index}`, + input: (() => { + const rawArguments = toolCall.arguments || ''; + try { + return rawArguments ? JSON.parse(rawArguments) : {}; + } catch { + return { value: rawArguments }; + } + })(), + }); + } + } + + if (contentBlocks.length <= 0) { + contentBlocks.push({ + type: 'text', + text: '', + }); + } + return contentBlocks; +} + +function serializeToolInputDelta(input: unknown): string | null { + if (input === undefined) return null; + if (typeof input === 'string') return input; + try { + return JSON.stringify(input); + } catch { + return JSON.stringify({}); + } +} + export function isAnthropicRawSseEventName(value: unknown): value is string { return typeof value === 'string' && ANTHROPIC_RAW_SSE_EVENT_NAMES.has(value); } @@ -493,25 +597,126 @@ export function serializeAnthropicFinalAsStream( const lines = [ ...anthropicMessagesStream.serializeEvent({ role: 'assistant' }, streamContext, downstreamContext), ]; + const serializeAnthropicEvent = (event: AnthropicExtendedStreamEvent) => anthropicMessagesStream.serializeEvent( + event, + streamContext, + downstreamContext, + ); + const contentBlocks = buildAnthropicFinalContentBlocks(normalizedFinal); - if (normalizedFinal.reasoningContent) { - lines.push( - ...anthropicMessagesStream.serializeEvent( - { reasoningDelta: normalizedFinal.reasoningContent }, + for (let index = 0; index < contentBlocks.length; index += 1) { + const block = contentBlocks[index]; + const blockType = asTrimmedString(block.type).toLowerCase(); + + if (blockType === 'thinking') { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'thinking', + index, + }, + }, + }, + )); + const thinkingText = asTrimmedString(block.thinking); + if (thinkingText) { + lines.push(...anthropicMessagesStream.serializeEvent( + { reasoningDelta: thinkingText }, + streamContext, + downstreamContext, + )); + } + const cleanSignature = cleanAnthropicReasoningSignature(block.signature); + if (cleanSignature) { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + signatureDelta: cleanSignature, + }, + }, + )); + } + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } + + if (blockType === 'redacted_thinking') { + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'redacted_thinking', + index, + }, + redactedThinkingData: asTrimmedString(block.data), + }, + }, + )); + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } + + if (blockType === 'tool_use') { + const argumentsDelta = serializeToolInputDelta(block.input); + lines.push(...anthropicMessagesStream.serializeEvent( + { + toolCallDeltas: [{ + index, + id: asTrimmedString(block.id) || undefined, + name: asTrimmedString(block.name) || undefined, + ...(argumentsDelta !== null ? { argumentsDelta } : {}), + }], + }, streamContext, downstreamContext, - ), - ); - } + )); + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); + continue; + } - if (normalizedFinal.content) { - lines.push( - ...anthropicMessagesStream.serializeEvent( - { contentDelta: normalizedFinal.content }, + lines.push(...serializeAnthropicEvent( + { + anthropic: { + startBlock: { + kind: 'text', + index, + }, + }, + }, + )); + if (typeof block.text === 'string') { + lines.push(...anthropicMessagesStream.serializeEvent( + { contentDelta: block.text }, streamContext, downstreamContext, - ), - ); + )); + } + lines.push(...serializeAnthropicEvent( + { + anthropic: { + stopBlockIndex: index, + }, + }, + )); } lines.push( @@ -766,8 +971,11 @@ export const anthropicMessagesStream = { events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, context)); } - if (anthropicEvent.anthropic?.signatureDelta) { - bufferPendingSignature(context, anthropicEvent.anthropic.signatureDelta); + const signatureDelta = anthropicEvent.anthropic?.signatureDelta + ?? (typeof event.reasoningSignature === 'string' ? event.reasoningSignature : undefined); + const cleanSignatureDelta = cleanAnthropicReasoningSignature(signatureDelta); + if (cleanSignatureDelta) { + bufferPendingSignature(context, cleanSignatureDelta); } if (anthropicEvent.anthropic?.redactedThinkingData) { diff --git a/src/server/transformers/canonical/request.test.ts b/src/server/transformers/canonical/request.test.ts index bca8bd2..f265cbf 100644 --- a/src/server/transformers/canonical/request.test.ts +++ b/src/server/transformers/canonical/request.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { createCanonicalRequestEnvelope } from './request.js'; +import { + canonicalRequestFromOpenAiBody, + canonicalRequestToOpenAiChatBody, + createCanonicalRequestEnvelope, +} from './request.js'; describe('canonical request helpers', () => { it('normalizes a count_tokens request without provider-owned fields', () => { @@ -45,4 +49,169 @@ describe('canonical request helpers', () => { messages: [], }); }); + + it('parses metadata and explicit function tool choice from OpenAI-compatible bodies', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + tools: [{ + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + strict: true, + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }, + }], + tool_choice: { + type: 'function', + function: { + name: 'Glob', + }, + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-chat', + }); + + expect(request).toMatchObject({ + requestedModel: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + tools: [{ + name: 'Glob', + description: 'Search files', + strict: true, + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }], + toolChoice: { + type: 'tool', + name: 'Glob', + }, + }); + }); + + it('parses anthropic-shaped tools from compatibility bodies', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + tools: [{ + name: 'Glob', + description: 'Search files', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }], + tool_choice: { + type: 'tool', + name: 'Glob', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-chat', + }); + + expect(request.tools).toEqual([{ + name: 'Glob', + description: 'Search files', + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }]); + expect(request.toolChoice).toEqual({ + type: 'tool', + name: 'Glob', + }); + }); + + it('builds metadata back into OpenAI chat requests', () => { + const body = canonicalRequestToOpenAiChatBody({ + operation: 'generate', + surface: 'openai-chat', + cliProfile: 'generic', + requestedModel: 'gpt-5', + stream: false, + messages: [{ role: 'user', parts: [{ type: 'text', text: 'hello' }] }], + metadata: { user_id: 'user-1' }, + toolChoice: { + type: 'tool', + name: 'Glob', + }, + tools: [{ + name: 'Glob', + strict: true, + inputSchema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }], + }); + + expect(body).toMatchObject({ + model: 'gpt-5', + metadata: { user_id: 'user-1' }, + tool_choice: { + type: 'function', + function: { + name: 'Glob', + }, + }, + tools: [{ + type: 'function', + function: { + name: 'Glob', + strict: true, + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + }, + }, + }], + }); + }); + + it('round-trips include continuity metadata back into OpenAI-compatible bodies', () => { + const request = canonicalRequestFromOpenAiBody({ + body: { + model: 'gpt-5', + stream: true, + include: ['reasoning.encrypted_content', 'message.input_image.image_url'], + reasoning: { + effort: 'high', + }, + messages: [{ role: 'user', content: 'hello' }], + }, + surface: 'openai-responses', + }); + + const body = canonicalRequestToOpenAiChatBody(request); + + expect(body).toMatchObject({ + model: 'gpt-5', + reasoning_effort: 'high', + include: ['reasoning.encrypted_content', 'message.input_image.image_url'], + }); + }); }); diff --git a/src/server/transformers/canonical/request.ts b/src/server/transformers/canonical/request.ts index 74d58a1..92aec80 100644 --- a/src/server/transformers/canonical/request.ts +++ b/src/server/transformers/canonical/request.ts @@ -197,6 +197,13 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine const type = asTrimmedString(rawToolChoice.type).toLowerCase(); if (type === 'auto' || type === 'none') return type; if (type === 'any' || type === 'required') return 'required'; + if (type === 'function') { + const name = asTrimmedString( + (isRecord(rawToolChoice.function) ? rawToolChoice.function.name : undefined) + ?? rawToolChoice.name, + ); + return name ? { type: 'tool', name } : undefined; + } if (type !== 'tool') return undefined; const name = asTrimmedString( @@ -212,8 +219,9 @@ function parseTools(rawTools: unknown): CanonicalTool[] | undefined { const tools = rawTools .flatMap((item) => { if (!isRecord(item)) return []; + const itemType = asTrimmedString(item.type).toLowerCase(); - if (asTrimmedString(item.type).toLowerCase() === 'function' && isRecord(item.function)) { + if (itemType === 'function' && isRecord(item.function)) { const name = asTrimmedString(item.function.name); if (!name) return []; return [{ @@ -221,10 +229,24 @@ function parseTools(rawTools: unknown): CanonicalTool[] | undefined { ...(asTrimmedString(item.function.description) ? { description: asTrimmedString(item.function.description) } : {}), + ...(typeof item.function.strict === 'boolean' ? { strict: item.function.strict } : {}), ...(isRecord(item.function.parameters) ? { inputSchema: cloneJsonValue(item.function.parameters) } : {}), }]; } + if ((itemType === '' || itemType === 'tool') && asTrimmedString(item.name)) { + return [{ + name: asTrimmedString(item.name), + ...(asTrimmedString(item.description) + ? { description: asTrimmedString(item.description) } + : {}), + ...(typeof item.strict === 'boolean' ? { strict: item.strict } : {}), + ...(isRecord(item.input_schema) + ? { inputSchema: cloneJsonValue(item.input_schema) } + : (isRecord(item.inputSchema) ? { inputSchema: cloneJsonValue(item.inputSchema) } : {})), + }]; + } + if (Array.isArray(item.functionDeclarations)) { return item.functionDeclarations.flatMap((declaration) => { if (!isRecord(declaration)) return []; @@ -252,6 +274,9 @@ export function canonicalRequestFromOpenAiBody( input: CanonicalRequestFromOpenAiBodyInput, ): CanonicalRequestEnvelope { const body = input.body; + const metadata = isRecord(input.metadata) + ? input.metadata + : (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined); const messages: CanonicalMessage[] = []; const rawMessages = Array.isArray(body.messages) ? body.messages : []; @@ -329,7 +354,7 @@ export function canonicalRequestFromOpenAiBody( ...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}), ...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}), ...(Object.keys(continuation).length > 0 ? { continuation } : {}), - ...(input.metadata ? { metadata: input.metadata } : {}), + ...(metadata ? { metadata } : {}), ...((reasoningResult.metadata || input.passthrough) ? { passthrough: { @@ -478,6 +503,20 @@ export function canonicalRequestToOpenAiChatBody( if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort; if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens; if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary; + const transformerMetadata = isRecord(request.passthrough?.transformerMetadata) + ? request.passthrough.transformerMetadata as Record + : null; + const passthroughInclude = Array.isArray(transformerMetadata?.include) + ? (transformerMetadata.include as unknown[]) + .filter((item): item is string => typeof item === 'string' && item.trim().length > 0) + .map((item) => item.trim()) + : []; + const mergedInclude = [ + ...(request.reasoning?.includeEncryptedContent ? ['reasoning.encrypted_content'] : []), + ...passthroughInclude, + ].filter((item, index, all) => all.indexOf(item) === index); + if (mergedInclude.length > 0) body.include = mergedInclude; + if (request.metadata !== undefined) body.metadata = cloneJsonValue(request.metadata); if (request.continuation?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey; if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId; if (Array.isArray(request.tools) && request.tools.length > 0) { @@ -486,6 +525,7 @@ export function canonicalRequestToOpenAiChatBody( function: { name: tool.name, ...(tool.description ? { description: tool.description } : {}), + ...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}), parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }), }, })); diff --git a/src/server/transformers/canonical/tools.ts b/src/server/transformers/canonical/tools.ts index 55b0be4..9600d18 100644 --- a/src/server/transformers/canonical/tools.ts +++ b/src/server/transformers/canonical/tools.ts @@ -1,6 +1,7 @@ export type CanonicalTool = { name: string; description?: string; + strict?: boolean; inputSchema?: Record | null; }; diff --git a/src/server/transformers/openai/responses/aggregator.test.ts b/src/server/transformers/openai/responses/aggregator.test.ts index 36266ed..17d0765 100644 --- a/src/server/transformers/openai/responses/aggregator.test.ts +++ b/src/server/transformers/openai/responses/aggregator.test.ts @@ -389,6 +389,54 @@ describe('serializeConvertedResponsesEvents', () => { }); }); + it('emits terminal done events before relaying an upstream response.completed event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hello world', + } as any, + }); + + const completionLines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_done_1', + model: 'gpt-5', + usage: { + input_tokens: 2, + output_tokens: 4, + total_tokens: 6, + }, + }, + }, + }, + }); + + const events = parseSseEvents(completionLines); + expect(events.map((entry) => entry.event ?? 'data')).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + ]); + }); + it('emits canonical reasoning done events before response.completed when recovering sparse reasoning output', () => { const state = createOpenAiResponsesAggregateState('gpt-5'); const streamContext = createStreamTransformContext('gpt-5'); @@ -763,4 +811,433 @@ describe('serializeConvertedResponsesEvents', () => { }, }); }); + + it('emits terminal done events before forwarding an upstream response.completed event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'hello world', + } as any, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_1', + model: 'gpt-5', + usage: { + input_tokens: 2, + output_tokens: 4, + total_tokens: 6, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + ]); + }); + + it('closes reasoning summary parts before forwarding an upstream response.completed event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + reasoningDelta: 'Think ', + } as any, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.completed', + responsesPayload: { + type: 'response.completed', + response: { + id: 'resp_reasoning_done', + model: 'gpt-5', + usage: { + input_tokens: 1, + output_tokens: 2, + total_tokens: 3, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.completed', + ]); + }); + + it('preserves response.incomplete as a first-class terminal event', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 2, + completionTokens: 4, + totalTokens: 6, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + contentDelta: 'partial answer', + } as any, + }); + + const lines = serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.incomplete', + responsesPayload: { + type: 'response.incomplete', + response: { + id: 'resp_incomplete_1', + model: 'gpt-5', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + usage: { + input_tokens: 2, + output_tokens: 4, + total_tokens: 6, + }, + }, + }, + }, + }); + + const events = parseSseEvents(lines); + expect(events.map((entry) => entry.event)).toEqual([ + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.incomplete', + ]); + expect(events[3]?.payload).toMatchObject({ + type: 'response.incomplete', + response: { + id: 'resp_incomplete_1', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + output_text: 'partial answer', + }, + }); + }); + + it('closes every unterminated message part and only emits output_text.done for text parts', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'msg_multi_1', + type: 'message', + role: 'assistant', + status: 'in_progress', + content: [], + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.added', + responsesPayload: { + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_multi_1', + content_index: 0, + part: { + type: 'refusal', + text: 'no', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.content_part.added', + responsesPayload: { + type: 'response.content_part.added', + output_index: 0, + item_id: 'msg_multi_1', + content_index: 1, + part: { + type: 'output_text', + text: 'yes', + }, + }, + }, + }); + + const lines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(lines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.content_part.done', + 'response.output_text.done', + 'response.content_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.content_part.done', + content_index: 0, + part: { + type: 'refusal', + text: 'no', + }, + }); + expect(events[1]?.payload).toMatchObject({ + type: 'response.output_text.done', + item_id: 'msg_multi_1', + text: 'yes', + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.content_part.done', + content_index: 1, + part: { + type: 'output_text', + text: 'yes', + }, + }); + }); + + it('closes every unterminated reasoning summary part before completion', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'rs_multi_1', + type: 'reasoning', + status: 'in_progress', + summary: [], + }, + }, + }, + }); + + for (const [summaryIndex, text] of [[0, 'first'], [1, 'second']] as const) { + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.reasoning_summary_part.added', + responsesPayload: { + type: 'response.reasoning_summary_part.added', + item_id: 'rs_multi_1', + output_index: 0, + summary_index: summaryIndex, + part: { + type: 'summary_text', + text, + }, + }, + }, + }); + } + + const lines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(lines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.reasoning_summary_text.done', + 'response.reasoning_summary_part.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + expect(events[0]?.payload).toMatchObject({ + type: 'response.reasoning_summary_text.done', + summary_index: 0, + text: 'first', + }); + expect(events[2]?.payload).toMatchObject({ + type: 'response.reasoning_summary_text.done', + summary_index: 1, + text: 'second', + }); + }); + + it('does not synthesize duplicate tool input done events after original tool completion already arrived', () => { + const state = createOpenAiResponsesAggregateState('gpt-5'); + const streamContext = createStreamTransformContext('gpt-5'); + const usage = { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }; + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 0, + item: { + id: 'fc_done_1', + type: 'function_call', + status: 'in_progress', + call_id: 'fc_done_1', + name: 'browser', + arguments: '', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.function_call_arguments.done', + responsesPayload: { + type: 'response.function_call_arguments.done', + item_id: 'fc_done_1', + call_id: 'fc_done_1', + output_index: 0, + name: 'browser', + arguments: '{"url":"https://example.com"}', + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.output_item.added', + responsesPayload: { + type: 'response.output_item.added', + output_index: 1, + item: { + id: 'ct_done_1', + type: 'custom_tool_call', + status: 'in_progress', + call_id: 'ct_done_1', + name: 'browser', + input: '', + }, + }, + }, + }); + + serializeConvertedResponsesEvents({ + state, + streamContext, + usage, + event: { + responsesEventType: 'response.custom_tool_call_input.done', + responsesPayload: { + type: 'response.custom_tool_call_input.done', + item_id: 'ct_done_1', + call_id: 'ct_done_1', + output_index: 1, + name: 'browser', + input: 'open example.com', + }, + }, + }); + + const lines = completeResponsesStream(state, streamContext, usage); + const events = parseSseEvents(lines); + + expect(events.map((entry) => entry.event ?? (entry.payload === '[DONE]' ? '[DONE]' : 'data'))).toEqual([ + 'response.output_item.done', + 'response.output_item.done', + 'response.completed', + '[DONE]', + ]); + }); }); diff --git a/src/server/transformers/openai/responses/aggregator.ts b/src/server/transformers/openai/responses/aggregator.ts index 701ad6b..fde6662 100644 --- a/src/server/transformers/openai/responses/aggregator.ts +++ b/src/server/transformers/openai/responses/aggregator.ts @@ -61,6 +61,8 @@ const outputTextDoneMarker = Symbol('responses.output_text.done'); const contentPartDoneMarker = Symbol('responses.content_part.done'); const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.done'); const reasoningSummaryPartDoneMarker = Symbol('responses.reasoning_summary_part.done'); +const functionCallArgumentsDoneMarker = Symbol('responses.function_call_arguments.done'); +const customToolInputDoneMarker = Symbol('responses.custom_tool_call_input.done'); export type OpenAiResponsesAggregateState = { modelName: string; @@ -76,6 +78,7 @@ export type OpenAiResponsesAggregateState = { usageExtras: Record; completed: boolean; failed: boolean; + incomplete: boolean; }; export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState { @@ -93,6 +96,7 @@ export function createOpenAiResponsesAggregateState(modelName: string): OpenAiRe usageExtras: {}, completed: false, failed: false, + incomplete: false, }; } @@ -395,7 +399,7 @@ function materializeResponse( streamContext: StreamTransformContext, usage: ResponsesUsageSummary, responseTemplate?: Record | null, - statusOverride?: 'completed' | 'failed', + statusOverride?: 'completed' | 'failed' | 'incomplete', ): Record { const base = cloneRecord(responseTemplate) || {}; const responseId = ensureResponseId( @@ -417,7 +421,7 @@ function materializeResponse( ...item, status: status && status !== 'in_progress' ? status - : (state.failed ? 'failed' : 'completed'), + : (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'), }; }); @@ -426,7 +430,7 @@ function materializeResponse( id: responseId, object: 'response', created_at: createdAt, - status: statusOverride ?? (state.failed ? 'failed' : 'completed'), + status: statusOverride ?? (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'), model: asTrimmedString(base.model) || streamContext.model || state.modelName, output, output_text: collectOutputText(state), @@ -497,6 +501,9 @@ function applyOriginalResponsesPayload( ...reasoningState.item, ...item, }; + if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) { + next.status = 'completed'; + } if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)) { next.summary = reasoningState.item.summary; } @@ -512,6 +519,9 @@ function applyOriginalResponsesPayload( ...existing, ...item, }; + if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) { + next.status = 'completed'; + } if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) { next.content = existing.content; } @@ -575,6 +585,7 @@ function applyOriginalResponsesPayload( ); if (eventType === 'response.function_call_arguments.done') { entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? ''); + markTerminalMarker(entry.item, functionCallArgumentsDoneMarker); } else { entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; } @@ -591,6 +602,7 @@ function applyOriginalResponsesPayload( ); if (eventType === 'response.custom_tool_call_input.done') { entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? ''); + markTerminalMarker(entry.item, customToolInputDoneMarker); } else { entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`; } @@ -654,17 +666,36 @@ function applyOriginalResponsesPayload( } case 'response.completed': { mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); + const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'completed'); state.completed = true; const responsePayload = cloneRecord(payload.response); const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed'); - return [serializeSse('response.completed', { ...payload, response: materialized })]; + return [ + ...terminalLines, + serializeSse('response.completed', { ...payload, response: materialized }), + ]; } case 'response.failed': { mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); + const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'failed'); state.failed = true; const responsePayload = cloneRecord(payload.response); const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed'); - return [serializeSse('response.failed', { ...payload, response: materialized })]; + return [ + ...terminalLines, + serializeSse('response.failed', { ...payload, response: materialized }), + ]; + } + case 'response.incomplete': { + mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage); + const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete'); + state.incomplete = true; + const responsePayload = cloneRecord(payload.response); + const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'incomplete'); + return [ + ...terminalLines, + serializeSse('response.incomplete', { ...payload, response: materialized }), + ]; } default: mergeUsageExtras(state, payload.usage); @@ -803,7 +834,7 @@ function buildSyntheticToolEvents( function buildSyntheticTerminalItemDoneEvents( state: OpenAiResponsesAggregateState, - status: 'completed' | 'failed', + status: 'completed' | 'failed' | 'incomplete', ): string[] { const lines: string[] = []; @@ -812,7 +843,7 @@ function buildSyntheticTerminalItemDoneEvents( if (!isRecord(item)) continue; const currentStatus = asTrimmedString(item.status).toLowerCase(); - if (currentStatus === 'completed' || currentStatus === 'failed') continue; + if (currentStatus === 'completed' || currentStatus === 'failed' || currentStatus === 'incomplete') continue; const itemType = asTrimmedString(item.type).toLowerCase(); const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index); @@ -820,10 +851,12 @@ function buildSyntheticTerminalItemDoneEvents( if (itemType === 'message') { const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : []; - const part = content[0]; - if (isRecord(part)) { + for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) { + const part = content[contentIndex]; + if (!isRecord(part)) continue; + const partType = asTrimmedString(part.type).toLowerCase(); const text = typeof part.text === 'string' ? part.text : String(part.text ?? ''); - if (!hasTerminalMarker(part, outputTextDoneMarker)) { + if ((partType === 'output_text' || partType === 'text') && !hasTerminalMarker(part, outputTextDoneMarker)) { lines.push(serializeSse('response.output_text.done', { type: 'response.output_text.done', output_index: index, @@ -837,7 +870,7 @@ function buildSyntheticTerminalItemDoneEvents( type: 'response.content_part.done', output_index: index, item_id: itemId, - content_index: 0, + content_index: contentIndex, part: cloneJson(part), })); markTerminalMarker(part, contentPartDoneMarker); @@ -845,15 +878,16 @@ function buildSyntheticTerminalItemDoneEvents( } } else if (itemType === 'reasoning') { const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : []; - const part = summary[0]; - if (isRecord(part)) { + for (let summaryIndex = 0; summaryIndex < summary.length; summaryIndex += 1) { + const part = summary[summaryIndex]; + if (!isRecord(part)) continue; const text = typeof part.text === 'string' ? part.text : String(part.text ?? ''); if (!hasTerminalMarker(part, reasoningSummaryTextDoneMarker)) { lines.push(serializeSse('response.reasoning_summary_text.done', { type: 'response.reasoning_summary_text.done', item_id: itemId, output_index: index, - summary_index: 0, + summary_index: summaryIndex, text, })); markTerminalMarker(part, reasoningSummaryTextDoneMarker); @@ -863,7 +897,7 @@ function buildSyntheticTerminalItemDoneEvents( type: 'response.reasoning_summary_part.done', item_id: itemId, output_index: index, - summary_index: 0, + summary_index: summaryIndex, part: cloneJson(part), })); markTerminalMarker(part, reasoningSummaryPartDoneMarker); @@ -873,26 +907,32 @@ function buildSyntheticTerminalItemDoneEvents( const callId = asTrimmedString(item.call_id) || itemId; const name = asTrimmedString(item.name); const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? ''); - lines.push(serializeSse('response.function_call_arguments.done', { - type: 'response.function_call_arguments.done', - item_id: itemId, - call_id: callId, - output_index: index, - ...(name ? { name } : {}), - arguments: argumentsText, - })); + if (!hasTerminalMarker(item, functionCallArgumentsDoneMarker)) { + lines.push(serializeSse('response.function_call_arguments.done', { + type: 'response.function_call_arguments.done', + item_id: itemId, + call_id: callId, + output_index: index, + ...(name ? { name } : {}), + arguments: argumentsText, + })); + markTerminalMarker(item, functionCallArgumentsDoneMarker); + } } else if (itemType === 'custom_tool_call') { const callId = asTrimmedString(item.call_id) || itemId; const name = asTrimmedString(item.name); const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? ''); - lines.push(serializeSse('response.custom_tool_call_input.done', { - type: 'response.custom_tool_call_input.done', - item_id: itemId, - call_id: callId, - output_index: index, - ...(name ? { name } : {}), - input: inputText, - })); + if (!hasTerminalMarker(item, customToolInputDoneMarker)) { + lines.push(serializeSse('response.custom_tool_call_input.done', { + type: 'response.custom_tool_call_input.done', + item_id: itemId, + call_id: callId, + output_index: index, + ...(name ? { name } : {}), + input: inputText, + })); + markTerminalMarker(item, customToolInputDoneMarker); + } } item.status = status; @@ -941,7 +981,7 @@ export function completeResponsesStream( streamContext: StreamTransformContext, usage: ResponsesUsageSummary, ): string[] { - if (state.failed || state.completed) { + if (state.failed || state.completed || state.incomplete) { return [serializeDone()]; } const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed'); @@ -986,3 +1026,25 @@ export function failResponsesStream( serializeDone(), ]; } + +export function incompleteResponsesStream( + state: OpenAiResponsesAggregateState, + streamContext: StreamTransformContext, + usage: ResponsesUsageSummary, + payload: unknown, +): string[] { + if (state.failed || state.completed || state.incomplete) { + return [serializeDone()]; + } + const lines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete'); + state.incomplete = true; + const incompletePayload = cloneRecord(payload); + return [ + ...lines, + serializeSse('response.incomplete', { + ...incompletePayload, + response: materializeResponse(state, streamContext, usage, cloneRecord(incompletePayload?.response), 'incomplete'), + }), + serializeDone(), + ]; +} diff --git a/src/server/transformers/openai/responses/compatibility.ts b/src/server/transformers/openai/responses/compatibility.ts index fe9be9a..960a8a5 100644 --- a/src/server/transformers/openai/responses/compatibility.ts +++ b/src/server/transformers/openai/responses/compatibility.ts @@ -61,6 +61,8 @@ export function buildResponsesCompatibilityBodies( if (instructions !== null) richCandidate.instructions = instructions; const passthroughFields = [ + 'tools', + 'tool_choice', 'parallel_tool_calls', 'include', 'reasoning', @@ -228,6 +230,8 @@ function buildStrictResponsesBody( model, input: body.input, stream: body.stream === true, + ...(body.tools !== undefined ? { tools: cloneJsonValue(body.tools) } : {}), + ...(body.tool_choice !== undefined ? { tool_choice: cloneJsonValue(body.tool_choice) } : {}), ...(explicitInstructions !== null ? { instructions: explicitInstructions } : {}), diff --git a/src/server/transformers/openai/responses/conversion.test.ts b/src/server/transformers/openai/responses/conversion.test.ts index c54b3db..c146ad5 100644 --- a/src/server/transformers/openai/responses/conversion.test.ts +++ b/src/server/transformers/openai/responses/conversion.test.ts @@ -1109,6 +1109,219 @@ describe('convertResponsesBodyToOpenAiBody', () => { }); }); + it('preserves tools and tool_choice across all compatibility retry bodies', () => { + const candidates = buildResponsesCompatibilityBodies({ + model: 'gpt-5', + input: 'hello', + stream: true, + metadata: { user_id: 'user-1' }, + instructions: 'be helpful', + tools: [{ + type: 'function', + function: { + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + }, + }], + tool_choice: { + type: 'function', + function: { + name: 'lookup_weather', + }, + }, + }); + + expect(candidates.length).toBeGreaterThan(0); + for (const candidate of candidates) { + expect(candidate.tools).toEqual([{ + type: 'function', + function: { + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + }, + }]); + expect(candidate.tool_choice).toEqual({ + type: 'function', + function: { + name: 'lookup_weather', + }, + }); + } + }); + + it('maps native tool choices and strict function tools into OpenAI-compatible fallback bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + tools: [{ + type: 'function', + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + strict: true, + }], + tool_choice: { + type: 'tool', + name: 'lookup_weather', + }, + }, + 'gpt-5', + false, + ); + + expect(result.tools).toEqual([{ + type: 'function', + function: { + name: 'lookup_weather', + parameters: { + type: 'object', + properties: { + city: { type: 'string' }, + }, + }, + strict: true, + }, + }]); + expect(result.tool_choice).toEqual({ + type: 'function', + function: { + name: 'lookup_weather', + }, + }); + }); + + it('derives Responses reasoning config from chat-style reasoning request fields', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + reasoning_effort: 'high', + reasoning_budget: 2048, + reasoning_summary: 'detailed', + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + reasoning: { + effort: 'high', + budget_tokens: 2048, + summary: 'detailed', + }, + }); + }); + + it('preserves assistant reasoning history when converting OpenAI-compatible bodies to Responses input', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ + role: 'assistant', + content: '', + reasoning_content: 'plan quietly', + reasoning_signature: 'sig_1', + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + }], + }], + }, + 'gpt-5', + false, + ); + + expect(result.input).toEqual([ + { + type: 'reasoning', + summary: [{ + type: 'summary_text', + text: 'plan quietly', + }], + encrypted_content: 'sig_1', + }, + { + type: 'function_call', + call_id: 'call_1', + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + ]); + }); + + it('preserves chat-native modalities and audio settings when converting to Responses bodies', () => { + const result = convertOpenAiBodyToResponsesBody( + { + model: 'gpt-5', + messages: [{ role: 'user', content: 'hello' }], + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, + }); + }); + + it('preserves metadata, modalities, and audio when converting Responses bodies back to OpenAI-compatible bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + metadata: { user_id: 'user-1' }, + modalities: ['text', 'audio'], + audio: { + voice: 'alloy', + format: 'mp3', + }, + }); + }); + it('maps Responses text.format back into OpenAI response_format', () => { const result = convertResponsesBodyToOpenAiBody( { @@ -1151,6 +1364,27 @@ describe('convertResponsesBodyToOpenAiBody', () => { }); }); + it('preserves responses metadata when converting back to OpenAI-compatible bodies', () => { + const result = convertResponsesBodyToOpenAiBody( + { + model: 'gpt-5', + input: 'hello', + metadata: { + user_id: 'user-1', + }, + }, + 'gpt-5', + false, + ); + + expect(result).toMatchObject({ + model: 'gpt-5', + metadata: { + user_id: 'user-1', + }, + }); + }); + it('normalizes field parity when converting Responses input back to OpenAI-compatible input', () => { const result = convertResponsesBodyToOpenAiBody( { diff --git a/src/server/transformers/openai/responses/conversion.ts b/src/server/transformers/openai/responses/conversion.ts index 90ba91c..ed16d4d 100644 --- a/src/server/transformers/openai/responses/conversion.ts +++ b/src/server/transformers/openai/responses/conversion.ts @@ -179,6 +179,20 @@ function normalizeResponsesRequestFieldParity( normalized.text = textConfig; } + if (!isRecord(normalized.reasoning)) { + const reasoning: Record = {}; + const effort = normalizeOptionalTrimmedString((normalized as Record).reasoning_effort); + if (effort) reasoning.effort = effort; + const budgetTokens = toFiniteIntegerLike((normalized as Record).reasoning_budget); + if (budgetTokens !== null) reasoning.budget_tokens = budgetTokens; + const summary = normalizeOptionalTrimmedString((normalized as Record).reasoning_summary); + if (summary) reasoning.summary = summary; + if (Object.keys(reasoning).length > 0) normalized.reasoning = reasoning; + } + delete normalized.reasoning_effort; + delete normalized.reasoning_budget; + delete normalized.reasoning_summary; + return normalized; } @@ -448,6 +462,28 @@ export function convertOpenAiBodyToResponsesBody( } if (role === 'assistant') { + const reasoningContent = extractTextContent( + item.reasoning_content + ?? item.reasoning + ?? item.thinking, + ).trim(); + const reasoningSignature = asTrimmedString(item.reasoning_signature); + if (reasoningContent || reasoningSignature) { + const reasoningItem: Record = { + type: 'reasoning', + }; + if (reasoningContent) { + reasoningItem.summary = [{ + type: 'summary_text', + text: reasoningContent, + }]; + } + if (reasoningSignature) { + reasoningItem.encrypted_content = reasoningSignature; + } + inputItems.push(reasoningItem); + } + const normalizedContent = normalizeResponsesMessageContent('assistant', item.content); if (normalizedContent.length > 0) { inputItems.push({ @@ -527,7 +563,12 @@ export function convertOpenAiBodyToResponsesBody( if (topP !== null) body.top_p = topP; if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata; + if (openaiBody.modalities !== undefined) body.modalities = cloneJsonValue(openaiBody.modalities); + if (openaiBody.audio !== undefined) body.audio = cloneJsonValue(openaiBody.audio); if (openaiBody.reasoning !== undefined) body.reasoning = openaiBody.reasoning; + if (openaiBody.reasoning_effort !== undefined) body.reasoning_effort = openaiBody.reasoning_effort; + if (openaiBody.reasoning_budget !== undefined) body.reasoning_budget = openaiBody.reasoning_budget; + if (openaiBody.reasoning_summary !== undefined) body.reasoning_summary = openaiBody.reasoning_summary; if (openaiBody.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls; if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap); if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier; @@ -696,6 +737,14 @@ function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown { if (!isRecord(rawToolChoice)) return rawToolChoice; const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'tool') { + const name = asTrimmedString(rawToolChoice.name); + if (!name) return 'required'; + return { + type: 'function', + function: { name }, + }; + } if (type === 'function') { if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) { return rawToolChoice; @@ -845,6 +894,9 @@ export function convertResponsesBodyToOpenAiBody( if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) { payload.max_tokens = normalizedBody.max_output_tokens; } + if (normalizedBody.metadata !== undefined) payload.metadata = cloneJsonValue(normalizedBody.metadata); + if (normalizedBody.modalities !== undefined) payload.modalities = cloneJsonValue(normalizedBody.modalities); + if (normalizedBody.audio !== undefined) payload.audio = cloneJsonValue(normalizedBody.audio); if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls; if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools); if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice); diff --git a/src/server/transformers/openai/responses/outbound.test.ts b/src/server/transformers/openai/responses/outbound.test.ts index 37d9aac..9a6a9da 100644 --- a/src/server/transformers/openai/responses/outbound.test.ts +++ b/src/server/transformers/openai/responses/outbound.test.ts @@ -109,6 +109,47 @@ describe('serializeResponsesFinalPayload', () => { expect(payload).not.toHaveProperty('output_text'); }); + it('serializes response mode with created_at instead of created', () => { + const payload = serializeResponsesFinalPayload({ + upstreamPayload: { + id: 'chatcmpl_response_mode', + object: 'chat.completion', + created: 1700000000, + model: 'gpt-5', + choices: [ + { + index: 0, + finish_reason: 'stop', + message: { + role: 'assistant', + content: 'hello', + }, + }, + ], + }, + normalized: { + id: 'chatcmpl_response_mode', + model: 'gpt-5', + created: 1700000000, + content: 'hello', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 11, + completionTokens: 7, + totalTokens: 18, + }, + } as any); + + expect(payload).toMatchObject({ + object: 'response', + created_at: 1700000000, + }); + expect(payload).not.toHaveProperty('created'); + }); + it('preserves top-level chat annotations on synthetic assistant messages', () => { const payload = serializeResponsesFinalPayload({ upstreamPayload: { @@ -427,4 +468,49 @@ describe('serializeResponsesFinalPayload', () => { }, ]); }); + + it('preserves image generation mime_type on synthesized response payloads', () => { + const payload = serializeResponsesFinalPayload({ + upstreamPayload: { + id: 'resp_like_mime_1', + model: 'gpt-5', + output: [ + { + id: 'img_1', + type: 'image_generation_call', + status: 'completed', + result: 'data:image/png;base64,final', + mime_type: 'image/png', + output_format: 'png', + }, + ], + }, + normalized: { + id: 'resp_like_mime_1', + model: 'gpt-5', + created: 1700000000, + content: '', + reasoningContent: '', + finishReason: 'stop', + toolCalls: [], + }, + usage: { + promptTokens: 1, + completionTokens: 2, + totalTokens: 3, + }, + }); + + expect(payload.output).toEqual([ + { + id: 'img_1', + type: 'image_generation_call', + status: 'completed', + result: 'data:image/png;base64,final', + mime_type: 'image/png', + output_format: 'png', + partial_images: [], + }, + ]); + }); }); diff --git a/src/server/transformers/openai/responses/outbound.ts b/src/server/transformers/openai/responses/outbound.ts index be581e8..37af024 100644 --- a/src/server/transformers/openai/responses/outbound.ts +++ b/src/server/transformers/openai/responses/outbound.ts @@ -220,6 +220,7 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut result: cloneJson(rawItem.result), partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [], ...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}), + ...(rawItem.mime_type !== undefined ? { mime_type: cloneJson(rawItem.mime_type) } : {}), ...(rawItem.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}), ...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}), ...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}), @@ -418,7 +419,7 @@ export function serializeResponsesFinalPayload(input: { return { id: responseId, object: 'response', - created: normalized.created, + created_at: normalized.created, status: 'completed', model: normalized.model, output, diff --git a/src/server/transformers/openai/responses/proxyStream.test.ts b/src/server/transformers/openai/responses/proxyStream.test.ts index fa59cf2..f364dce 100644 --- a/src/server/transformers/openai/responses/proxyStream.test.ts +++ b/src/server/transformers/openai/responses/proxyStream.test.ts @@ -69,4 +69,193 @@ describe('createResponsesProxyStreamSession', () => { expect(output).toContain('"output_text":"hello from responses upstream"'); expect(output).toContain('data: [DONE]'); }); + + it('preserves the canonical [DONE] terminator after an explicit response.completed SSE event', async () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const chunk = [ + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_stream_1","model":"gpt-5","usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + + const reader = { + reads: 0, + async read() { + if (this.reads > 0) return { done: true }; + this.reads += 1; + return { done: false, value: new TextEncoder().encode(chunk) }; + }, + async cancel() { + return undefined; + }, + releaseLock() {}, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = await session.run(reader as any, { + end() { + ended = true; + }, + }); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + const output = lines.join(''); + expect(output).toContain('event: response.completed'); + expect(output).toContain('data: [DONE]'); + }); + + it('preserves response.incomplete SSE terminals instead of coercing them to response.failed', async () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const chunk = [ + 'event: response.incomplete', + 'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_1","model":"gpt-5","status":"incomplete","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":5,"output_tokens":3,"total_tokens":8}}}', + '', + 'data: [DONE]', + '', + ].join('\n'); + + const reader = { + reads: 0, + async read() { + if (this.reads > 0) return { done: true }; + this.reads += 1; + return { done: false, value: new TextEncoder().encode(chunk) }; + }, + async cancel() { + return undefined; + }, + releaseLock() {}, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = await session.run(reader as any, { + end() { + ended = true; + }, + }); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + const output = lines.join(''); + expect(output).toContain('event: response.incomplete'); + expect(output).toContain('"status":"incomplete"'); + expect(output).toContain('"incomplete_details":{"reason":"max_output_tokens"}'); + expect(output).not.toContain('event: response.failed'); + expect(output).toContain('data: [DONE]'); + }); + + it('preserves non-SSE incomplete fallback payloads as response.incomplete', () => { + const lines: string[] = []; + let ended = false; + const usage = { + promptTokens: 5, + completionTokens: 3, + totalTokens: 8, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }; + const payload = { + id: 'resp_incomplete_fallback_1', + object: 'response', + status: 'incomplete', + incomplete_details: { + reason: 'max_output_tokens', + }, + model: 'gpt-5.2', + output_text: 'partial answer', + output: [ + { + id: 'msg_incomplete_1', + type: 'message', + role: 'assistant', + status: 'incomplete', + content: [{ type: 'output_text', text: 'partial answer' }], + }, + ], + usage: { + input_tokens: usage.promptTokens, + output_tokens: usage.completionTokens, + total_tokens: usage.totalTokens, + }, + }; + + const session = createResponsesProxyStreamSession({ + modelName: 'gpt-5.2', + successfulUpstreamPath: '/v1/responses', + getUsage: () => usage, + writeLines: (nextLines) => { + lines.push(...nextLines); + }, + writeRaw: () => {}, + }); + + const result = session.consumeUpstreamFinalPayload( + payload, + JSON.stringify(payload), + { + end() { + ended = true; + }, + }, + ); + + expect(result).toEqual({ + status: 'completed', + errorMessage: null, + }); + expect(ended).toBe(true); + + const output = lines.join(''); + expect(output).toContain('event: response.incomplete'); + expect(output).toContain('"status":"incomplete"'); + expect(output).toContain('"output_text":"partial answer"'); + expect(output).not.toContain('event: response.completed'); + expect(output).toContain('data: [DONE]'); + }); }); diff --git a/src/server/transformers/openai/responses/proxyStream.ts b/src/server/transformers/openai/responses/proxyStream.ts index 3f0e67d..56f1f30 100644 --- a/src/server/transformers/openai/responses/proxyStream.ts +++ b/src/server/transformers/openai/responses/proxyStream.ts @@ -119,6 +119,7 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes || input.successfulUpstreamPath.endsWith('/responses') || input.successfulUpstreamPath.endsWith('/responses/compact'); let finalized = false; + let terminalEventSeen = false; let terminalResult: ResponsesProxyStreamResult = { status: 'completed', errorMessage: null, @@ -145,8 +146,6 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes }; const complete = () => { - if (finalized) return; - finalized = true; terminalResult = { status: 'completed', errorMessage: null, @@ -155,6 +154,10 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes const closeOut = () => { if (finalized) return; + if (terminalEventSeen) { + finalize(); + return; + } if (requiresExplicitTerminalEvent) { fail({ type: 'response.failed', @@ -190,15 +193,14 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes const isFailureEvent = ( eventBlock.event === 'error' || eventBlock.event === 'response.failed' - || eventBlock.event === 'response.incomplete' || payloadType === 'error' || payloadType === 'response.failed' - || payloadType === 'response.incomplete' ); if (isFailureEvent) { fail(parsedPayload); return true; } + const isIncompleteEvent = eventBlock.event === 'response.incomplete' || payloadType === 'response.incomplete'; if (parsedPayload && typeof parsedPayload === 'object') { const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName); @@ -225,7 +227,8 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes return true; } input.writeLines(convertedLines); - if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') { + if (eventBlock.event === 'response.completed' || payloadType === 'response.completed' || isIncompleteEvent) { + terminalEventSeen = true; complete(); } return false; @@ -249,6 +252,9 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes const payloadType = (isRecord(payload) && typeof payload.type === 'string') ? payload.type : ''; + const payloadStatus = isRecord(payload) && typeof payload.status === 'string' + ? payload.status + : ''; if (payloadType === 'error' || payloadType === 'response.failed') { fail(payload); response?.end(); @@ -266,7 +272,8 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes usage: input.getUsage(), serializationMode: 'response', }); - if (shouldFailEmptyResponsesCompletion({ + const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete'; + if (!isIncompletePayload && shouldFailEmptyResponsesCompletion({ payload: { type: 'response.completed', response: streamPayload }, state: responsesState, usage: input.getUsage(), @@ -287,16 +294,29 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes output_text: '', }; - finalized = true; - terminalResult = { - status: 'completed', - errorMessage: null, - }; - input.writeLines([ - `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, - `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`, - 'data: [DONE]\n\n', - ]); + if (isIncompletePayload) { + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines([ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + `event: response.incomplete\ndata: ${JSON.stringify({ type: 'response.incomplete', response: streamPayload })}\n\n`, + 'data: [DONE]\n\n', + ]); + } else { + finalized = true; + terminalResult = { + status: 'completed', + errorMessage: null, + }; + input.writeLines([ + `event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`, + `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`, + 'data: [DONE]\n\n', + ]); + } response?.end(); return terminalResult; }, diff --git a/src/server/transformers/openai/responses/stream.ts b/src/server/transformers/openai/responses/stream.ts index 7d4da3e..4e50bc3 100644 --- a/src/server/transformers/openai/responses/stream.ts +++ b/src/server/transformers/openai/responses/stream.ts @@ -46,6 +46,7 @@ export const openAiResponsesStream = { 'response.image_generation_call.partial_image', 'response.image_generation_call.completed', 'response.completed', + 'response.incomplete', 'response.failed', ] as const, createContext(modelName: string): StreamTransformContext { diff --git a/src/server/transformers/shared/chatFormatsCore.ts b/src/server/transformers/shared/chatFormatsCore.ts index e3f0401..97b0645 100644 --- a/src/server/transformers/shared/chatFormatsCore.ts +++ b/src/server/transformers/shared/chatFormatsCore.ts @@ -437,6 +437,34 @@ function parseResponsesOutputText(payload: Record): string { return parts.join('\n\n'); } +function parseResponsesReasoning(payload: Record): { + reasoningContent: string; + reasoningSignature?: string; +} { + const output = Array.isArray(payload.output) ? payload.output : []; + const reasoningParts: string[] = []; + let reasoningSignature = ''; + + for (const item of output) { + if (!isRecord(item)) continue; + if (asTrimmedString(item.type).toLowerCase() !== 'reasoning') continue; + + const parsed = extractTextAndReasoning(item.summary ?? item.content ?? item); + const text = joinNonEmpty([parsed.content, parsed.reasoning]); + if (text) reasoningParts.push(text); + + const encrypted = asTrimmedString(item.encrypted_content); + if (!reasoningSignature && encrypted) { + reasoningSignature = encrypted; + } + } + + return { + reasoningContent: joinNonEmpty(reasoningParts), + ...(reasoningSignature ? { reasoningSignature } : {}), + }; +} + function stringifyUnknownValue(value: unknown): string { if (typeof value === 'string') return value; if (value === undefined || value === null) return ''; @@ -658,6 +686,98 @@ function collapseOpenAiContentBlocks( return text || null; } +function pickPositiveInteger(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return undefined; +} + +function convertClaudeToolsToOpenAiChat(rawTools: unknown): unknown { + if (!Array.isArray(rawTools)) return rawTools; + + return rawTools.map((item) => { + if (!isRecord(item)) return item; + + const type = asTrimmedString(item.type).toLowerCase(); + if (type === 'function' || type === 'custom' || type === 'image_generation') { + return item; + } + + const name = asTrimmedString(item.name); + if (!name) return item; + + return { + type: 'function', + function: { + name, + ...(asTrimmedString(item.description) + ? { description: asTrimmedString(item.description) } + : {}), + parameters: isRecord(item.input_schema) + ? item.input_schema + : (isRecord(item.parameters) ? item.parameters : { type: 'object' }), + }, + }; + }); +} + +function convertClaudeToolChoiceToOpenAiChat(rawToolChoice: unknown): unknown { + if (rawToolChoice === undefined) return undefined; + if (typeof rawToolChoice === 'string') { + const normalized = rawToolChoice.trim().toLowerCase(); + if (normalized === 'any') return 'required'; + return normalized || rawToolChoice; + } + if (!isRecord(rawToolChoice)) return rawToolChoice; + + const type = asTrimmedString(rawToolChoice.type).toLowerCase(); + if (type === 'auto' || type === 'none') return type; + if (type === 'any' || type === 'required') return 'required'; + if (type === 'function' && isRecord(rawToolChoice.function)) { + const name = asTrimmedString(rawToolChoice.function.name); + return name + ? { + type: 'function', + function: { name }, + } + : 'required'; + } + if (type !== 'tool') return rawToolChoice; + + const name = asTrimmedString( + rawToolChoice.name + ?? (isRecord(rawToolChoice.tool) ? rawToolChoice.tool.name : undefined), + ); + return name + ? { + type: 'function', + function: { name }, + } + : 'required'; +} + +function extractClaudeReasoningRequest( + body: Record, +): { reasoningEffort?: string; reasoningBudget?: number } { + const thinking = isRecord(body.thinking) ? body.thinking : null; + const outputConfig = isRecord(body.output_config) ? body.output_config : null; + const reasoningEffort = asTrimmedString(outputConfig?.effort).toLowerCase(); + const reasoningBudget = pickPositiveInteger( + thinking?.budget_tokens + ?? thinking?.budgetTokens, + ); + + return { + ...(reasoningEffort ? { reasoningEffort } : {}), + ...(reasoningBudget !== undefined ? { reasoningBudget } : {}), + }; +} + function convertClaudeRequestToOpenAiBody(body: Record): { model: string; stream: boolean; @@ -717,6 +837,7 @@ function convertClaudeRequestToOpenAiBody(body: Record): { // - user.tool_result -> tool messages with tool_call_id const contentBlocks: Array> = []; const toolCalls: Array> = []; + const reasoningParts: string[] = []; const flushContentAsMessage = () => { const contentPayload = collapseOpenAiContentBlocks(contentBlocks); @@ -730,7 +851,6 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const blockType = typeof block.type === 'string' ? block.type : ''; if (blockType === 'tool_result') { - flushContentAsMessage(); appendToolResultMessage(block.tool_use_id, block.content); continue; } @@ -758,13 +878,18 @@ function convertClaudeRequestToOpenAiBody(body: Record): { continue; } + const extracted = extractTextAndReasoning(block); + if (mappedRole === 'assistant' && extracted.reasoning) { + reasoningParts.push(extracted.reasoning); + } + const contentBlock = toOpenAiContentBlockFromClaudeBlock(block); if (contentBlock) { contentBlocks.push(contentBlock); continue; } - const text = parseClaudeMessageContent(block); + const text = extracted.content || parseClaudeMessageContent(block); if (text) { contentBlocks.push({ type: 'text', @@ -780,11 +905,20 @@ function convertClaudeRequestToOpenAiBody(body: Record): { tool_calls: toolCalls, }; assistantMessage.content = merged || ''; + const reasoningContent = joinNonEmpty(reasoningParts); + if (reasoningContent) assistantMessage.reasoning_content = reasoningContent; messages.push(assistantMessage); - } else if (merged !== null) { - messages.push({ + } else if (merged !== null || (mappedRole === 'assistant' && reasoningParts.length > 0)) { + const nextMessage: Record = { role: mappedRole, - content: merged, + content: merged ?? '', + }; + if (mappedRole === 'assistant') { + const reasoningContent = joinNonEmpty(reasoningParts); + if (reasoningContent) nextMessage.reasoning_content = reasoningContent; + } + messages.push({ + ...nextMessage, }); } } @@ -801,6 +935,8 @@ function convertClaudeRequestToOpenAiBody(body: Record): { const topP = pickFiniteNumber(body.top_p); if (topP !== undefined) payload.top_p = topP; + if (isRecord(body.metadata)) payload.metadata = body.metadata; + const maxTokens = pickFiniteNumber(body.max_tokens); if (maxTokens !== undefined) { payload.max_tokens = maxTokens; @@ -812,8 +948,12 @@ function convertClaudeRequestToOpenAiBody(body: Record): { payload.stop = body.stop_sequences; } - if (body.tools !== undefined) payload.tools = body.tools; - if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice; + const reasoningRequest = extractClaudeReasoningRequest(body); + if (reasoningRequest.reasoningEffort) payload.reasoning_effort = reasoningRequest.reasoningEffort; + if (reasoningRequest.reasoningBudget !== undefined) payload.reasoning_budget = reasoningRequest.reasoningBudget; + + if (body.tools !== undefined) payload.tools = convertClaudeToolsToOpenAiChat(body.tools); + if (body.tool_choice !== undefined) payload.tool_choice = convertClaudeToolChoiceToOpenAiChat(body.tool_choice); return { model, stream, messages, payload }; } @@ -927,12 +1067,14 @@ export function normalizeUpstreamFinalResponse( if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) { const toolCalls = collectToolCallsFromResponsesPayload(payload); + const responsesReasoning = parseResponsesReasoning(payload); return { id: isNonEmptyString(payload.id) ? payload.id : fallbackId, model: isNonEmptyString(payload.model) ? payload.model : fallbackModel, - created: ensureIntegerTimestamp(payload.created, now), + created: ensureIntegerTimestamp((payload as any).created_at ?? payload.created, now), content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText), - reasoningContent: '', + reasoningContent: responsesReasoning.reasoningContent, + ...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}), finishReason: toolCalls.length > 0 ? 'tool_calls' : (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'), diff --git a/src/server/transformers/shared/normalized.test.ts b/src/server/transformers/shared/normalized.test.ts index 74069c2..6555893 100644 --- a/src/server/transformers/shared/normalized.test.ts +++ b/src/server/transformers/shared/normalized.test.ts @@ -5,6 +5,7 @@ import { fromTransformerMetadataRecord, normalizeStopReason, normalizeUpstreamFinalResponse, + parseDownstreamChatRequest, pullSseEventsWithDone, serializeFinalResponse, toTransformerMetadataRecord, @@ -78,6 +79,185 @@ describe('shared normalized helpers', () => { }); }); + it('preserves responses reasoning summaries and encrypted reasoning signatures in final normalization', () => { + expect(normalizeUpstreamFinalResponse({ + object: 'response', + id: 'resp_reasoning_1', + model: 'gpt-test', + created_at: 456, + output: [ + { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'plan quietly' }], + encrypted_content: 'enc-1', + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'hello' }], + }, + ], + status: 'completed', + }, 'fallback-model')).toEqual({ + id: 'resp_reasoning_1', + model: 'gpt-test', + created: 456, + content: 'hello', + reasoningContent: 'plan quietly', + reasoningSignature: 'enc-1', + finishReason: 'stop', + toolCalls: [], + }); + }); + + it('normalizes responses payloads with reasoning summaries and encrypted signatures', () => { + expect(normalizeUpstreamFinalResponse({ + object: 'response', + id: 'resp_reasoning_1', + model: 'gpt-test', + created: 123, + output: [ + { + type: 'reasoning', + encrypted_content: 'enc_1', + summary: [ + { type: 'summary_text', text: 'plan quietly' }, + ], + }, + ], + status: 'completed', + }, 'fallback-model')).toEqual({ + id: 'resp_reasoning_1', + model: 'gpt-test', + created: 123, + content: '', + reasoningContent: 'plan quietly', + reasoningSignature: 'enc_1', + finishReason: 'stop', + toolCalls: [], + }); + }); + + it('maps claude tools, tool choice, metadata, and reasoning when parsing downstream requests', () => { + const result = parseDownstreamChatRequest({ + model: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + thinking: { + type: 'enabled', + budget_tokens: 2048, + }, + output_config: { + effort: 'high', + }, + tools: [{ + name: 'Glob', + description: 'Search files', + input_schema: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }], + tool_choice: { + type: 'tool', + name: 'Glob', + }, + messages: [{ + role: 'user', + content: 'hello', + }], + }, 'claude'); + + expect(result.error).toBeUndefined(); + expect(result.value?.upstreamBody).toMatchObject({ + model: 'gpt-5', + stream: true, + metadata: { user_id: 'user-1' }, + reasoning_effort: 'high', + reasoning_budget: 2048, + tools: [{ + type: 'function', + function: { + name: 'Glob', + description: 'Search files', + parameters: { + type: 'object', + properties: { + pattern: { type: 'string' }, + }, + required: ['pattern'], + }, + }, + }], + tool_choice: { + type: 'function', + function: { + name: 'Glob', + }, + }, + }); + }); + + it('keeps claude thinking in reasoning_content and emits tool results before follow-up user text', () => { + const result = parseDownstreamChatRequest({ + model: 'gpt-5', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'plan quietly' }, + { + type: 'tool_use', + id: 'toolu_abc', + name: 'Glob', + input: { pattern: 'README*' }, + }, + ], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_abc', + content: [{ type: 'text', text: '{"matches":1}' }], + }, + { type: 'text', text: 'continue' }, + ], + }, + ], + }, 'claude'); + + expect(result.error).toBeUndefined(); + expect(result.value?.upstreamBody.messages).toEqual([ + { + role: 'assistant', + content: '', + reasoning_content: 'plan quietly', + tool_calls: [{ + id: 'toolu_abc', + type: 'function', + function: { + name: 'Glob', + arguments: '{"pattern":"README*"}', + }, + }], + }, + { + role: 'tool', + tool_call_id: 'toolu_abc', + content: '{"matches":1}', + }, + { + role: 'user', + content: 'continue', + }, + ]); + }); + it('serializes normalized final responses for claude', () => { const normalized = { id: 'chatcmpl-1',