forked from boos/BoosAPI
fix: preserve protocol parity across Claude fallbacks (#256)
* fix: preserve protocol parity across claude fallbacks * fix: tighten protocol parity across responses and anthropic fallbacks * fix: preserve stricter tool contracts across fallbacks * fix: unblock anthropic stream server build
This commit is contained in:
@@ -3190,6 +3190,87 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(toolMessage.content).toContain('matches');
|
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 () => {
|
it('forces claude platform to use /v1/messages with x-api-key auth for openai downstream requests', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
|
|||||||
@@ -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<any>((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<T>() {
|
function createDeferred<T>() {
|
||||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
let reject!: (reason?: unknown) => void;
|
let reject!: (reason?: unknown) => void;
|
||||||
@@ -559,6 +588,78 @@ describe('responses websocket transport', () => {
|
|||||||
expect(messages[0]?.response?.id).toBe('resp_http_fallback_401');
|
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 () => {
|
it('preserves query parameter auth when websocket transport falls back to the HTTP responses route', async () => {
|
||||||
rejectedUpgradeStatus = 401;
|
rejectedUpgradeStatus = 401;
|
||||||
rejectedUpgradeStatusText = 'Unauthorized';
|
rejectedUpgradeStatusText = 'Unauthorized';
|
||||||
@@ -793,7 +894,10 @@ describe('responses websocket transport', () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
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([
|
firstResponseGate.resolve(createSseResponse([
|
||||||
'event: response.output_item.done\n',
|
'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',
|
'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',
|
'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();
|
socket.close();
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(messages.map((message) => message.type)).toEqual([
|
expect(secondTurnMessage).toMatchObject({
|
||||||
'response.output_item.done',
|
type: 'response.completed',
|
||||||
'response.output_item.done',
|
response: {
|
||||||
'response.completed',
|
id: 'resp_ws_2',
|
||||||
'response.completed',
|
},
|
||||||
]);
|
});
|
||||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
const secondBody = JSON.parse(String(secondOptions.body));
|
const secondBody = JSON.parse(String(secondOptions.body));
|
||||||
expect(secondBody.input).toEqual([
|
expect(secondBody.input).toEqual([
|
||||||
@@ -916,7 +1024,10 @@ describe('responses websocket transport', () => {
|
|||||||
}));
|
}));
|
||||||
await firstResponsePromise;
|
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({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
previous_response_id: 'resp_ws_1',
|
previous_response_id: 'resp_ws_1',
|
||||||
@@ -929,10 +1040,15 @@ describe('responses websocket transport', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
await secondResponsePromise;
|
const secondResponse = await secondResponsePromise;
|
||||||
socket.close();
|
socket.close();
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(0);
|
expect(secondResponse).toMatchObject({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_ws_2',
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(upstreamConnectionCount).toBe(1);
|
expect(upstreamConnectionCount).toBe(1);
|
||||||
expect(upstreamRequests).toHaveLength(2);
|
expect(upstreamRequests).toHaveLength(2);
|
||||||
expect(upstreamRequests[0]).toMatchObject({
|
expect(upstreamRequests[0]).toMatchObject({
|
||||||
|
|||||||
@@ -602,7 +602,7 @@ async function handleResponsesWebsocketConnection(
|
|||||||
socket,
|
socket,
|
||||||
request,
|
request,
|
||||||
payload: normalized.request,
|
payload: normalized.request,
|
||||||
preserveIncrementalMode: false,
|
preserveIncrementalMode: supportsIncrementalInput,
|
||||||
authToken: authContext.token,
|
authToken: authContext.token,
|
||||||
});
|
});
|
||||||
if (fallbackOutput) {
|
if (fallbackOutput) {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js';
|
import { normalizeUpstreamFinalResponse, toClaudeStopReason, type NormalizedFinalResponse } from '../../shared/normalized.js';
|
||||||
|
import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js';
|
||||||
import { toAnthropicUsagePayload } from './usage.js';
|
import { toAnthropicUsagePayload } from './usage.js';
|
||||||
|
|
||||||
type AnthropicRecord = Record<string, unknown>;
|
type AnthropicRecord = Record<string, unknown>;
|
||||||
@@ -25,6 +26,10 @@ function cloneJsonValue<T>(value: T): T {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function parseJsonLike(raw: string): unknown {
|
function parseJsonLike(raw: string): unknown {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return {};
|
if (!trimmed) return {};
|
||||||
@@ -41,17 +46,46 @@ function buildClaudeMessageId(sourceId: string): string {
|
|||||||
return `msg_${sanitized || Date.now()}`;
|
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<Record<string, unknown>> {
|
function buildAnthropicContent(normalized: NormalizedFinalResponse): Array<Record<string, unknown>> {
|
||||||
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
|
const anthropicNormalized = normalized as AnthropicMessagesNormalizedFinalResponse;
|
||||||
if (Array.isArray(anthropicNormalized.nativeContent) && anthropicNormalized.nativeContent.length > 0) {
|
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<Record<string, unknown>> = [];
|
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||||
if (normalized.reasoningContent) {
|
const cleanSignature = cleanAnthropicReasoningSignature(normalized.reasoningSignature);
|
||||||
contentBlocks.push({
|
if (normalized.reasoningContent || cleanSignature) {
|
||||||
|
const thinkingBlock: Record<string, unknown> = {
|
||||||
type: 'thinking',
|
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) {
|
if (normalized.content) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { anthropicMessagesTransformer } from './index.js';
|
import { anthropicMessagesTransformer, serializeAnthropicFinalAsStream } from './index.js';
|
||||||
|
|
||||||
type SerializedAnthropicEvent = {
|
type SerializedAnthropicEvent = {
|
||||||
event: string;
|
event: string;
|
||||||
@@ -304,3 +304,142 @@ describe('anthropicMessagesStream.serializeEvent', () => {
|
|||||||
expect(stopEvents[0]?.payload.index).toBe(1);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ import {
|
|||||||
type NormalizedStreamEvent,
|
type NormalizedStreamEvent,
|
||||||
type StreamTransformContext,
|
type StreamTransformContext,
|
||||||
} from '../../shared/normalized.js';
|
} from '../../shared/normalized.js';
|
||||||
|
import { decodeAnthropicReasoningSignature } from '../../shared/reasoningTransport.js';
|
||||||
import { type AnthropicExtendedStreamEvent } from './aggregator.js';
|
import { type AnthropicExtendedStreamEvent } from './aggregator.js';
|
||||||
|
|
||||||
type AnthropicStreamPayload = Record<string, unknown>;
|
type AnthropicStreamPayload = Record<string, unknown>;
|
||||||
|
type AnthropicMessagesNormalizedFinalResponse = NormalizedFinalResponse & {
|
||||||
|
nativeContent?: AnthropicStreamPayload[];
|
||||||
|
};
|
||||||
|
|
||||||
type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking';
|
type AnthropicBlockKind = 'thinking' | 'text' | 'tool_use' | 'redacted_thinking';
|
||||||
|
|
||||||
@@ -57,6 +61,106 @@ function serializeSse(event: string, payload: Record<string, unknown>): string {
|
|||||||
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cloneJsonValue<T>(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 {
|
export function isAnthropicRawSseEventName(value: unknown): value is string {
|
||||||
return typeof value === 'string' && ANTHROPIC_RAW_SSE_EVENT_NAMES.has(value);
|
return typeof value === 'string' && ANTHROPIC_RAW_SSE_EVENT_NAMES.has(value);
|
||||||
}
|
}
|
||||||
@@ -493,25 +597,126 @@ export function serializeAnthropicFinalAsStream(
|
|||||||
const lines = [
|
const lines = [
|
||||||
...anthropicMessagesStream.serializeEvent({ role: 'assistant' }, streamContext, downstreamContext),
|
...anthropicMessagesStream.serializeEvent({ role: 'assistant' }, streamContext, downstreamContext),
|
||||||
];
|
];
|
||||||
|
const serializeAnthropicEvent = (event: AnthropicExtendedStreamEvent) => anthropicMessagesStream.serializeEvent(
|
||||||
|
event,
|
||||||
|
streamContext,
|
||||||
|
downstreamContext,
|
||||||
|
);
|
||||||
|
const contentBlocks = buildAnthropicFinalContentBlocks(normalizedFinal);
|
||||||
|
|
||||||
if (normalizedFinal.reasoningContent) {
|
for (let index = 0; index < contentBlocks.length; index += 1) {
|
||||||
lines.push(
|
const block = contentBlocks[index];
|
||||||
...anthropicMessagesStream.serializeEvent(
|
const blockType = asTrimmedString(block.type).toLowerCase();
|
||||||
{ reasoningDelta: normalizedFinal.reasoningContent },
|
|
||||||
|
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,
|
streamContext,
|
||||||
downstreamContext,
|
downstreamContext,
|
||||||
),
|
));
|
||||||
);
|
lines.push(...serializeAnthropicEvent(
|
||||||
}
|
{
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (normalizedFinal.content) {
|
lines.push(...serializeAnthropicEvent(
|
||||||
lines.push(
|
{
|
||||||
...anthropicMessagesStream.serializeEvent(
|
anthropic: {
|
||||||
{ contentDelta: normalizedFinal.content },
|
startBlock: {
|
||||||
|
kind: 'text',
|
||||||
|
index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
));
|
||||||
|
if (typeof block.text === 'string') {
|
||||||
|
lines.push(...anthropicMessagesStream.serializeEvent(
|
||||||
|
{ contentDelta: block.text },
|
||||||
streamContext,
|
streamContext,
|
||||||
downstreamContext,
|
downstreamContext,
|
||||||
),
|
));
|
||||||
);
|
}
|
||||||
|
lines.push(...serializeAnthropicEvent(
|
||||||
|
{
|
||||||
|
anthropic: {
|
||||||
|
stopBlockIndex: index,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(
|
lines.push(
|
||||||
@@ -766,8 +971,11 @@ export const anthropicMessagesStream = {
|
|||||||
events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, context));
|
events.push(...handleExplicitBlockStart(startBlock.kind, normalizedStartIndex, context));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anthropicEvent.anthropic?.signatureDelta) {
|
const signatureDelta = anthropicEvent.anthropic?.signatureDelta
|
||||||
bufferPendingSignature(context, anthropicEvent.anthropic.signatureDelta);
|
?? (typeof event.reasoningSignature === 'string' ? event.reasoningSignature : undefined);
|
||||||
|
const cleanSignatureDelta = cleanAnthropicReasoningSignature(signatureDelta);
|
||||||
|
if (cleanSignatureDelta) {
|
||||||
|
bufferPendingSignature(context, cleanSignatureDelta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (anthropicEvent.anthropic?.redactedThinkingData) {
|
if (anthropicEvent.anthropic?.redactedThinkingData) {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createCanonicalRequestEnvelope } from './request.js';
|
import {
|
||||||
|
canonicalRequestFromOpenAiBody,
|
||||||
|
canonicalRequestToOpenAiChatBody,
|
||||||
|
createCanonicalRequestEnvelope,
|
||||||
|
} from './request.js';
|
||||||
|
|
||||||
describe('canonical request helpers', () => {
|
describe('canonical request helpers', () => {
|
||||||
it('normalizes a count_tokens request without provider-owned fields', () => {
|
it('normalizes a count_tokens request without provider-owned fields', () => {
|
||||||
@@ -45,4 +49,169 @@ describe('canonical request helpers', () => {
|
|||||||
messages: [],
|
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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -197,6 +197,13 @@ function parseToolChoice(rawToolChoice: unknown): CanonicalToolChoice | undefine
|
|||||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
||||||
if (type === 'auto' || type === 'none') return type;
|
if (type === 'auto' || type === 'none') return type;
|
||||||
if (type === 'any' || type === 'required') return 'required';
|
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;
|
if (type !== 'tool') return undefined;
|
||||||
|
|
||||||
const name = asTrimmedString(
|
const name = asTrimmedString(
|
||||||
@@ -212,8 +219,9 @@ function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
|||||||
const tools = rawTools
|
const tools = rawTools
|
||||||
.flatMap((item) => {
|
.flatMap((item) => {
|
||||||
if (!isRecord(item)) return [];
|
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);
|
const name = asTrimmedString(item.function.name);
|
||||||
if (!name) return [];
|
if (!name) return [];
|
||||||
return [{
|
return [{
|
||||||
@@ -221,10 +229,24 @@ function parseTools(rawTools: unknown): CanonicalTool[] | undefined {
|
|||||||
...(asTrimmedString(item.function.description)
|
...(asTrimmedString(item.function.description)
|
||||||
? { description: 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) } : {}),
|
...(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)) {
|
if (Array.isArray(item.functionDeclarations)) {
|
||||||
return item.functionDeclarations.flatMap((declaration) => {
|
return item.functionDeclarations.flatMap((declaration) => {
|
||||||
if (!isRecord(declaration)) return [];
|
if (!isRecord(declaration)) return [];
|
||||||
@@ -252,6 +274,9 @@ export function canonicalRequestFromOpenAiBody(
|
|||||||
input: CanonicalRequestFromOpenAiBodyInput,
|
input: CanonicalRequestFromOpenAiBodyInput,
|
||||||
): CanonicalRequestEnvelope {
|
): CanonicalRequestEnvelope {
|
||||||
const body = input.body;
|
const body = input.body;
|
||||||
|
const metadata = isRecord(input.metadata)
|
||||||
|
? input.metadata
|
||||||
|
: (isRecord(body.metadata) ? cloneJsonValue(body.metadata) : undefined);
|
||||||
const messages: CanonicalMessage[] = [];
|
const messages: CanonicalMessage[] = [];
|
||||||
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
const rawMessages = Array.isArray(body.messages) ? body.messages : [];
|
||||||
|
|
||||||
@@ -329,7 +354,7 @@ export function canonicalRequestFromOpenAiBody(
|
|||||||
...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}),
|
...(parseTools(body.tools) ? { tools: parseTools(body.tools) } : {}),
|
||||||
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
|
...(parseToolChoice(body.tool_choice) !== undefined ? { toolChoice: parseToolChoice(body.tool_choice) } : {}),
|
||||||
...(Object.keys(continuation).length > 0 ? { continuation } : {}),
|
...(Object.keys(continuation).length > 0 ? { continuation } : {}),
|
||||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
...(metadata ? { metadata } : {}),
|
||||||
...((reasoningResult.metadata || input.passthrough)
|
...((reasoningResult.metadata || input.passthrough)
|
||||||
? {
|
? {
|
||||||
passthrough: {
|
passthrough: {
|
||||||
@@ -478,6 +503,20 @@ export function canonicalRequestToOpenAiChatBody(
|
|||||||
if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort;
|
if (request.reasoning?.effort) body.reasoning_effort = request.reasoning.effort;
|
||||||
if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens;
|
if (request.reasoning?.budgetTokens !== undefined) body.reasoning_budget = request.reasoning.budgetTokens;
|
||||||
if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary;
|
if (request.reasoning?.summary) body.reasoning_summary = request.reasoning.summary;
|
||||||
|
const transformerMetadata = isRecord(request.passthrough?.transformerMetadata)
|
||||||
|
? request.passthrough.transformerMetadata as Record<string, unknown>
|
||||||
|
: 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?.promptCacheKey) body.prompt_cache_key = request.continuation.promptCacheKey;
|
||||||
if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId;
|
if (request.continuation?.previousResponseId) body.previous_response_id = request.continuation.previousResponseId;
|
||||||
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
if (Array.isArray(request.tools) && request.tools.length > 0) {
|
||||||
@@ -486,6 +525,7 @@ export function canonicalRequestToOpenAiChatBody(
|
|||||||
function: {
|
function: {
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
...(tool.description ? { description: tool.description } : {}),
|
...(tool.description ? { description: tool.description } : {}),
|
||||||
|
...(typeof tool.strict === 'boolean' ? { strict: tool.strict } : {}),
|
||||||
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
parameters: cloneJsonValue(tool.inputSchema ?? { type: 'object' }),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type CanonicalTool = {
|
export type CanonicalTool = {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
strict?: boolean;
|
||||||
inputSchema?: Record<string, unknown> | null;
|
inputSchema?: Record<string, unknown> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
it('emits canonical reasoning done events before response.completed when recovering sparse reasoning output', () => {
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
const streamContext = createStreamTransformContext('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]',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const outputTextDoneMarker = Symbol('responses.output_text.done');
|
|||||||
const contentPartDoneMarker = Symbol('responses.content_part.done');
|
const contentPartDoneMarker = Symbol('responses.content_part.done');
|
||||||
const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.done');
|
const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.done');
|
||||||
const reasoningSummaryPartDoneMarker = Symbol('responses.reasoning_summary_part.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 = {
|
export type OpenAiResponsesAggregateState = {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
@@ -76,6 +78,7 @@ export type OpenAiResponsesAggregateState = {
|
|||||||
usageExtras: Record<string, unknown>;
|
usageExtras: Record<string, unknown>;
|
||||||
completed: boolean;
|
completed: boolean;
|
||||||
failed: boolean;
|
failed: boolean;
|
||||||
|
incomplete: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState {
|
export function createOpenAiResponsesAggregateState(modelName: string): OpenAiResponsesAggregateState {
|
||||||
@@ -93,6 +96,7 @@ export function createOpenAiResponsesAggregateState(modelName: string): OpenAiRe
|
|||||||
usageExtras: {},
|
usageExtras: {},
|
||||||
completed: false,
|
completed: false,
|
||||||
failed: false,
|
failed: false,
|
||||||
|
incomplete: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,7 +399,7 @@ function materializeResponse(
|
|||||||
streamContext: StreamTransformContext,
|
streamContext: StreamTransformContext,
|
||||||
usage: ResponsesUsageSummary,
|
usage: ResponsesUsageSummary,
|
||||||
responseTemplate?: Record<string, unknown> | null,
|
responseTemplate?: Record<string, unknown> | null,
|
||||||
statusOverride?: 'completed' | 'failed',
|
statusOverride?: 'completed' | 'failed' | 'incomplete',
|
||||||
): Record<string, unknown> {
|
): Record<string, unknown> {
|
||||||
const base = cloneRecord(responseTemplate) || {};
|
const base = cloneRecord(responseTemplate) || {};
|
||||||
const responseId = ensureResponseId(
|
const responseId = ensureResponseId(
|
||||||
@@ -417,7 +421,7 @@ function materializeResponse(
|
|||||||
...item,
|
...item,
|
||||||
status: status && status !== 'in_progress'
|
status: status && status !== 'in_progress'
|
||||||
? status
|
? status
|
||||||
: (state.failed ? 'failed' : 'completed'),
|
: (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -426,7 +430,7 @@ function materializeResponse(
|
|||||||
id: responseId,
|
id: responseId,
|
||||||
object: 'response',
|
object: 'response',
|
||||||
created_at: createdAt,
|
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,
|
model: asTrimmedString(base.model) || streamContext.model || state.modelName,
|
||||||
output,
|
output,
|
||||||
output_text: collectOutputText(state),
|
output_text: collectOutputText(state),
|
||||||
@@ -497,6 +501,9 @@ function applyOriginalResponsesPayload(
|
|||||||
...reasoningState.item,
|
...reasoningState.item,
|
||||||
...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)) {
|
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)) {
|
||||||
next.summary = reasoningState.item.summary;
|
next.summary = reasoningState.item.summary;
|
||||||
}
|
}
|
||||||
@@ -512,6 +519,9 @@ function applyOriginalResponsesPayload(
|
|||||||
...existing,
|
...existing,
|
||||||
...item,
|
...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)) {
|
if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) {
|
||||||
next.content = existing.content;
|
next.content = existing.content;
|
||||||
}
|
}
|
||||||
@@ -575,6 +585,7 @@ function applyOriginalResponsesPayload(
|
|||||||
);
|
);
|
||||||
if (eventType === 'response.function_call_arguments.done') {
|
if (eventType === 'response.function_call_arguments.done') {
|
||||||
entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? '');
|
entry.item.arguments = typeof payload.arguments === 'string' ? payload.arguments : String(payload.arguments ?? '');
|
||||||
|
markTerminalMarker(entry.item, functionCallArgumentsDoneMarker);
|
||||||
} else {
|
} else {
|
||||||
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
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') {
|
if (eventType === 'response.custom_tool_call_input.done') {
|
||||||
entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? '');
|
entry.item.input = typeof payload.input === 'string' ? payload.input : String(payload.input ?? '');
|
||||||
|
markTerminalMarker(entry.item, customToolInputDoneMarker);
|
||||||
} else {
|
} else {
|
||||||
entry.item.input = `${typeof entry.item.input === 'string' ? entry.item.input : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
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': {
|
case 'response.completed': {
|
||||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||||
|
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
||||||
state.completed = true;
|
state.completed = true;
|
||||||
const responsePayload = cloneRecord(payload.response);
|
const responsePayload = cloneRecord(payload.response);
|
||||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed');
|
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': {
|
case 'response.failed': {
|
||||||
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
mergeUsageExtras(state, payload.response && isRecord(payload.response) ? payload.response.usage : payload.usage);
|
||||||
|
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'failed');
|
||||||
state.failed = true;
|
state.failed = true;
|
||||||
const responsePayload = cloneRecord(payload.response);
|
const responsePayload = cloneRecord(payload.response);
|
||||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed');
|
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:
|
default:
|
||||||
mergeUsageExtras(state, payload.usage);
|
mergeUsageExtras(state, payload.usage);
|
||||||
@@ -803,7 +834,7 @@ function buildSyntheticToolEvents(
|
|||||||
|
|
||||||
function buildSyntheticTerminalItemDoneEvents(
|
function buildSyntheticTerminalItemDoneEvents(
|
||||||
state: OpenAiResponsesAggregateState,
|
state: OpenAiResponsesAggregateState,
|
||||||
status: 'completed' | 'failed',
|
status: 'completed' | 'failed' | 'incomplete',
|
||||||
): string[] {
|
): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
@@ -812,7 +843,7 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
if (!isRecord(item)) continue;
|
if (!isRecord(item)) continue;
|
||||||
|
|
||||||
const currentStatus = asTrimmedString(item.status).toLowerCase();
|
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 itemType = asTrimmedString(item.type).toLowerCase();
|
||||||
const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index);
|
const itemId = ensureOutputItemId(asTrimmedString(item.id), 'out', index);
|
||||||
@@ -820,10 +851,12 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
|
|
||||||
if (itemType === 'message') {
|
if (itemType === 'message') {
|
||||||
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
|
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
|
||||||
const part = content[0];
|
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
|
||||||
if (isRecord(part)) {
|
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 ?? '');
|
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', {
|
lines.push(serializeSse('response.output_text.done', {
|
||||||
type: 'response.output_text.done',
|
type: 'response.output_text.done',
|
||||||
output_index: index,
|
output_index: index,
|
||||||
@@ -837,7 +870,7 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
type: 'response.content_part.done',
|
type: 'response.content_part.done',
|
||||||
output_index: index,
|
output_index: index,
|
||||||
item_id: itemId,
|
item_id: itemId,
|
||||||
content_index: 0,
|
content_index: contentIndex,
|
||||||
part: cloneJson(part),
|
part: cloneJson(part),
|
||||||
}));
|
}));
|
||||||
markTerminalMarker(part, contentPartDoneMarker);
|
markTerminalMarker(part, contentPartDoneMarker);
|
||||||
@@ -845,15 +878,16 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
}
|
}
|
||||||
} else if (itemType === 'reasoning') {
|
} else if (itemType === 'reasoning') {
|
||||||
const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : [];
|
const summary = Array.isArray(item.summary) ? item.summary as AggregateOutputItem[] : [];
|
||||||
const part = summary[0];
|
for (let summaryIndex = 0; summaryIndex < summary.length; summaryIndex += 1) {
|
||||||
if (isRecord(part)) {
|
const part = summary[summaryIndex];
|
||||||
|
if (!isRecord(part)) continue;
|
||||||
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
const text = typeof part.text === 'string' ? part.text : String(part.text ?? '');
|
||||||
if (!hasTerminalMarker(part, reasoningSummaryTextDoneMarker)) {
|
if (!hasTerminalMarker(part, reasoningSummaryTextDoneMarker)) {
|
||||||
lines.push(serializeSse('response.reasoning_summary_text.done', {
|
lines.push(serializeSse('response.reasoning_summary_text.done', {
|
||||||
type: 'response.reasoning_summary_text.done',
|
type: 'response.reasoning_summary_text.done',
|
||||||
item_id: itemId,
|
item_id: itemId,
|
||||||
output_index: index,
|
output_index: index,
|
||||||
summary_index: 0,
|
summary_index: summaryIndex,
|
||||||
text,
|
text,
|
||||||
}));
|
}));
|
||||||
markTerminalMarker(part, reasoningSummaryTextDoneMarker);
|
markTerminalMarker(part, reasoningSummaryTextDoneMarker);
|
||||||
@@ -863,7 +897,7 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
type: 'response.reasoning_summary_part.done',
|
type: 'response.reasoning_summary_part.done',
|
||||||
item_id: itemId,
|
item_id: itemId,
|
||||||
output_index: index,
|
output_index: index,
|
||||||
summary_index: 0,
|
summary_index: summaryIndex,
|
||||||
part: cloneJson(part),
|
part: cloneJson(part),
|
||||||
}));
|
}));
|
||||||
markTerminalMarker(part, reasoningSummaryPartDoneMarker);
|
markTerminalMarker(part, reasoningSummaryPartDoneMarker);
|
||||||
@@ -873,26 +907,32 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
const callId = asTrimmedString(item.call_id) || itemId;
|
const callId = asTrimmedString(item.call_id) || itemId;
|
||||||
const name = asTrimmedString(item.name);
|
const name = asTrimmedString(item.name);
|
||||||
const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? '');
|
const argumentsText = typeof item.arguments === 'string' ? item.arguments : String(item.arguments ?? '');
|
||||||
lines.push(serializeSse('response.function_call_arguments.done', {
|
if (!hasTerminalMarker(item, functionCallArgumentsDoneMarker)) {
|
||||||
type: 'response.function_call_arguments.done',
|
lines.push(serializeSse('response.function_call_arguments.done', {
|
||||||
item_id: itemId,
|
type: 'response.function_call_arguments.done',
|
||||||
call_id: callId,
|
item_id: itemId,
|
||||||
output_index: index,
|
call_id: callId,
|
||||||
...(name ? { name } : {}),
|
output_index: index,
|
||||||
arguments: argumentsText,
|
...(name ? { name } : {}),
|
||||||
}));
|
arguments: argumentsText,
|
||||||
|
}));
|
||||||
|
markTerminalMarker(item, functionCallArgumentsDoneMarker);
|
||||||
|
}
|
||||||
} else if (itemType === 'custom_tool_call') {
|
} else if (itemType === 'custom_tool_call') {
|
||||||
const callId = asTrimmedString(item.call_id) || itemId;
|
const callId = asTrimmedString(item.call_id) || itemId;
|
||||||
const name = asTrimmedString(item.name);
|
const name = asTrimmedString(item.name);
|
||||||
const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? '');
|
const inputText = typeof item.input === 'string' ? item.input : String(item.input ?? '');
|
||||||
lines.push(serializeSse('response.custom_tool_call_input.done', {
|
if (!hasTerminalMarker(item, customToolInputDoneMarker)) {
|
||||||
type: 'response.custom_tool_call_input.done',
|
lines.push(serializeSse('response.custom_tool_call_input.done', {
|
||||||
item_id: itemId,
|
type: 'response.custom_tool_call_input.done',
|
||||||
call_id: callId,
|
item_id: itemId,
|
||||||
output_index: index,
|
call_id: callId,
|
||||||
...(name ? { name } : {}),
|
output_index: index,
|
||||||
input: inputText,
|
...(name ? { name } : {}),
|
||||||
}));
|
input: inputText,
|
||||||
|
}));
|
||||||
|
markTerminalMarker(item, customToolInputDoneMarker);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item.status = status;
|
item.status = status;
|
||||||
@@ -941,7 +981,7 @@ export function completeResponsesStream(
|
|||||||
streamContext: StreamTransformContext,
|
streamContext: StreamTransformContext,
|
||||||
usage: ResponsesUsageSummary,
|
usage: ResponsesUsageSummary,
|
||||||
): string[] {
|
): string[] {
|
||||||
if (state.failed || state.completed) {
|
if (state.failed || state.completed || state.incomplete) {
|
||||||
return [serializeDone()];
|
return [serializeDone()];
|
||||||
}
|
}
|
||||||
const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
const lines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
||||||
@@ -986,3 +1026,25 @@ export function failResponsesStream(
|
|||||||
serializeDone(),
|
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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export function buildResponsesCompatibilityBodies(
|
|||||||
if (instructions !== null) richCandidate.instructions = instructions;
|
if (instructions !== null) richCandidate.instructions = instructions;
|
||||||
|
|
||||||
const passthroughFields = [
|
const passthroughFields = [
|
||||||
|
'tools',
|
||||||
|
'tool_choice',
|
||||||
'parallel_tool_calls',
|
'parallel_tool_calls',
|
||||||
'include',
|
'include',
|
||||||
'reasoning',
|
'reasoning',
|
||||||
@@ -228,6 +230,8 @@ function buildStrictResponsesBody(
|
|||||||
model,
|
model,
|
||||||
input: body.input,
|
input: body.input,
|
||||||
stream: body.stream === true,
|
stream: body.stream === true,
|
||||||
|
...(body.tools !== undefined ? { tools: cloneJsonValue(body.tools) } : {}),
|
||||||
|
...(body.tool_choice !== undefined ? { tool_choice: cloneJsonValue(body.tool_choice) } : {}),
|
||||||
...(explicitInstructions !== null
|
...(explicitInstructions !== null
|
||||||
? { instructions: explicitInstructions }
|
? { instructions: explicitInstructions }
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -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', () => {
|
it('maps Responses text.format back into OpenAI response_format', () => {
|
||||||
const result = convertResponsesBodyToOpenAiBody(
|
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', () => {
|
it('normalizes field parity when converting Responses input back to OpenAI-compatible input', () => {
|
||||||
const result = convertResponsesBodyToOpenAiBody(
|
const result = convertResponsesBodyToOpenAiBody(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -179,6 +179,20 @@ function normalizeResponsesRequestFieldParity(
|
|||||||
normalized.text = textConfig;
|
normalized.text = textConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isRecord(normalized.reasoning)) {
|
||||||
|
const reasoning: Record<string, unknown> = {};
|
||||||
|
const effort = normalizeOptionalTrimmedString((normalized as Record<string, unknown>).reasoning_effort);
|
||||||
|
if (effort) reasoning.effort = effort;
|
||||||
|
const budgetTokens = toFiniteIntegerLike((normalized as Record<string, unknown>).reasoning_budget);
|
||||||
|
if (budgetTokens !== null) reasoning.budget_tokens = budgetTokens;
|
||||||
|
const summary = normalizeOptionalTrimmedString((normalized as Record<string, unknown>).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;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +462,28 @@ export function convertOpenAiBodyToResponsesBody(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (role === 'assistant') {
|
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<string, unknown> = {
|
||||||
|
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);
|
const normalizedContent = normalizeResponsesMessageContent('assistant', item.content);
|
||||||
if (normalizedContent.length > 0) {
|
if (normalizedContent.length > 0) {
|
||||||
inputItems.push({
|
inputItems.push({
|
||||||
@@ -527,7 +563,12 @@ export function convertOpenAiBodyToResponsesBody(
|
|||||||
if (topP !== null) body.top_p = topP;
|
if (topP !== null) body.top_p = topP;
|
||||||
|
|
||||||
if (openaiBody.metadata !== undefined) body.metadata = openaiBody.metadata;
|
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 !== 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.parallel_tool_calls !== undefined) body.parallel_tool_calls = openaiBody.parallel_tool_calls;
|
||||||
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
|
if (openaiBody.tools !== undefined) body.tools = convertOpenAiToolsToResponses(openaiBody.tools, toolNameMap);
|
||||||
if (openaiBody.safety_identifier !== undefined) body.safety_identifier = openaiBody.safety_identifier;
|
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;
|
if (!isRecord(rawToolChoice)) return rawToolChoice;
|
||||||
|
|
||||||
const type = asTrimmedString(rawToolChoice.type).toLowerCase();
|
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 (type === 'function') {
|
||||||
if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) {
|
if (isRecord(rawToolChoice.function) && asTrimmedString(rawToolChoice.function.name)) {
|
||||||
return rawToolChoice;
|
return rawToolChoice;
|
||||||
@@ -845,6 +894,9 @@ export function convertResponsesBodyToOpenAiBody(
|
|||||||
if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) {
|
if (typeof normalizedBody.max_output_tokens === 'number' && Number.isFinite(normalizedBody.max_output_tokens)) {
|
||||||
payload.max_tokens = 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.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls;
|
||||||
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
|
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
|
||||||
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
|
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
|
||||||
|
|||||||
@@ -109,6 +109,47 @@ describe('serializeResponsesFinalPayload', () => {
|
|||||||
expect(payload).not.toHaveProperty('output_text');
|
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', () => {
|
it('preserves top-level chat annotations on synthetic assistant messages', () => {
|
||||||
const payload = serializeResponsesFinalPayload({
|
const payload = serializeResponsesFinalPayload({
|
||||||
upstreamPayload: {
|
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: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,6 +220,7 @@ function extractSyntheticOutputItemsFromUpstream(payload: unknown): ResponsesOut
|
|||||||
result: cloneJson(rawItem.result),
|
result: cloneJson(rawItem.result),
|
||||||
partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [],
|
partial_images: Array.isArray(rawItem.partial_images) ? cloneJson(rawItem.partial_images) : [],
|
||||||
...(rawItem.background !== undefined ? { background: cloneJson(rawItem.background) } : {}),
|
...(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.output_format !== undefined ? { output_format: cloneJson(rawItem.output_format) } : {}),
|
||||||
...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}),
|
...(rawItem.quality !== undefined ? { quality: cloneJson(rawItem.quality) } : {}),
|
||||||
...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}),
|
...(rawItem.size !== undefined ? { size: cloneJson(rawItem.size) } : {}),
|
||||||
@@ -418,7 +419,7 @@ export function serializeResponsesFinalPayload(input: {
|
|||||||
return {
|
return {
|
||||||
id: responseId,
|
id: responseId,
|
||||||
object: 'response',
|
object: 'response',
|
||||||
created: normalized.created,
|
created_at: normalized.created,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
model: normalized.model,
|
model: normalized.model,
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -69,4 +69,193 @@ describe('createResponsesProxyStreamSession', () => {
|
|||||||
expect(output).toContain('"output_text":"hello from responses upstream"');
|
expect(output).toContain('"output_text":"hello from responses upstream"');
|
||||||
expect(output).toContain('data: [DONE]');
|
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]');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
|| input.successfulUpstreamPath.endsWith('/responses')
|
|| input.successfulUpstreamPath.endsWith('/responses')
|
||||||
|| input.successfulUpstreamPath.endsWith('/responses/compact');
|
|| input.successfulUpstreamPath.endsWith('/responses/compact');
|
||||||
let finalized = false;
|
let finalized = false;
|
||||||
|
let terminalEventSeen = false;
|
||||||
let terminalResult: ResponsesProxyStreamResult = {
|
let terminalResult: ResponsesProxyStreamResult = {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -145,8 +146,6 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
};
|
};
|
||||||
|
|
||||||
const complete = () => {
|
const complete = () => {
|
||||||
if (finalized) return;
|
|
||||||
finalized = true;
|
|
||||||
terminalResult = {
|
terminalResult = {
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
@@ -155,6 +154,10 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
|
|
||||||
const closeOut = () => {
|
const closeOut = () => {
|
||||||
if (finalized) return;
|
if (finalized) return;
|
||||||
|
if (terminalEventSeen) {
|
||||||
|
finalize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (requiresExplicitTerminalEvent) {
|
if (requiresExplicitTerminalEvent) {
|
||||||
fail({
|
fail({
|
||||||
type: 'response.failed',
|
type: 'response.failed',
|
||||||
@@ -190,15 +193,14 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
const isFailureEvent = (
|
const isFailureEvent = (
|
||||||
eventBlock.event === 'error'
|
eventBlock.event === 'error'
|
||||||
|| eventBlock.event === 'response.failed'
|
|| eventBlock.event === 'response.failed'
|
||||||
|| eventBlock.event === 'response.incomplete'
|
|
||||||
|| payloadType === 'error'
|
|| payloadType === 'error'
|
||||||
|| payloadType === 'response.failed'
|
|| payloadType === 'response.failed'
|
||||||
|| payloadType === 'response.incomplete'
|
|
||||||
);
|
);
|
||||||
if (isFailureEvent) {
|
if (isFailureEvent) {
|
||||||
fail(parsedPayload);
|
fail(parsedPayload);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
const isIncompleteEvent = eventBlock.event === 'response.incomplete' || payloadType === 'response.incomplete';
|
||||||
|
|
||||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
if (parsedPayload && typeof parsedPayload === 'object') {
|
||||||
const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName);
|
const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName);
|
||||||
@@ -225,7 +227,8 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
input.writeLines(convertedLines);
|
input.writeLines(convertedLines);
|
||||||
if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') {
|
if (eventBlock.event === 'response.completed' || payloadType === 'response.completed' || isIncompleteEvent) {
|
||||||
|
terminalEventSeen = true;
|
||||||
complete();
|
complete();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -249,6 +252,9 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
const payloadType = (isRecord(payload) && typeof payload.type === 'string')
|
const payloadType = (isRecord(payload) && typeof payload.type === 'string')
|
||||||
? payload.type
|
? payload.type
|
||||||
: '';
|
: '';
|
||||||
|
const payloadStatus = isRecord(payload) && typeof payload.status === 'string'
|
||||||
|
? payload.status
|
||||||
|
: '';
|
||||||
if (payloadType === 'error' || payloadType === 'response.failed') {
|
if (payloadType === 'error' || payloadType === 'response.failed') {
|
||||||
fail(payload);
|
fail(payload);
|
||||||
response?.end();
|
response?.end();
|
||||||
@@ -266,7 +272,8 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
usage: input.getUsage(),
|
usage: input.getUsage(),
|
||||||
serializationMode: 'response',
|
serializationMode: 'response',
|
||||||
});
|
});
|
||||||
if (shouldFailEmptyResponsesCompletion({
|
const isIncompletePayload = payloadType === 'response.incomplete' || payloadStatus === 'incomplete';
|
||||||
|
if (!isIncompletePayload && shouldFailEmptyResponsesCompletion({
|
||||||
payload: { type: 'response.completed', response: streamPayload },
|
payload: { type: 'response.completed', response: streamPayload },
|
||||||
state: responsesState,
|
state: responsesState,
|
||||||
usage: input.getUsage(),
|
usage: input.getUsage(),
|
||||||
@@ -287,16 +294,29 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
output_text: '',
|
output_text: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
finalized = true;
|
if (isIncompletePayload) {
|
||||||
terminalResult = {
|
finalized = true;
|
||||||
status: 'completed',
|
terminalResult = {
|
||||||
errorMessage: null,
|
status: 'completed',
|
||||||
};
|
errorMessage: null,
|
||||||
input.writeLines([
|
};
|
||||||
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
input.writeLines([
|
||||||
`event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response: streamPayload })}\n\n`,
|
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||||
'data: [DONE]\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();
|
response?.end();
|
||||||
return terminalResult;
|
return terminalResult;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export const openAiResponsesStream = {
|
|||||||
'response.image_generation_call.partial_image',
|
'response.image_generation_call.partial_image',
|
||||||
'response.image_generation_call.completed',
|
'response.image_generation_call.completed',
|
||||||
'response.completed',
|
'response.completed',
|
||||||
|
'response.incomplete',
|
||||||
'response.failed',
|
'response.failed',
|
||||||
] as const,
|
] as const,
|
||||||
createContext(modelName: string): StreamTransformContext {
|
createContext(modelName: string): StreamTransformContext {
|
||||||
|
|||||||
@@ -437,6 +437,34 @@ function parseResponsesOutputText(payload: Record<string, unknown>): string {
|
|||||||
return parts.join('\n\n');
|
return parts.join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseResponsesReasoning(payload: Record<string, unknown>): {
|
||||||
|
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 {
|
function stringifyUnknownValue(value: unknown): string {
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return value;
|
||||||
if (value === undefined || value === null) return '';
|
if (value === undefined || value === null) return '';
|
||||||
@@ -658,6 +686,98 @@ function collapseOpenAiContentBlocks(
|
|||||||
return text || null;
|
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<string, unknown>,
|
||||||
|
): { 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<string, unknown>): {
|
function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
||||||
model: string;
|
model: string;
|
||||||
stream: boolean;
|
stream: boolean;
|
||||||
@@ -717,6 +837,7 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
|||||||
// - user.tool_result -> tool messages with tool_call_id
|
// - user.tool_result -> tool messages with tool_call_id
|
||||||
const contentBlocks: Array<Record<string, unknown>> = [];
|
const contentBlocks: Array<Record<string, unknown>> = [];
|
||||||
const toolCalls: Array<Record<string, unknown>> = [];
|
const toolCalls: Array<Record<string, unknown>> = [];
|
||||||
|
const reasoningParts: string[] = [];
|
||||||
|
|
||||||
const flushContentAsMessage = () => {
|
const flushContentAsMessage = () => {
|
||||||
const contentPayload = collapseOpenAiContentBlocks(contentBlocks);
|
const contentPayload = collapseOpenAiContentBlocks(contentBlocks);
|
||||||
@@ -730,7 +851,6 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
|||||||
const blockType = typeof block.type === 'string' ? block.type : '';
|
const blockType = typeof block.type === 'string' ? block.type : '';
|
||||||
|
|
||||||
if (blockType === 'tool_result') {
|
if (blockType === 'tool_result') {
|
||||||
flushContentAsMessage();
|
|
||||||
appendToolResultMessage(block.tool_use_id, block.content);
|
appendToolResultMessage(block.tool_use_id, block.content);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -758,13 +878,18 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extracted = extractTextAndReasoning(block);
|
||||||
|
if (mappedRole === 'assistant' && extracted.reasoning) {
|
||||||
|
reasoningParts.push(extracted.reasoning);
|
||||||
|
}
|
||||||
|
|
||||||
const contentBlock = toOpenAiContentBlockFromClaudeBlock(block);
|
const contentBlock = toOpenAiContentBlockFromClaudeBlock(block);
|
||||||
if (contentBlock) {
|
if (contentBlock) {
|
||||||
contentBlocks.push(contentBlock);
|
contentBlocks.push(contentBlock);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = parseClaudeMessageContent(block);
|
const text = extracted.content || parseClaudeMessageContent(block);
|
||||||
if (text) {
|
if (text) {
|
||||||
contentBlocks.push({
|
contentBlocks.push({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@@ -780,11 +905,20 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
|||||||
tool_calls: toolCalls,
|
tool_calls: toolCalls,
|
||||||
};
|
};
|
||||||
assistantMessage.content = merged || '';
|
assistantMessage.content = merged || '';
|
||||||
|
const reasoningContent = joinNonEmpty(reasoningParts);
|
||||||
|
if (reasoningContent) assistantMessage.reasoning_content = reasoningContent;
|
||||||
messages.push(assistantMessage);
|
messages.push(assistantMessage);
|
||||||
} else if (merged !== null) {
|
} else if (merged !== null || (mappedRole === 'assistant' && reasoningParts.length > 0)) {
|
||||||
messages.push({
|
const nextMessage: Record<string, unknown> = {
|
||||||
role: mappedRole,
|
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<string, unknown>): {
|
|||||||
const topP = pickFiniteNumber(body.top_p);
|
const topP = pickFiniteNumber(body.top_p);
|
||||||
if (topP !== undefined) payload.top_p = topP;
|
if (topP !== undefined) payload.top_p = topP;
|
||||||
|
|
||||||
|
if (isRecord(body.metadata)) payload.metadata = body.metadata;
|
||||||
|
|
||||||
const maxTokens = pickFiniteNumber(body.max_tokens);
|
const maxTokens = pickFiniteNumber(body.max_tokens);
|
||||||
if (maxTokens !== undefined) {
|
if (maxTokens !== undefined) {
|
||||||
payload.max_tokens = maxTokens;
|
payload.max_tokens = maxTokens;
|
||||||
@@ -812,8 +948,12 @@ function convertClaudeRequestToOpenAiBody(body: Record<string, unknown>): {
|
|||||||
payload.stop = body.stop_sequences;
|
payload.stop = body.stop_sequences;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (body.tools !== undefined) payload.tools = body.tools;
|
const reasoningRequest = extractClaudeReasoningRequest(body);
|
||||||
if (body.tool_choice !== undefined) payload.tool_choice = body.tool_choice;
|
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 };
|
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))) {
|
if (isRecord(payload) && ((payload as any).object === 'response' || Array.isArray((payload as any).output))) {
|
||||||
const toolCalls = collectToolCallsFromResponsesPayload(payload);
|
const toolCalls = collectToolCallsFromResponsesPayload(payload);
|
||||||
|
const responsesReasoning = parseResponsesReasoning(payload);
|
||||||
return {
|
return {
|
||||||
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
id: isNonEmptyString(payload.id) ? payload.id : fallbackId,
|
||||||
model: isNonEmptyString(payload.model) ? payload.model : fallbackModel,
|
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),
|
content: parseResponsesOutputText(payload) || (toolCalls.length > 0 ? '' : fallbackText),
|
||||||
reasoningContent: '',
|
reasoningContent: responsesReasoning.reasoningContent,
|
||||||
|
...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}),
|
||||||
finishReason: toolCalls.length > 0
|
finishReason: toolCalls.length > 0
|
||||||
? 'tool_calls'
|
? 'tool_calls'
|
||||||
: (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'),
|
: (normalizeStopReason(payload.finish_reason ?? payload.status) || 'stop'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
fromTransformerMetadataRecord,
|
fromTransformerMetadataRecord,
|
||||||
normalizeStopReason,
|
normalizeStopReason,
|
||||||
normalizeUpstreamFinalResponse,
|
normalizeUpstreamFinalResponse,
|
||||||
|
parseDownstreamChatRequest,
|
||||||
pullSseEventsWithDone,
|
pullSseEventsWithDone,
|
||||||
serializeFinalResponse,
|
serializeFinalResponse,
|
||||||
toTransformerMetadataRecord,
|
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', () => {
|
it('serializes normalized final responses for claude', () => {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
id: 'chatcmpl-1',
|
id: 'chatcmpl-1',
|
||||||
|
|||||||
Reference in New Issue
Block a user