forked from boos/BoosAPI
fix responses lifecycle parity (#272)
* fix responses lifecycle parity * remove websocket test debug logging * fix responses proxy stream typecheck * update compact upstream response expectations * add community and deepwiki badges * update linuxdo banner link * fix responses review follow-ups * preserve whitespace in responses terminal deltas * fix responses stream reconciliation edges --------- Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
@@ -10,6 +10,12 @@
|
|||||||
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
汇聚成 <strong>一个 API Key、一个入口</strong>,自动发现模型、智能路由、成本最优。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://linux.do/t/topic/1671489" alt="LINUX DO">
|
||||||
|
<img src="https://shorturl.at/ggSqS" alt="LINUX DO">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/cita-777/metapi/releases">
|
<a href="https://github.com/cita-777/metapi/releases">
|
||||||
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/cita-777/metapi?label=Release&logo=github&style=flat">
|
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/cita-777/metapi?label=Release&logo=github&style=flat">
|
||||||
@@ -17,6 +23,9 @@
|
|||||||
--><a href="https://github.com/cita-777/metapi/stargazers">
|
--><a href="https://github.com/cita-777/metapi/stargazers">
|
||||||
<img alt="GitHub Stars" src="https://img.shields.io/github/stars/cita-777/metapi?style=flat&logo=github&label=Stars">
|
<img alt="GitHub Stars" src="https://img.shields.io/github/stars/cita-777/metapi?style=flat&logo=github&label=Stars">
|
||||||
</a><!--
|
</a><!--
|
||||||
|
--><a href="https://deepwiki.com/cita-777/metapi">
|
||||||
|
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg">
|
||||||
|
</a><!--
|
||||||
--><a href="https://hub.docker.com/r/1467078763/metapi">
|
--><a href="https://hub.docker.com/r/1467078763/metapi">
|
||||||
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/1467078763/metapi?style=flat&logo=docker&label=Docker%20Pulls">
|
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/1467078763/metapi?style=flat&logo=docker&label=Docker%20Pulls">
|
||||||
</a><!--
|
</a><!--
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ Bring together all your New API / One API / OneHub / DoneHub / Veloera / AnyRout
|
|||||||
into <strong>one API Key, one endpoint</strong>, with automatic model discovery, smart routing, and cost optimization.
|
into <strong>one API Key, one endpoint</strong>, with automatic model discovery, smart routing, and cost optimization.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://linux.do/t/topic/1671489" alt="LINUX DO">
|
||||||
|
<img src="https://shorturl.at/ggSqS" alt="LINUX DO">
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/cita-777/metapi/releases">
|
<a href="https://github.com/cita-777/metapi/releases">
|
||||||
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/cita-777/metapi?label=Release&logo=github&style=flat">
|
<img alt="GitHub Release" src="https://img.shields.io/github/v/release/cita-777/metapi?label=Release&logo=github&style=flat">
|
||||||
@@ -17,6 +23,9 @@ into <strong>one API Key, one endpoint</strong>, with automatic model discovery,
|
|||||||
--><a href="https://github.com/cita-777/metapi/stargazers">
|
--><a href="https://github.com/cita-777/metapi/stargazers">
|
||||||
<img alt="GitHub Stars" src="https://img.shields.io/github/stars/cita-777/metapi?style=flat&logo=github&label=Stars">
|
<img alt="GitHub Stars" src="https://img.shields.io/github/stars/cita-777/metapi?style=flat&logo=github&label=Stars">
|
||||||
</a><!--
|
</a><!--
|
||||||
|
--><a href="https://deepwiki.com/cita-777/metapi">
|
||||||
|
<img alt="Ask DeepWiki" src="https://deepwiki.com/badge.svg">
|
||||||
|
</a><!--
|
||||||
--><a href="https://hub.docker.com/r/1467078763/metapi">
|
--><a href="https://hub.docker.com/r/1467078763/metapi">
|
||||||
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/1467078763/metapi?style=flat&logo=docker&label=Docker%20Pulls">
|
<img alt="Docker Pulls" src="https://img.shields.io/docker/pulls/1467078763/metapi?style=flat&logo=docker&label=Docker%20Pulls">
|
||||||
</a><!--
|
</a><!--
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isResponsesWebsocketTransportRequest(headers: Record<string, unknown>): boolean {
|
||||||
|
return Object.entries(headers)
|
||||||
|
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
|
||||||
|
&& String(rawValue).trim() === '1');
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeIncludeList(value: unknown): string[] {
|
function normalizeIncludeList(value: unknown): string[] {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -485,12 +491,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
const writeLines = (lines: string[]) => {
|
const writeLines = (lines: string[]) => {
|
||||||
for (const line of lines) reply.raw.write(line);
|
for (const line of lines) reply.raw.write(line);
|
||||||
};
|
};
|
||||||
|
const websocketTransportRequest = isResponsesWebsocketTransportRequest(request.headers as Record<string, unknown>);
|
||||||
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
|
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
|
||||||
modelName,
|
modelName,
|
||||||
successfulUpstreamPath,
|
successfulUpstreamPath,
|
||||||
strictTerminalEvents: Object.entries(request.headers as Record<string, unknown>)
|
|
||||||
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
|
|
||||||
&& String(rawValue).trim() === '1'),
|
|
||||||
getUsage: () => parsedUsage,
|
getUsage: () => parsedUsage,
|
||||||
onParsedPayload: (payload) => {
|
onParsedPayload: (payload) => {
|
||||||
if (payload && typeof payload === 'object') {
|
if (payload && typeof payload === 'object') {
|
||||||
@@ -608,7 +612,65 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
|
|
||||||
startSseResponse();
|
startSseResponse();
|
||||||
|
|
||||||
const upstreamReader = upstream.body?.getReader();
|
let replayReader: ReturnType<typeof createSingleChunkStreamReader> | null = null;
|
||||||
|
if (websocketTransportRequest) {
|
||||||
|
const rawText = await upstream.text();
|
||||||
|
if (looksLikeResponsesSseText(rawText)) {
|
||||||
|
try {
|
||||||
|
const collectedPayload = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
||||||
|
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(collectedPayload));
|
||||||
|
const createdPayload = {
|
||||||
|
...collectedPayload,
|
||||||
|
status: 'in_progress',
|
||||||
|
output: [],
|
||||||
|
output_text: '',
|
||||||
|
};
|
||||||
|
const terminalEventType = String(collectedPayload.status || '').trim().toLowerCase() === 'incomplete'
|
||||||
|
? 'response.incomplete'
|
||||||
|
: 'response.completed';
|
||||||
|
writeLines([
|
||||||
|
`event: response.created\ndata: ${JSON.stringify({ type: 'response.created', response: createdPayload })}\n\n`,
|
||||||
|
`event: ${terminalEventType}\ndata: ${JSON.stringify({ type: terminalEventType, response: collectedPayload })}\n\n`,
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]);
|
||||||
|
reply.raw.end();
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
await finalizeStreamSuccess(parsedUsage, latency);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Fall through to the generic stream session for response.failed/error terminals.
|
||||||
|
}
|
||||||
|
|
||||||
|
const streamResult = await streamSession.run(
|
||||||
|
createSingleChunkStreamReader(rawText),
|
||||||
|
reply.raw,
|
||||||
|
);
|
||||||
|
const latency = Date.now() - startTime;
|
||||||
|
if (streamResult.status === 'failed') {
|
||||||
|
await failureToolkit.recordStreamFailure({
|
||||||
|
selected,
|
||||||
|
requestedModel,
|
||||||
|
modelName,
|
||||||
|
errorMessage: streamResult.errorMessage,
|
||||||
|
latencyMs: latency,
|
||||||
|
retryCount,
|
||||||
|
promptTokens: parsedUsage.promptTokens,
|
||||||
|
completionTokens: parsedUsage.completionTokens,
|
||||||
|
totalTokens: parsedUsage.totalTokens,
|
||||||
|
upstreamPath: successfulUpstreamPath,
|
||||||
|
runtimeFailureStatus: 502,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await finalizeStreamSuccess(parsedUsage, latency);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
replayReader = createSingleChunkStreamReader(rawText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamReader = replayReader ?? upstream.body?.getReader();
|
||||||
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
const baseReader = String(selected.site.platform || '').trim().toLowerCase() === 'gemini-cli' && upstreamReader
|
||||||
? createGeminiCliStreamReader(upstreamReader)
|
? createGeminiCliStreamReader(upstreamReader)
|
||||||
: upstreamReader;
|
: upstreamReader;
|
||||||
|
|||||||
@@ -1154,6 +1154,112 @@ describe('chat proxy stream behavior', () => {
|
|||||||
expect(response.body).toContain('[DONE]');
|
expect(response.body).toContain('[DONE]');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('replays downgraded chat-completions SSE for websocket transport without requiring native responses terminals', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
supportedEndpointTypes: ['/v1/chat/completions'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-ws","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-ws","model":"upstream-gpt","choices":[{"delta":{"content":"hello from fallback"},"finish_reason":null}]}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-ws","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
headers: {
|
||||||
|
'x-metapi-responses-websocket-transport': '1',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(response.body).toContain('response.output_item.added');
|
||||||
|
expect(response.body).toContain('response.output_text.delta');
|
||||||
|
expect(response.body).toContain('response.completed');
|
||||||
|
expect(response.body).not.toContain('response.failed');
|
||||||
|
expect(response.body).toContain('[DONE]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes reasoning items before emitting reasoning summary deltas on /v1/responses streams', async () => {
|
||||||
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
modelName: 'upstream-gpt',
|
||||||
|
supportedEndpointTypes: ['/v1/chat/completions'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupRatio: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const upstreamBody = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-reasoning","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-reasoning","model":"upstream-gpt","choices":[{"delta":{"reasoning_content":"plan first"},"finish_reason":null}]}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-r1-reasoning","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'));
|
||||||
|
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchMock.mockResolvedValue(new Response(upstreamBody, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'text/event-stream; charset=utf-8' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2',
|
||||||
|
input: 'hello',
|
||||||
|
stream: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||||
|
expect(response.body).toContain('event: response.output_item.added');
|
||||||
|
expect(response.body).toContain('event: response.reasoning_summary_part.added');
|
||||||
|
expect(response.body).toContain('event: response.reasoning_summary_text.delta');
|
||||||
|
const eventBlocks = response.body.split('\n\n').filter((block) => block.trim().length > 0);
|
||||||
|
const reasoningItemAddedIndex = eventBlocks.findIndex(
|
||||||
|
(block) => block.includes('event: response.output_item.added') && block.includes('"type":"reasoning"'),
|
||||||
|
);
|
||||||
|
const reasoningSummaryPartAddedIndex = eventBlocks.findIndex(
|
||||||
|
(block) => block.includes('event: response.reasoning_summary_part.added'),
|
||||||
|
);
|
||||||
|
const reasoningSummaryTextDeltaIndex = eventBlocks.findIndex(
|
||||||
|
(block) => block.includes('event: response.reasoning_summary_text.delta'),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reasoningItemAddedIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(reasoningItemAddedIndex).toBeLessThan(reasoningSummaryPartAddedIndex);
|
||||||
|
expect(reasoningSummaryPartAddedIndex).toBeLessThan(reasoningSummaryTextDeltaIndex);
|
||||||
|
});
|
||||||
|
|
||||||
it('converts chat tool_calls SSE to Responses function_call events on /v1/responses', async () => {
|
it('converts chat tool_calls SSE to Responses function_call events on /v1/responses', async () => {
|
||||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||||
models: [
|
models: [
|
||||||
|
|||||||
@@ -279,7 +279,8 @@ describe('responses proxy compact upstream routing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(response.json()).toEqual({
|
const body = response.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
id: 'cmp_sse_123',
|
id: 'cmp_sse_123',
|
||||||
object: 'response.compaction',
|
object: 'response.compaction',
|
||||||
input_tokens: 12,
|
input_tokens: 12,
|
||||||
@@ -292,6 +293,12 @@ describe('responses proxy compact upstream routing', () => {
|
|||||||
encrypted_content: 'enc-from-sse',
|
encrypted_content: 'enc-from-sse',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 12,
|
||||||
|
output_tokens: 3,
|
||||||
|
total_tokens: 15,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
expect(body.created_at).toEqual(expect.any(Number));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -534,7 +534,40 @@ describe('responses websocket transport', () => {
|
|||||||
|
|
||||||
const socket = createClientSocket(baseUrl);
|
const socket = createClientSocket(baseUrl);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
const messagesPromise = waitForSocketMessages(socket, 1);
|
const messagesPromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const message = await messagesPromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(message?.type).toBe('response.completed');
|
||||||
|
expect(message?.response?.id).toBe('resp_http_fallback');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats response.incomplete as a terminal HTTP fallback payload without appending websocket error', async () => {
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
siteUrl: rejectedUpgradeSiteUrl,
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
fetchMock.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.incomplete\n',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_http_incomplete","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"output_text":"partial","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const messagesPromise = waitForSocketMessages(socket, 2);
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
@@ -546,8 +579,13 @@ describe('responses websocket transport', () => {
|
|||||||
socket.close();
|
socket.close();
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
|
expect(messages.map((message) => message?.type)).toEqual([
|
||||||
expect(messages[0]?.response?.id).toBe('resp_http_fallback');
|
'response.created',
|
||||||
|
'response.incomplete',
|
||||||
|
]);
|
||||||
|
expect(messages.some((message) => message?.type === 'error')).toBe(false);
|
||||||
|
const terminalMessage = messages[1];
|
||||||
|
expect(terminalMessage?.response?.incomplete_details?.reason).toBe('max_output_tokens');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 401', async () => {
|
it('falls back to the HTTP responses executor when the upstream codex websocket upgrade returns 401', async () => {
|
||||||
@@ -572,7 +610,10 @@ describe('responses websocket transport', () => {
|
|||||||
|
|
||||||
const socket = createClientSocket(baseUrl);
|
const socket = createClientSocket(baseUrl);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
const messagesPromise = waitForSocketMessages(socket, 1);
|
const messagesPromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
@@ -580,12 +621,58 @@ describe('responses websocket transport', () => {
|
|||||||
input: [],
|
input: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const messages = await messagesPromise;
|
const message = await messagesPromise;
|
||||||
socket.close();
|
socket.close();
|
||||||
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
|
expect(message?.type).toBe('response.completed');
|
||||||
expect(messages[0]?.response?.id).toBe('resp_http_fallback_401');
|
expect(message?.response?.id).toBe('resp_http_fallback_401');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats response.incomplete as a terminal HTTP fallback event instead of appending a websocket error', 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.incomplete\n',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"output_text":"partial","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const messagePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.incomplete',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const message = await messagePromise;
|
||||||
|
await expect(
|
||||||
|
waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(nextMessage) => nextMessage?.type === 'error',
|
||||||
|
150,
|
||||||
|
),
|
||||||
|
).rejects.toThrow('Timed out waiting for matching websocket message');
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(message?.response?.id).toBe('resp_http_incomplete');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves previous_response_id when websocket upgrade fallback uses HTTP on incremental-capable upstreams', async () => {
|
it('preserves previous_response_id when websocket upgrade fallback uses HTTP on incremental-capable upstreams', async () => {
|
||||||
@@ -617,7 +704,10 @@ describe('responses websocket transport', () => {
|
|||||||
|
|
||||||
const socket = createClientSocket(baseUrl);
|
const socket = createClientSocket(baseUrl);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
const firstResponsePromise = waitForSocketMessages(socket, 1);
|
const firstResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
@@ -626,8 +716,11 @@ describe('responses websocket transport', () => {
|
|||||||
input: [],
|
input: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const firstMessages = await firstResponsePromise;
|
const firstMessage = await firstResponsePromise;
|
||||||
const secondResponsePromise = waitForSocketMessages(socket, 1);
|
const secondResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_fallback_2',
|
||||||
|
);
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
@@ -645,7 +738,7 @@ describe('responses websocket transport', () => {
|
|||||||
await secondResponsePromise;
|
await secondResponsePromise;
|
||||||
socket.close();
|
socket.close();
|
||||||
|
|
||||||
expect(firstMessages[0]?.type).toBe('response.completed');
|
expect(firstMessage?.type).toBe('response.completed');
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
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));
|
||||||
@@ -660,6 +753,181 @@ describe('responses websocket transport', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('carries forward incomplete-turn output into the next fallback websocket request input', 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.incomplete\n',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete_1","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_http_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"carry me"}]}],"output_text":"carry me","incomplete_details":{"reason":"max_output_tokens"},"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_incomplete_2","model":"gpt-5.4","status":"completed","output":[],"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 = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.incomplete',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const firstMessage = await firstResponsePromise;
|
||||||
|
const secondResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'continue' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await secondResponsePromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(firstMessage?.type).toBe('response.incomplete');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
|
const secondBody = JSON.parse(String(secondOptions.body));
|
||||||
|
expect(secondBody.input).toHaveLength(2);
|
||||||
|
expect(secondBody.input[0]).toMatchObject({
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [{ type: 'output_text', text: 'carry me' }],
|
||||||
|
});
|
||||||
|
expect(secondBody.input[1]).toEqual({
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'continue' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries forward terminal output from incomplete HTTP fallback turns on non-incremental upstreams', async () => {
|
||||||
|
rejectedUpgradeStatus = 426;
|
||||||
|
rejectedUpgradeStatusText = 'Upgrade Required';
|
||||||
|
rejectedUpgradeBody = JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'upgrade required',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
siteUrl: rejectedUpgradeSiteUrl,
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.incomplete\n',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_http_incomplete_1","model":"gpt-4.1","status":"incomplete","output":[{"id":"msg_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial tool call"}]}],"output_text":"partial tool call","incomplete_details":{"reason":"max_output_tokens"},"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_complete_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"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 = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.incomplete',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
instructions: 'be helpful',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
id: 'msg_user_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
await firstResponsePromise;
|
||||||
|
|
||||||
|
const secondResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
previous_response_id: 'resp_http_incomplete_1',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
id: 'tool_out_1',
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call_1',
|
||||||
|
output: 'tool result',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
await secondResponsePromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
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).toBeUndefined();
|
||||||
|
expect(secondBody.model).toBe('gpt-4.1');
|
||||||
|
expect(secondBody.instructions).toBe('be helpful');
|
||||||
|
expect(secondBody.input).toHaveLength(3);
|
||||||
|
expect(secondBody.input[0]).toEqual({
|
||||||
|
id: 'msg_user_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||||
|
});
|
||||||
|
expect(secondBody.input[1]).toMatchObject({
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [{ type: 'output_text', text: 'partial tool call' }],
|
||||||
|
});
|
||||||
|
expect(secondBody.input[2]).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';
|
||||||
@@ -770,7 +1038,10 @@ describe('responses websocket transport', () => {
|
|||||||
const socket = createClientSocket(baseUrl);
|
const socket = createClientSocket(baseUrl);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
|
|
||||||
const firstResponsePromise = waitForSocketMessages(socket, 1);
|
const firstResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
model: 'gpt-4.1',
|
model: 'gpt-4.1',
|
||||||
@@ -786,7 +1057,10 @@ describe('responses websocket transport', () => {
|
|||||||
}));
|
}));
|
||||||
await firstResponsePromise;
|
await firstResponsePromise;
|
||||||
|
|
||||||
const secondResponsePromise = waitForSocketMessages(socket, 1);
|
const secondResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
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',
|
||||||
@@ -812,33 +1086,116 @@ describe('responses websocket transport', () => {
|
|||||||
expect(secondBody.previous_response_id).toBeUndefined();
|
expect(secondBody.previous_response_id).toBeUndefined();
|
||||||
expect(secondBody.model).toBe('gpt-4.1');
|
expect(secondBody.model).toBe('gpt-4.1');
|
||||||
expect(secondBody.instructions).toBe('be helpful');
|
expect(secondBody.instructions).toBe('be helpful');
|
||||||
expect(secondBody.input).toEqual([
|
expect(secondBody.input).toHaveLength(4);
|
||||||
|
expect(secondBody.input[0]).toEqual({
|
||||||
|
id: 'msg_user_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||||
|
});
|
||||||
|
expect(secondBody.input[1]).toMatchObject({
|
||||||
|
id: 'fc_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
});
|
||||||
|
expect(secondBody.input[2]).toEqual({
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'call tool' }],
|
||||||
|
});
|
||||||
|
expect(secondBody.input[3]).toEqual({
|
||||||
|
id: 'tool_out_1',
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call_1',
|
||||||
|
output: 'tool result',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps streamed output items for follow-up turns when the terminal HTTP fallback payload has an empty output array', async () => {
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.output_item.done\n',
|
||||||
|
'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n',
|
||||||
|
'event: response.output_item.done\n',
|
||||||
|
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n',
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_ws_empty_output","model":"gpt-4.1","status":"completed","output":[],"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_ws_followup","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"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 = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
instructions: 'be helpful',
|
||||||
|
input: [
|
||||||
{
|
{
|
||||||
id: 'msg_user_1',
|
id: 'msg_user_1',
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [{ type: 'input_text', text: 'call the tool' }],
|
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
id: 'fc_1',
|
}));
|
||||||
type: 'function_call',
|
await firstResponsePromise;
|
||||||
call_id: 'call_1',
|
|
||||||
status: 'completed',
|
const secondResponsePromise = waitForSocketMessageMatching(
|
||||||
},
|
socket,
|
||||||
{
|
(message) => message?.type === 'response.completed',
|
||||||
id: 'msg_1',
|
);
|
||||||
type: 'message',
|
socket.send(JSON.stringify({
|
||||||
role: 'assistant',
|
type: 'response.create',
|
||||||
status: 'completed',
|
model: 'gpt-4.1',
|
||||||
content: [{ type: 'output_text', text: 'call tool' }],
|
instructions: 'be helpful',
|
||||||
},
|
previous_response_id: 'resp_ws_empty_output',
|
||||||
|
input: [
|
||||||
{
|
{
|
||||||
id: 'tool_out_1',
|
id: 'tool_out_1',
|
||||||
type: 'function_call_output',
|
type: 'function_call_output',
|
||||||
call_id: 'call_1',
|
call_id: 'call_1',
|
||||||
output: 'tool result',
|
output: 'tool result',
|
||||||
},
|
},
|
||||||
]);
|
],
|
||||||
|
}));
|
||||||
|
await secondResponsePromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
|
const secondBody = JSON.parse(String(secondOptions.body));
|
||||||
|
|
||||||
|
expect(secondBody.input).toHaveLength(4);
|
||||||
|
expect(secondBody.input[1]).toMatchObject({
|
||||||
|
id: 'fc_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
});
|
||||||
|
expect(secondBody.input[2]).toEqual({
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'call tool' }],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serializes websocket messages per connection so follow-up turns wait for the previous HTTP fallback to finish', async () => {
|
it('serializes websocket messages per connection so follow-up turns wait for the previous HTTP fallback to finish', async () => {
|
||||||
@@ -924,33 +1281,31 @@ describe('responses websocket transport', () => {
|
|||||||
});
|
});
|
||||||
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).toHaveLength(4);
|
||||||
{
|
expect(secondBody.input[0]).toEqual({
|
||||||
id: 'msg_user_1',
|
id: 'msg_user_1',
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: [{ type: 'input_text', text: 'call the tool' }],
|
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||||
},
|
});
|
||||||
{
|
expect(secondBody.input[1]).toMatchObject({
|
||||||
id: 'fc_1',
|
id: 'fc_1',
|
||||||
type: 'function_call',
|
type: 'function_call',
|
||||||
call_id: 'call_1',
|
call_id: 'call_1',
|
||||||
status: 'completed',
|
});
|
||||||
},
|
expect(secondBody.input[2]).toEqual({
|
||||||
{
|
|
||||||
id: 'msg_1',
|
id: 'msg_1',
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
content: [{ type: 'output_text', text: 'call tool' }],
|
content: [{ type: 'output_text', text: 'call tool' }],
|
||||||
},
|
});
|
||||||
{
|
expect(secondBody.input[3]).toEqual({
|
||||||
id: 'tool_out_1',
|
id: 'tool_out_1',
|
||||||
type: 'function_call_output',
|
type: 'function_call_output',
|
||||||
call_id: 'call_1',
|
call_id: 'call_1',
|
||||||
output: 'tool result',
|
output: 'tool result',
|
||||||
},
|
});
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
|
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
|
||||||
@@ -1008,7 +1363,10 @@ describe('responses websocket transport', () => {
|
|||||||
const socket = createClientSocket(baseUrl);
|
const socket = createClientSocket(baseUrl);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
|
|
||||||
const firstResponsePromise = waitForSocketMessages(socket, 1);
|
const firstResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
@@ -1100,7 +1458,10 @@ describe('responses websocket transport', () => {
|
|||||||
|
|
||||||
const socket = createClientSocket(baseUrl);
|
const socket = createClientSocket(baseUrl);
|
||||||
await waitForSocketOpen(socket);
|
await waitForSocketOpen(socket);
|
||||||
const firstResponsePromise = waitForSocketMessages(socket, 1);
|
const firstResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_1',
|
||||||
|
);
|
||||||
|
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
@@ -1109,8 +1470,11 @@ describe('responses websocket transport', () => {
|
|||||||
input: [],
|
input: [],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const firstMessages = await firstResponsePromise;
|
const firstMessage = await firstResponsePromise;
|
||||||
const secondResponsePromise = waitForSocketMessages(socket, 1);
|
const secondResponsePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_2',
|
||||||
|
);
|
||||||
socket.send(JSON.stringify({
|
socket.send(JSON.stringify({
|
||||||
type: 'response.create',
|
type: 'response.create',
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
@@ -1128,13 +1492,20 @@ describe('responses websocket transport', () => {
|
|||||||
await secondResponsePromise;
|
await secondResponsePromise;
|
||||||
socket.close();
|
socket.close();
|
||||||
|
|
||||||
expect(firstMessages[0]?.type).toBe('response.completed');
|
expect(firstMessage?.type).toBe('response.completed');
|
||||||
expect(upstreamConnectionCount).toBe(0);
|
expect(upstreamConnectionCount).toBe(0);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
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.previous_response_id).toBeUndefined();
|
expect(secondBody.previous_response_id).toBeUndefined();
|
||||||
expect(secondBody.input).toEqual([
|
expect(secondBody.input).toEqual([
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'first' }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'tool_out_1',
|
id: 'tool_out_1',
|
||||||
type: 'function_call_output',
|
type: 'function_call_output',
|
||||||
@@ -1282,4 +1653,238 @@ describe('responses websocket transport', () => {
|
|||||||
]);
|
]);
|
||||||
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
|
expect(messages[2]?.error?.message).toContain('stream closed before response.completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not append websocket error after an upstream response.incomplete terminal event', async () => {
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
siteUrl: upstreamSiteUrl,
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
upstreamMessageHandler = (socket) => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.incomplete',
|
||||||
|
response: {
|
||||||
|
id: 'resp_ws_incomplete',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
status: 'incomplete',
|
||||||
|
output: [{
|
||||||
|
id: 'msg_ws_incomplete',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [{ type: 'output_text', text: 'partial' }],
|
||||||
|
}],
|
||||||
|
incomplete_details: {
|
||||||
|
reason: 'max_output_tokens',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const incompletePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.incomplete',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const message = await incompletePromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(message?.type).toBe('response.incomplete');
|
||||||
|
expect(message?.response?.incomplete_details?.reason).toBe('max_output_tokens');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not append websocket error after an upstream response.failed terminal event with output', async () => {
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
siteUrl: upstreamSiteUrl,
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
upstreamMessageHandler = (socket) => {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.failed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_ws_failed',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
status: 'failed',
|
||||||
|
output: [{
|
||||||
|
id: 'msg_ws_failed',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'failed',
|
||||||
|
content: [{ type: 'output_text', text: 'partial before failure' }],
|
||||||
|
}],
|
||||||
|
error: {
|
||||||
|
message: 'tool crashed',
|
||||||
|
type: 'server_error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
socket.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const failedPromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const message = await failedPromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(message?.type).toBe('response.failed');
|
||||||
|
expect(message?.response?.error?.message).toBe('tool crashed');
|
||||||
|
expect(message?.response?.output?.[0]?.content?.[0]?.text).toBe('partial before failure');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries forward output from response.incomplete terminal payloads on non-incremental websocket turns', async () => {
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.incomplete\n',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_followup","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_followup","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"output_text":"partial","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]))
|
||||||
|
.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_followup_done","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const firstMessagesPromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.incomplete',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const firstMessage = await firstMessagesPromise;
|
||||||
|
const secondMessagesPromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_followup_done',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
previous_response_id: 'resp_incomplete_followup',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await secondMessagesPromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(firstMessage?.response?.output).toEqual([
|
||||||
|
{
|
||||||
|
id: 'msg_incomplete_followup',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [{ type: 'output_text', text: 'partial' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
|
const secondBody = JSON.parse(String(secondOptions.body));
|
||||||
|
expect(secondBody.input).toHaveLength(1);
|
||||||
|
expect(secondBody.input[0]).toMatchObject({
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [{ type: 'output_text', text: 'partial' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries forward output from response.failed terminal payloads on non-incremental websocket turns', async () => {
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.failed\n',
|
||||||
|
'data: {"type":"response.failed","response":{"id":"resp_failed_followup","model":"gpt-4.1","status":"failed","output":[{"id":"msg_failed_followup","type":"message","role":"assistant","status":"failed","content":[{"type":"output_text","text":"partial failure"}]}],"error":{"message":"tool crashed","type":"server_error"},"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]))
|
||||||
|
.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_failed_followup_done","model":"gpt-4.1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const firstMessagePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.failed',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const firstMessage = await firstMessagePromise;
|
||||||
|
const secondMessagePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_failed_followup_done',
|
||||||
|
);
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
previous_response_id: 'resp_failed_followup',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await secondMessagePromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(firstMessage?.response?.output).toEqual([
|
||||||
|
{
|
||||||
|
id: 'msg_failed_followup',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'failed',
|
||||||
|
content: [{ type: 'output_text', text: 'partial failure' }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
|
const secondBody = JSON.parse(String(secondOptions.body));
|
||||||
|
expect(secondBody.input).toHaveLength(1);
|
||||||
|
expect(secondBody.input[0]).toMatchObject({
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'output_text', text: 'partial failure' }],
|
||||||
|
});
|
||||||
|
expect(['failed', 'incomplete']).toContain(secondBody.input[0]?.status);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,4 +72,161 @@ describe('collectResponsesFinalPayloadFromSse', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns response.incomplete payloads instead of treating them as upstream failures', async () => {
|
||||||
|
const upstream = {
|
||||||
|
async text() {
|
||||||
|
return [
|
||||||
|
'event: response.created',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||||
|
'',
|
||||||
|
'event: response.incomplete',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_terminal","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial answer"}]}],"output_text":"partial answer","incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||||
|
'',
|
||||||
|
'data: [DONE]',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||||
|
.resolves
|
||||||
|
.toMatchObject({
|
||||||
|
payload: {
|
||||||
|
id: 'resp_incomplete_terminal',
|
||||||
|
status: 'incomplete',
|
||||||
|
output_text: 'partial answer',
|
||||||
|
incomplete_details: {
|
||||||
|
reason: 'max_output_tokens',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns response.incomplete terminal payloads instead of throwing', async () => {
|
||||||
|
const upstream = {
|
||||||
|
async text() {
|
||||||
|
return [
|
||||||
|
'event: response.created',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_incomplete_1","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||||
|
'',
|
||||||
|
'event: response.output_text.delta',
|
||||||
|
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_1","delta":"partial"}',
|
||||||
|
'',
|
||||||
|
'event: response.incomplete',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_1","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_1","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||||
|
'',
|
||||||
|
'data: [DONE]',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||||
|
.resolves
|
||||||
|
.toMatchObject({
|
||||||
|
payload: {
|
||||||
|
id: 'resp_incomplete_1',
|
||||||
|
status: 'incomplete',
|
||||||
|
incomplete_details: {
|
||||||
|
reason: 'max_output_tokens',
|
||||||
|
},
|
||||||
|
output_text: 'partial',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats response.incomplete as terminal even when only response.output carries the visible text', async () => {
|
||||||
|
const upstream = {
|
||||||
|
async text() {
|
||||||
|
return [
|
||||||
|
'event: response.created',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||||
|
'',
|
||||||
|
'event: response.incomplete',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_output_only","model":"gpt-5.4","status":"incomplete","output":[{"id":"msg_incomplete_output_only","type":"message","role":"assistant","status":"incomplete","content":[{"type":"output_text","text":"partial from output"}]}],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||||
|
'',
|
||||||
|
'data: [DONE]',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||||
|
.resolves
|
||||||
|
.toMatchObject({
|
||||||
|
payload: {
|
||||||
|
id: 'resp_incomplete_output_only',
|
||||||
|
status: 'incomplete',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_incomplete_output_only',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'partial from output',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output_text: 'partial from output',
|
||||||
|
incomplete_details: {
|
||||||
|
reason: 'max_output_tokens',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves incomplete item status when response.incomplete needs aggregate output repair', async () => {
|
||||||
|
const upstream = {
|
||||||
|
async text() {
|
||||||
|
return [
|
||||||
|
'event: response.created',
|
||||||
|
'data: {"type":"response.created","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}',
|
||||||
|
'',
|
||||||
|
'event: response.output_item.added',
|
||||||
|
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_incomplete_repair","type":"message","role":"assistant","status":"in_progress","content":[]}}',
|
||||||
|
'',
|
||||||
|
'event: response.output_text.delta',
|
||||||
|
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_incomplete_repair","delta":"partial repair"}',
|
||||||
|
'',
|
||||||
|
'event: response.incomplete',
|
||||||
|
'data: {"type":"response.incomplete","response":{"id":"resp_incomplete_repair","model":"gpt-5.4","status":"incomplete","output":[],"incomplete_details":{"reason":"max_output_tokens"},"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||||
|
'',
|
||||||
|
'data: [DONE]',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||||
|
.resolves
|
||||||
|
.toMatchObject({
|
||||||
|
payload: {
|
||||||
|
id: 'resp_incomplete_repair',
|
||||||
|
status: 'incomplete',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_incomplete_repair',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'partial repair',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
output_text: 'partial repair',
|
||||||
|
incomplete_details: {
|
||||||
|
reason: 'max_output_tokens',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
|
|
||||||
|
type ResponsesTerminalStatus = 'completed' | 'incomplete';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function parseResponsesSsePayload(data: string): Record<string, unknown> | null {
|
function parseResponsesSsePayload(data: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(data);
|
const parsed = JSON.parse(data);
|
||||||
@@ -72,6 +78,27 @@ function hasMeaningfulFinalResponsesPayload(payload: Record<string, unknown>): b
|
|||||||
return hasMeaningfulResponsesOutput(payload.output);
|
return hasMeaningfulResponsesOutput(payload.output);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectResponsesOutputText(payload: Record<string, unknown>): string {
|
||||||
|
const output = Array.isArray(payload.output) ? payload.output : [];
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
for (const item of output) {
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
if (asTrimmedString(item.type).toLowerCase() !== 'message') continue;
|
||||||
|
const content = Array.isArray(item.content) ? item.content : [];
|
||||||
|
for (const part of content) {
|
||||||
|
if (!isRecord(part)) continue;
|
||||||
|
const partType = asTrimmedString(part.type).toLowerCase();
|
||||||
|
const text = typeof part.text === 'string' ? part.text : '';
|
||||||
|
if ((partType === 'output_text' || partType === 'text') && text) {
|
||||||
|
parts.push(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
function rememberStreamResponseEnvelope(
|
function rememberStreamResponseEnvelope(
|
||||||
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
@@ -95,29 +122,132 @@ function rememberStreamResponseEnvelope(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function materializeCompletedPayloadFromAggregate(
|
function ensureResponseId(rawId: string): string {
|
||||||
|
const trimmed = rawId.trim() || `resp_${Date.now()}`;
|
||||||
|
return trimmed.startsWith('resp_') ? trimmed : `resp_${trimmed}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUsagePayload(usage: ReturnType<typeof parseProxyUsage>): Record<string, unknown> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
input_tokens: usage.promptTokens,
|
||||||
|
output_tokens: usage.completionTokens,
|
||||||
|
total_tokens: usage.totalTokens,
|
||||||
|
};
|
||||||
|
const inputDetails: Record<string, unknown> = {};
|
||||||
|
if ((usage.cacheReadTokens || 0) > 0) inputDetails.cached_tokens = usage.cacheReadTokens;
|
||||||
|
if ((usage.cacheCreationTokens || 0) > 0) inputDetails.cache_creation_tokens = usage.cacheCreationTokens;
|
||||||
|
if (Object.keys(inputDetails).length > 0) payload.input_tokens_details = inputDetails;
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneAggregateOutputItem(
|
||||||
|
item: unknown,
|
||||||
|
terminalStatus: ResponsesTerminalStatus,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
if (!isRecord(item)) return null;
|
||||||
|
const next = structuredClone(item);
|
||||||
|
const currentStatus = asTrimmedString(next.status).toLowerCase();
|
||||||
|
next.status = currentStatus && currentStatus !== 'in_progress'
|
||||||
|
? currentStatus
|
||||||
|
: terminalStatus;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function materializeTerminalPayloadFromAggregate(
|
||||||
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
|
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
|
||||||
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||||
usage: ReturnType<typeof parseProxyUsage>,
|
usage: ReturnType<typeof parseProxyUsage>,
|
||||||
): Record<string, unknown> | null {
|
terminalStatus: ResponsesTerminalStatus,
|
||||||
const lines = openAiResponsesTransformer.aggregator.complete(
|
): Record<string, unknown> {
|
||||||
|
const output = aggregateState.outputItems
|
||||||
|
.map((item) => cloneAggregateOutputItem(item, terminalStatus))
|
||||||
|
.filter((item): item is Record<string, unknown> => !!item);
|
||||||
|
const usagePayload = buildUsagePayload(usage);
|
||||||
|
const usageWithExtras = Object.keys(aggregateState.usageExtras).length > 0
|
||||||
|
? { ...usagePayload, ...structuredClone(aggregateState.usageExtras) }
|
||||||
|
: usagePayload;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: ensureResponseId(
|
||||||
|
asTrimmedString(aggregateState.responseId)
|
||||||
|
|| asTrimmedString(streamContext.id)
|
||||||
|
|| asTrimmedString(aggregateState.modelName),
|
||||||
|
),
|
||||||
|
object: 'response',
|
||||||
|
created_at: (
|
||||||
|
typeof aggregateState.createdAt === 'number' && Number.isFinite(aggregateState.createdAt)
|
||||||
|
? aggregateState.createdAt
|
||||||
|
: streamContext.created
|
||||||
|
) || Math.floor(Date.now() / 1000),
|
||||||
|
status: terminalStatus,
|
||||||
|
model: asTrimmedString(streamContext.model) || asTrimmedString(aggregateState.modelName),
|
||||||
|
output,
|
||||||
|
output_text: collectResponsesOutputText({ output }),
|
||||||
|
usage: usageWithExtras,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichTerminalPayload(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
aggregateState: ReturnType<typeof openAiResponsesTransformer.aggregator.createState>,
|
||||||
|
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||||
|
usage: ReturnType<typeof parseProxyUsage>,
|
||||||
|
terminalStatus: ResponsesTerminalStatus,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const next = structuredClone(payload);
|
||||||
|
const materialized = materializeTerminalPayloadFromAggregate(
|
||||||
aggregateState,
|
aggregateState,
|
||||||
streamContext,
|
streamContext,
|
||||||
usage,
|
usage,
|
||||||
|
terminalStatus,
|
||||||
);
|
);
|
||||||
const { events } = openAiResponsesTransformer.pullSseEvents(lines.join(''));
|
|
||||||
for (const event of events) {
|
if (!hasMeaningfulResponsesOutput(next.output) && materialized && hasMeaningfulResponsesOutput(materialized.output)) {
|
||||||
if (event.data === '[DONE]') continue;
|
next.output = materialized.output;
|
||||||
const payload = parseResponsesSsePayload(event.data);
|
|
||||||
if (!payload) continue;
|
|
||||||
if (event.event === 'response.completed' && isRecord(payload.response)) {
|
|
||||||
return payload.response;
|
|
||||||
}
|
}
|
||||||
if (payload.type === 'response.completed' && hasCompleteFinalResponsesPayload(payload)) {
|
|
||||||
return payload;
|
const currentOutputText = typeof next.output_text === 'string' ? next.output_text : '';
|
||||||
|
if (!currentOutputText) {
|
||||||
|
const derivedOutputText = collectResponsesOutputText(next)
|
||||||
|
|| (materialized && typeof materialized.output_text === 'string' ? materialized.output_text : '');
|
||||||
|
if (derivedOutputText) {
|
||||||
|
next.output_text = derivedOutputText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
|
if (materialized && next.usage === undefined && materialized.usage !== undefined) {
|
||||||
|
next.usage = materialized.usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMissingResponsesTerminalFields(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
fallbackPayload: Record<string, unknown> | null,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (!fallbackPayload) return payload;
|
||||||
|
const merged = { ...payload };
|
||||||
|
if (merged.output === undefined && fallbackPayload.output !== undefined) {
|
||||||
|
merged.output = fallbackPayload.output;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(typeof merged.output_text !== 'string' || merged.output_text.length === 0)
|
||||||
|
&& typeof fallbackPayload.output_text === 'string'
|
||||||
|
&& fallbackPayload.output_text.length > 0
|
||||||
|
) {
|
||||||
|
merged.output_text = fallbackPayload.output_text;
|
||||||
|
}
|
||||||
|
if (merged.object === undefined && fallbackPayload.object !== undefined) {
|
||||||
|
merged.object = fallbackPayload.object;
|
||||||
|
}
|
||||||
|
if (merged.created_at === undefined && fallbackPayload.created_at !== undefined) {
|
||||||
|
merged.created_at = fallbackPayload.created_at;
|
||||||
|
}
|
||||||
|
if (merged.usage === undefined && fallbackPayload.usage !== undefined) {
|
||||||
|
merged.usage = fallbackPayload.usage;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function looksLikeResponsesSseText(rawText: string): boolean {
|
export function looksLikeResponsesSseText(rawText: string): boolean {
|
||||||
@@ -169,18 +299,20 @@ export function collectResponsesFinalPayloadFromSseText(
|
|||||||
promptTokensIncludeCache: null as boolean | null,
|
promptTokensIncludeCache: null as boolean | null,
|
||||||
};
|
};
|
||||||
let completedPayload: Record<string, unknown> | null = null;
|
let completedPayload: Record<string, unknown> | null = null;
|
||||||
|
let terminalStatus: ResponsesTerminalStatus = 'completed';
|
||||||
|
|
||||||
const captureCompletedPayloadFromEvent = (
|
const captureCompletedPayloadFromEvent = (
|
||||||
eventType: string,
|
eventType: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
) => {
|
) => {
|
||||||
if (completedPayload) return;
|
if (completedPayload) return;
|
||||||
if (eventType === 'response.failed' || eventType === 'response.incomplete' || eventType === 'error') {
|
if (eventType === 'response.failed' || eventType === 'error') {
|
||||||
throw new Error(getResponsesFailureMessage(payload));
|
throw new Error(getResponsesFailureMessage(payload));
|
||||||
}
|
}
|
||||||
if (eventType !== 'response.completed') {
|
if (eventType !== 'response.completed' && eventType !== 'response.incomplete') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
terminalStatus = eventType === 'response.incomplete' ? 'incomplete' : 'completed';
|
||||||
if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) {
|
if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) {
|
||||||
completedPayload = payload.response;
|
completedPayload = payload.response;
|
||||||
return;
|
return;
|
||||||
@@ -236,11 +368,36 @@ export function collectResponsesFinalPayloadFromSseText(
|
|||||||
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
|
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
|
||||||
&& hasMeaningfulResponsesOutput(aggregateState.outputItems)
|
&& hasMeaningfulResponsesOutput(aggregateState.outputItems)
|
||||||
) {
|
) {
|
||||||
completedPayload = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
|
completedPayload = mergeMissingResponsesTerminalFields(
|
||||||
|
completedPayload,
|
||||||
|
materializeTerminalPayloadFromAggregate(
|
||||||
|
aggregateState,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
terminalStatus,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (completedPayload) {
|
||||||
|
completedPayload = mergeMissingResponsesTerminalFields(
|
||||||
|
completedPayload,
|
||||||
|
materializeTerminalPayloadFromAggregate(
|
||||||
|
aggregateState,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
terminalStatus,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!completedPayload) {
|
if (!completedPayload) {
|
||||||
const materialized = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
|
const materialized = materializeTerminalPayloadFromAggregate(
|
||||||
|
aggregateState,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
terminalStatus,
|
||||||
|
);
|
||||||
if (materialized) {
|
if (materialized) {
|
||||||
completedPayload = materialized;
|
completedPayload = materialized;
|
||||||
}
|
}
|
||||||
@@ -248,12 +405,18 @@ export function collectResponsesFinalPayloadFromSseText(
|
|||||||
|
|
||||||
if (completedPayload) {
|
if (completedPayload) {
|
||||||
return {
|
return {
|
||||||
payload: completedPayload,
|
payload: enrichTerminalPayload(
|
||||||
|
completedPayload,
|
||||||
|
aggregateState,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
terminalStatus,
|
||||||
|
),
|
||||||
rawText,
|
rawText,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error('stream disconnected before response.completed');
|
throw new Error('stream disconnected before terminal responses event');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function collectResponsesFinalPayloadFromSse(
|
export async function collectResponsesFinalPayloadFromSse(
|
||||||
|
|||||||
@@ -312,6 +312,11 @@ function synthesizePrewarmResponsePayloads(request: Record<string, unknown>) {
|
|||||||
function collectResponsesOutput(payloads: unknown[]): unknown[] {
|
function collectResponsesOutput(payloads: unknown[]): unknown[] {
|
||||||
const outputByIndex = new Map<number, unknown>();
|
const outputByIndex = new Map<number, unknown>();
|
||||||
let completedOutput: unknown[] | null = null;
|
let completedOutput: unknown[] | null = null;
|
||||||
|
const fallbackStatusForType = (type: string): string => {
|
||||||
|
if (type === 'response.completed') return 'completed';
|
||||||
|
if (type === 'response.failed') return 'failed';
|
||||||
|
return 'incomplete';
|
||||||
|
};
|
||||||
|
|
||||||
for (const payload of payloads) {
|
for (const payload of payloads) {
|
||||||
if (!isRecord(payload)) continue;
|
if (!isRecord(payload)) continue;
|
||||||
@@ -322,8 +327,54 @@ function collectResponsesOutput(payloads: unknown[]): unknown[] {
|
|||||||
outputByIndex.set(Number(payload.output_index), cloneJsonObject(payload.item));
|
outputByIndex.set(Number(payload.output_index), cloneJsonObject(payload.item));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (type === 'response.completed' && isRecord(payload.response) && Array.isArray(payload.response.output)) {
|
if (
|
||||||
completedOutput = cloneJsonObject(payload.response.output);
|
(type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed')
|
||||||
|
&& isRecord(payload.response)
|
||||||
|
&& Array.isArray(payload.response.output)
|
||||||
|
) {
|
||||||
|
const terminalOutput = cloneJsonObject(payload.response.output);
|
||||||
|
if (terminalOutput.length > 0 || outputByIndex.size === 0) {
|
||||||
|
completedOutput = terminalOutput;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed')
|
||||||
|
&& isRecord(payload.response)
|
||||||
|
&& typeof payload.response.output_text === 'string'
|
||||||
|
&& payload.response.output_text.trim()
|
||||||
|
) {
|
||||||
|
completedOutput = [{
|
||||||
|
id: `msg_${asTrimmedString(payload.response.id) || type}`,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: asTrimmedString(payload.response.status) || fallbackStatusForType(type),
|
||||||
|
content: [{
|
||||||
|
type: 'output_text',
|
||||||
|
text: payload.response.output_text,
|
||||||
|
}],
|
||||||
|
}];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (Array.isArray(payload.output)) {
|
||||||
|
const terminalOutput = cloneJsonObject(payload.output);
|
||||||
|
if (terminalOutput.length > 0 || outputByIndex.size === 0) {
|
||||||
|
completedOutput = terminalOutput;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof payload.output_text === 'string' && payload.output_text.trim()) {
|
||||||
|
const fallbackStatus = asTrimmedString(payload.status) || fallbackStatusForType(type || 'response.completed');
|
||||||
|
completedOutput = [{
|
||||||
|
id: `msg_${type || 'response'}`,
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: fallbackStatus,
|
||||||
|
content: [{
|
||||||
|
type: 'output_text',
|
||||||
|
text: payload.output_text,
|
||||||
|
}],
|
||||||
|
}];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -381,10 +432,9 @@ async function forwardResponsesRequestViaHttp(input: {
|
|||||||
if (!contentType.includes('text/event-stream')) {
|
if (!contentType.includes('text/event-stream')) {
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(response.body);
|
const payload = JSON.parse(response.body);
|
||||||
|
const output = collectResponsesOutput([payload]);
|
||||||
input.socket.send(JSON.stringify(payload));
|
input.socket.send(JSON.stringify(payload));
|
||||||
return isRecord(payload?.response) && Array.isArray(payload.response.output)
|
return output;
|
||||||
? cloneJsonObject(payload.response.output)
|
|
||||||
: [];
|
|
||||||
} catch {
|
} catch {
|
||||||
writeResponsesWebsocketError(input.socket, 502, 'Unexpected non-JSON websocket proxy response');
|
writeResponsesWebsocketError(input.socket, 502, 'Unexpected non-JSON websocket proxy response');
|
||||||
return null;
|
return null;
|
||||||
@@ -400,7 +450,7 @@ async function forwardResponsesRequestViaHttp(input: {
|
|||||||
const payload = JSON.parse(event.data);
|
const payload = JSON.parse(event.data);
|
||||||
forwardedPayloads.push(payload);
|
forwardedPayloads.push(payload);
|
||||||
const type = isRecord(payload) ? asTrimmedString(payload.type) : '';
|
const type = isRecord(payload) ? asTrimmedString(payload.type) : '';
|
||||||
if (type === 'response.completed' || type === 'response.failed') {
|
if (type === 'response.completed' || type === 'response.failed' || type === 'response.incomplete') {
|
||||||
sawTerminalPayload = true;
|
sawTerminalPayload = true;
|
||||||
}
|
}
|
||||||
input.socket.send(JSON.stringify(payload));
|
input.socket.send(JSON.stringify(payload));
|
||||||
@@ -597,7 +647,7 @@ async function handleResponsesWebsocketConnection(
|
|||||||
? error
|
? error
|
||||||
: new CodexWebsocketRuntimeError('upstream websocket request failed');
|
: new CodexWebsocketRuntimeError('upstream websocket request failed');
|
||||||
if (runtimeError.status && runtimeError.events.length === 0) {
|
if (runtimeError.status && runtimeError.events.length === 0) {
|
||||||
const fallbackOutput = await forwardResponsesRequestViaHttp({
|
const forwarded = await forwardResponsesRequestViaHttp({
|
||||||
app,
|
app,
|
||||||
socket,
|
socket,
|
||||||
request,
|
request,
|
||||||
@@ -605,8 +655,8 @@ async function handleResponsesWebsocketConnection(
|
|||||||
preserveIncrementalMode: supportsIncrementalInput,
|
preserveIncrementalMode: supportsIncrementalInput,
|
||||||
authToken: authContext.token,
|
authToken: authContext.token,
|
||||||
});
|
});
|
||||||
if (fallbackOutput) {
|
if (forwarded) {
|
||||||
lastResponseOutput = fallbackOutput;
|
lastResponseOutput = forwarded;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -614,6 +664,12 @@ async function handleResponsesWebsocketConnection(
|
|||||||
for (const payload of runtimeError.events) {
|
for (const payload of runtimeError.events) {
|
||||||
socket.send(JSON.stringify(payload));
|
socket.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
|
const emittedTerminalResponsesEvent = runtimeError.events.some((payload) => {
|
||||||
|
if (!isRecord(payload)) return false;
|
||||||
|
const type = asTrimmedString(payload.type);
|
||||||
|
return type === 'response.completed' || type === 'response.failed' || type === 'response.incomplete';
|
||||||
|
});
|
||||||
|
if (!emittedTerminalResponsesEvent) {
|
||||||
writeResponsesWebsocketError(
|
writeResponsesWebsocketError(
|
||||||
socket,
|
socket,
|
||||||
runtimeError.status || 408,
|
runtimeError.status || 408,
|
||||||
@@ -621,10 +677,11 @@ async function handleResponsesWebsocketConnection(
|
|||||||
runtimeError.payload,
|
runtimeError.payload,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const forwardedOutput = await forwardResponsesRequestViaHttp({
|
const forwarded = await forwardResponsesRequestViaHttp({
|
||||||
app,
|
app,
|
||||||
socket,
|
socket,
|
||||||
request,
|
request,
|
||||||
@@ -632,8 +689,8 @@ async function handleResponsesWebsocketConnection(
|
|||||||
preserveIncrementalMode: supportsIncrementalInput,
|
preserveIncrementalMode: supportsIncrementalInput,
|
||||||
authToken: authContext.token,
|
authToken: authContext.token,
|
||||||
});
|
});
|
||||||
if (forwardedOutput) {
|
if (forwarded) {
|
||||||
lastResponseOutput = forwardedOutput;
|
lastResponseOutput = forwarded;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
writeResponsesWebsocketError(socket, 500, 'internal websocket proxy error');
|
writeResponsesWebsocketError(socket, 500, 'internal websocket proxy error');
|
||||||
|
|||||||
@@ -264,6 +264,246 @@ describe('serializeConvertedResponsesEvents', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits response.output_item.added before synthetic reasoning summary events for delta-only reasoning streams', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
reasoningDelta: 'Think ',
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
|
||||||
|
expect(events.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.reasoning_summary_part.added',
|
||||||
|
'response.reasoning_summary_text.delta',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'rs_0',
|
||||||
|
type: 'reasoning',
|
||||||
|
status: 'in_progress',
|
||||||
|
summary: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(events[1]?.payload).toMatchObject({
|
||||||
|
type: 'response.reasoning_summary_part.added',
|
||||||
|
item_id: 'rs_0',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'summary_text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(events[2]?.payload).toMatchObject({
|
||||||
|
type: 'response.reasoning_summary_text.delta',
|
||||||
|
item_id: 'rs_0',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
delta: 'Think ',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps synthetic reasoning and message output items separate when reasoning arrives before content', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
reasoningDelta: 'plan first',
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentLines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
contentDelta: 'final answer',
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentEvents = parseSseEvents(contentLines);
|
||||||
|
expect(contentEvents.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.content_part.added',
|
||||||
|
'response.output_text.delta',
|
||||||
|
]);
|
||||||
|
expect(contentEvents[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 1,
|
||||||
|
item: {
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
content: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedLines = completeResponsesStream(state, streamContext, usage);
|
||||||
|
const payloads = parseSsePayloads(completedLines);
|
||||||
|
const completed = payloads.find((item) => item.type === 'response.completed');
|
||||||
|
expect(completed?.response?.output).toEqual([
|
||||||
|
{
|
||||||
|
id: 'rs_0',
|
||||||
|
type: 'reasoning',
|
||||||
|
status: 'completed',
|
||||||
|
summary: [
|
||||||
|
{
|
||||||
|
type: 'summary_text',
|
||||||
|
text: 'plan first',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'final answer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(completed?.response?.output_text).toBe('final answer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps synthetic message and function call items separate when one event contains both', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
contentDelta: 'hello',
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'Glob',
|
||||||
|
argumentsDelta: '{}',
|
||||||
|
}],
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
expect(events.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.content_part.added',
|
||||||
|
'response.output_text.delta',
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.function_call_arguments.delta',
|
||||||
|
]);
|
||||||
|
expect(events[3]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 1,
|
||||||
|
item: {
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'Glob',
|
||||||
|
arguments: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedLines = completeResponsesStream(state, streamContext, usage);
|
||||||
|
const payloads = parseSsePayloads(completedLines);
|
||||||
|
const completed = payloads.find((item) => item.type === 'response.completed');
|
||||||
|
expect(completed?.response?.output).toEqual([
|
||||||
|
{
|
||||||
|
id: 'msg_0',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'hello',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'call_1',
|
||||||
|
type: 'function_call',
|
||||||
|
status: 'completed',
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'Glob',
|
||||||
|
arguments: '{}',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills missing parent events before sparse native function call argument deltas', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.function_call_arguments.delta',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.function_call_arguments.delta',
|
||||||
|
output_index: 0,
|
||||||
|
call_id: 'call_sparse',
|
||||||
|
name: 'lookup',
|
||||||
|
delta: '{"q":"x"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
expect(events.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.function_call_arguments.delta',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_sparse',
|
||||||
|
name: 'lookup',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves richer image_generation_call fields while aggregating progress events', () => {
|
it('preserves richer image_generation_call fields while aggregating progress events', () => {
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
const streamContext = createStreamTransformContext('gpt-5');
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
@@ -333,6 +573,210 @@ describe('serializeConvertedResponsesEvents', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits response.output_item.done after native image generation 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.image_generation_call.completed',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.image_generation_call.completed',
|
||||||
|
item_id: 'img_done_1',
|
||||||
|
output_index: 0,
|
||||||
|
result: 'image-final',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.completed',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_image_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.output_item.done',
|
||||||
|
'response.completed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills native parent lifecycle events before forwarding sparse function call argument deltas', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.function_call_arguments.delta',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.function_call_arguments.delta',
|
||||||
|
output_index: 0,
|
||||||
|
call_id: 'call_sparse_1',
|
||||||
|
name: 'Glob',
|
||||||
|
delta: '{}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
expect(events.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.function_call_arguments.delta',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'call_sparse_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_sparse_1',
|
||||||
|
name: 'Glob',
|
||||||
|
arguments: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills native content part openers before forwarding sparse output_text.done events', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.output_text.done',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.output_text.done',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_sparse_1',
|
||||||
|
text: 'hello world',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
expect(events.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.content_part.added',
|
||||||
|
'response.output_text.done',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'msg_sparse_1',
|
||||||
|
type: 'message',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(events[1]?.payload).toMatchObject({
|
||||||
|
type: 'response.content_part.added',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_sparse_1',
|
||||||
|
content_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'output_text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(events[2]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_text.done',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_sparse_1',
|
||||||
|
text: 'hello world',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves native message item ids when sparse content parts open the parent item', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.content_part.added',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.content_part.added',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_sparse_content_1',
|
||||||
|
content_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'output_text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
expect(events.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.content_part.added',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'msg_sparse_content_1',
|
||||||
|
type: 'message',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(events[1]?.payload).toMatchObject({
|
||||||
|
type: 'response.content_part.added',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_sparse_content_1',
|
||||||
|
content_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'output_text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('emits canonical message done events before response.completed when recovering a sparse text stream', () => {
|
it('emits canonical message done events before response.completed when recovering a sparse text stream', () => {
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
const streamContext = createStreamTransformContext('gpt-5');
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
@@ -675,6 +1119,94 @@ describe('serializeConvertedResponsesEvents', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backfills missing native content_part.done even after output_item.done already marks the message completed', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 2,
|
||||||
|
completionTokens: 4,
|
||||||
|
totalTokens: 6,
|
||||||
|
};
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.content_part.added',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.content_part.added',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_native_done_1',
|
||||||
|
content_index: 0,
|
||||||
|
part: {
|
||||||
|
type: 'output_text',
|
||||||
|
text: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.output_text.done',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.output_text.done',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_native_done_1',
|
||||||
|
text: 'hello world',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.output_item.done',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'msg_native_done_1',
|
||||||
|
type: 'message',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.completed',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_native_done_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.completed',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not synthesize duplicate reasoning done events after original reasoning completion already arrived', () => {
|
it('does not synthesize duplicate reasoning done events after original reasoning completion already arrived', () => {
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
const streamContext = createStreamTransformContext('gpt-5');
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
@@ -908,6 +1440,76 @@ describe('serializeConvertedResponsesEvents', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves terminal response output from sparse response.incomplete payloads', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.incomplete',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.incomplete',
|
||||||
|
response: {
|
||||||
|
id: 'resp_sparse_incomplete',
|
||||||
|
model: 'gpt-5',
|
||||||
|
status: 'incomplete',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_sparse_incomplete',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'partial answer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
usage: {
|
||||||
|
input_tokens: 1,
|
||||||
|
output_tokens: 2,
|
||||||
|
total_tokens: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
expect(events.at(-1)?.payload).toMatchObject({
|
||||||
|
type: 'response.incomplete',
|
||||||
|
response: {
|
||||||
|
id: 'resp_sparse_incomplete',
|
||||||
|
status: 'incomplete',
|
||||||
|
output_text: 'partial answer',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_sparse_incomplete',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'incomplete',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'output_text',
|
||||||
|
text: 'partial answer',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves response.incomplete as a first-class terminal event', () => {
|
it('preserves response.incomplete as a first-class terminal event', () => {
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
const streamContext = createStreamTransformContext('gpt-5');
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
@@ -1145,6 +1747,68 @@ describe('serializeConvertedResponsesEvents', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('emits a generic output_item.done for native image generation items that completed earlier', () => {
|
||||||
|
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.image_generation_call.completed',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.image_generation_call.completed',
|
||||||
|
item_id: 'img_terminal_1',
|
||||||
|
output_index: 0,
|
||||||
|
result: 'final-image',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.completed',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_image_done_1',
|
||||||
|
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.output_item.done',
|
||||||
|
'response.completed',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'img_terminal_1',
|
||||||
|
type: 'image_generation_call',
|
||||||
|
status: 'completed',
|
||||||
|
result: 'final-image',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not synthesize duplicate tool input done events after original tool completion already arrived', () => {
|
it('does not synthesize duplicate tool input done events after original tool completion already arrived', () => {
|
||||||
const state = createOpenAiResponsesAggregateState('gpt-5');
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
const streamContext = createStreamTransformContext('gpt-5');
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
@@ -1240,4 +1904,197 @@ describe('serializeConvertedResponsesEvents', () => {
|
|||||||
'[DONE]',
|
'[DONE]',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps synthetic reasoning and message items separate when reasoning arrives before visible text', () => {
|
||||||
|
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 first',
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageLines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
contentDelta: 'visible answer',
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageEvents = parseSseEvents(messageLines);
|
||||||
|
expect(messageEvents[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
item: {
|
||||||
|
type: 'message',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionLines = completeResponsesStream(state, streamContext, usage);
|
||||||
|
const completionPayloads = parseSsePayloads(completionLines);
|
||||||
|
const completed = completionPayloads.find((item) => item.type === 'response.completed');
|
||||||
|
expect(completed?.response?.output).toHaveLength(2);
|
||||||
|
expect(completed?.response?.output?.find((item: any) => item.type === 'reasoning')).toMatchObject({
|
||||||
|
type: 'reasoning',
|
||||||
|
summary: [{ type: 'summary_text', text: 'think first' }],
|
||||||
|
});
|
||||||
|
expect(completed?.response?.output?.find((item: any) => item.type === 'message')).toMatchObject({
|
||||||
|
type: 'message',
|
||||||
|
content: [{ type: 'output_text', text: 'visible answer' }],
|
||||||
|
});
|
||||||
|
expect(completed?.response?.output_text).toBe('visible answer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps synthetic message and tool call items separate when content and tool deltas arrive together', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
contentDelta: 'hi',
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'Glob',
|
||||||
|
argumentsDelta: '{"pattern":"README*"}',
|
||||||
|
}],
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const completionLines = completeResponsesStream(state, streamContext, usage);
|
||||||
|
const completionPayloads = parseSsePayloads(completionLines);
|
||||||
|
const completed = completionPayloads.find((item) => item.type === 'response.completed');
|
||||||
|
expect(completed?.response?.output).toHaveLength(2);
|
||||||
|
expect(completed?.response?.output?.find((item: any) => item.type === 'message')).toMatchObject({
|
||||||
|
type: 'message',
|
||||||
|
content: [{ type: 'output_text', text: 'hi' }],
|
||||||
|
});
|
||||||
|
expect(completed?.response?.output?.find((item: any) => item.type === 'function_call')).toMatchObject({
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'Glob',
|
||||||
|
arguments: '{"pattern":"README*"}',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills missing output_item.added before sparse native function_call argument events', () => {
|
||||||
|
const state = createOpenAiResponsesAggregateState('gpt-5');
|
||||||
|
const streamContext = createStreamTransformContext('gpt-5');
|
||||||
|
const usage = {
|
||||||
|
promptTokens: 1,
|
||||||
|
completionTokens: 2,
|
||||||
|
totalTokens: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.function_call_arguments.delta',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.function_call_arguments.delta',
|
||||||
|
output_index: 0,
|
||||||
|
call_id: 'call_sparse_1',
|
||||||
|
name: 'Glob',
|
||||||
|
delta: '{"pattern":"README*"}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const events = parseSseEvents(lines);
|
||||||
|
expect(events.map((entry) => entry.event)).toEqual([
|
||||||
|
'response.output_item.added',
|
||||||
|
'response.function_call_arguments.delta',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_sparse_1',
|
||||||
|
name: 'Glob',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a generic output_item.done for native image_generation completion before response.completed', () => {
|
||||||
|
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.image_generation_call.completed',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.image_generation_call.completed',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'img_1',
|
||||||
|
result: 'image-result',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines = serializeConvertedResponsesEvents({
|
||||||
|
state,
|
||||||
|
streamContext,
|
||||||
|
usage,
|
||||||
|
event: {
|
||||||
|
responsesEventType: 'response.completed',
|
||||||
|
responsesPayload: {
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_image_1',
|
||||||
|
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.output_item.done',
|
||||||
|
'response.completed',
|
||||||
|
]);
|
||||||
|
expect(events[0]?.payload).toMatchObject({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'img_1',
|
||||||
|
type: 'image_generation_call',
|
||||||
|
status: 'completed',
|
||||||
|
result: 'image-result',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const reasoningSummaryTextDoneMarker = Symbol('responses.reasoning_summary_text.
|
|||||||
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 functionCallArgumentsDoneMarker = Symbol('responses.function_call_arguments.done');
|
||||||
const customToolInputDoneMarker = Symbol('responses.custom_tool_call_input.done');
|
const customToolInputDoneMarker = Symbol('responses.custom_tool_call_input.done');
|
||||||
|
const outputItemDoneMarker = Symbol('responses.output_item.done');
|
||||||
|
|
||||||
export type OpenAiResponsesAggregateState = {
|
export type OpenAiResponsesAggregateState = {
|
||||||
modelName: string;
|
modelName: string;
|
||||||
@@ -145,11 +146,147 @@ function rememberOutputId(state: OpenAiResponsesAggregateState, index: number, i
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preserveTerminalMarkers(source: AggregateOutputItem | undefined, target: AggregateOutputItem): void {
|
||||||
|
if (!isRecord(source)) return;
|
||||||
|
|
||||||
|
const itemMarkers = [
|
||||||
|
outputItemDoneMarker,
|
||||||
|
functionCallArgumentsDoneMarker,
|
||||||
|
customToolInputDoneMarker,
|
||||||
|
];
|
||||||
|
for (const marker of itemMarkers) {
|
||||||
|
if (hasTerminalMarker(source, marker)) {
|
||||||
|
markTerminalMarker(target, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceContent = Array.isArray(source.content) ? source.content : [];
|
||||||
|
const targetContent = Array.isArray(target.content) ? target.content : [];
|
||||||
|
for (let index = 0; index < Math.min(sourceContent.length, targetContent.length); index += 1) {
|
||||||
|
const sourcePart = sourceContent[index];
|
||||||
|
const targetPart = targetContent[index];
|
||||||
|
if (!isRecord(sourcePart) || !isRecord(targetPart)) continue;
|
||||||
|
if (hasTerminalMarker(sourcePart, outputTextDoneMarker)) {
|
||||||
|
markTerminalMarker(targetPart, outputTextDoneMarker);
|
||||||
|
}
|
||||||
|
if (hasTerminalMarker(sourcePart, contentPartDoneMarker)) {
|
||||||
|
markTerminalMarker(targetPart, contentPartDoneMarker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSummary = Array.isArray(source.summary) ? source.summary : [];
|
||||||
|
const targetSummary = Array.isArray(target.summary) ? target.summary : [];
|
||||||
|
for (let index = 0; index < Math.min(sourceSummary.length, targetSummary.length); index += 1) {
|
||||||
|
const sourcePart = sourceSummary[index];
|
||||||
|
const targetPart = targetSummary[index];
|
||||||
|
if (!isRecord(sourcePart) || !isRecord(targetPart)) continue;
|
||||||
|
if (hasTerminalMarker(sourcePart, reasoningSummaryTextDoneMarker)) {
|
||||||
|
markTerminalMarker(targetPart, reasoningSummaryTextDoneMarker);
|
||||||
|
}
|
||||||
|
if (hasTerminalMarker(sourcePart, reasoningSummaryPartDoneMarker)) {
|
||||||
|
markTerminalMarker(targetPart, reasoningSummaryPartDoneMarker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTerminalStatus(value: unknown): boolean {
|
||||||
|
const status = asTrimmedString(value).toLowerCase();
|
||||||
|
return status === 'completed' || status === 'failed' || status === 'incomplete';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutputItemType(item: unknown, expectedType: string): boolean {
|
||||||
|
return isRecord(item) && asTrimmedString(item.type).toLowerCase() === expectedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCompatibleOutputIndex(
|
||||||
|
state: OpenAiResponsesAggregateState,
|
||||||
|
expectedType: string,
|
||||||
|
rawIndex: unknown,
|
||||||
|
...candidateIds: Array<unknown>
|
||||||
|
): number {
|
||||||
|
const resolved = resolveOutputIndex(state, rawIndex, ...candidateIds);
|
||||||
|
const existing = state.outputItems[resolved];
|
||||||
|
if (!existing || isOutputItemType(existing, expectedType)) return resolved;
|
||||||
|
return state.outputItems.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotOutputItemForAdded(item: AggregateOutputItem): AggregateOutputItem {
|
||||||
|
const next = cloneJson(item);
|
||||||
|
const itemType = asTrimmedString(next.type).toLowerCase();
|
||||||
|
|
||||||
|
if (itemType === 'message') {
|
||||||
|
next.content = [];
|
||||||
|
} else if (itemType === 'reasoning') {
|
||||||
|
next.summary = [];
|
||||||
|
} else if (itemType === 'function_call') {
|
||||||
|
next.arguments = '';
|
||||||
|
} else if (itemType === 'custom_tool_call') {
|
||||||
|
next.input = '';
|
||||||
|
} else if (itemType === 'image_generation_call') {
|
||||||
|
next.result = null;
|
||||||
|
next.partial_images = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
next.status = 'in_progress';
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeOutputItemAdded(index: number, item: AggregateOutputItem): string {
|
||||||
|
return serializeSse('response.output_item.added', {
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: index,
|
||||||
|
item: snapshotOutputItemForAdded(item),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshotPartForAdded(part: AggregateOutputItem): AggregateOutputItem {
|
||||||
|
const next = cloneJson(part);
|
||||||
|
const partType = asTrimmedString(next.type).toLowerCase();
|
||||||
|
if (
|
||||||
|
(partType === 'output_text' || partType === 'text' || partType === 'summary_text')
|
||||||
|
&& typeof next.text === 'string'
|
||||||
|
) {
|
||||||
|
next.text = '';
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeContentPartAdded(
|
||||||
|
outputIndex: number,
|
||||||
|
itemId: string,
|
||||||
|
contentIndex: number,
|
||||||
|
part: AggregateOutputItem,
|
||||||
|
): string {
|
||||||
|
return serializeSse('response.content_part.added', {
|
||||||
|
type: 'response.content_part.added',
|
||||||
|
output_index: outputIndex,
|
||||||
|
item_id: itemId,
|
||||||
|
content_index: contentIndex,
|
||||||
|
part: snapshotPartForAdded(part),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeReasoningSummaryPartAdded(
|
||||||
|
outputIndex: number,
|
||||||
|
itemId: string,
|
||||||
|
summaryIndex: number,
|
||||||
|
part: AggregateOutputItem,
|
||||||
|
): string {
|
||||||
|
return serializeSse('response.reasoning_summary_part.added', {
|
||||||
|
type: 'response.reasoning_summary_part.added',
|
||||||
|
item_id: itemId,
|
||||||
|
output_index: outputIndex,
|
||||||
|
summary_index: summaryIndex,
|
||||||
|
part: snapshotPartForAdded(part),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function setOutputItem(
|
function setOutputItem(
|
||||||
state: OpenAiResponsesAggregateState,
|
state: OpenAiResponsesAggregateState,
|
||||||
index: number,
|
index: number,
|
||||||
item: AggregateOutputItem,
|
item: AggregateOutputItem,
|
||||||
): AggregateOutputItem {
|
): AggregateOutputItem {
|
||||||
|
const existing = isRecord(state.outputItems[index]) ? state.outputItems[index] as AggregateOutputItem : undefined;
|
||||||
const current = cloneRecord(state.outputItems[index]) || {};
|
const current = cloneRecord(state.outputItems[index]) || {};
|
||||||
const incoming = cloneJson(item);
|
const incoming = cloneJson(item);
|
||||||
const next = {
|
const next = {
|
||||||
@@ -168,6 +305,7 @@ function setOutputItem(
|
|||||||
) {
|
) {
|
||||||
next.partial_images = current.partial_images;
|
next.partial_images = current.partial_images;
|
||||||
}
|
}
|
||||||
|
preserveTerminalMarkers(existing, next);
|
||||||
state.outputItems[index] = next;
|
state.outputItems[index] = next;
|
||||||
rememberOutputId(state, index, next);
|
rememberOutputId(state, index, next);
|
||||||
return next;
|
return next;
|
||||||
@@ -200,34 +338,53 @@ function resolveOutputIndex(
|
|||||||
return state.outputItems.length;
|
return state.outputItems.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMessageItem(state: OpenAiResponsesAggregateState, indexHint?: number): { index: number; item: AggregateOutputItem } {
|
function ensureMessageItem(
|
||||||
const index = state.messageIndex ?? indexHint ?? 0;
|
state: OpenAiResponsesAggregateState,
|
||||||
|
indexHint?: number,
|
||||||
|
itemIdRaw?: unknown,
|
||||||
|
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||||
|
const preferredIndex = state.messageIndex ?? indexHint ?? 0;
|
||||||
|
const itemId = asTrimmedString(itemIdRaw);
|
||||||
|
const index = resolveCompatibleOutputIndex(state, 'message', preferredIndex);
|
||||||
|
const created = !state.outputItems[index];
|
||||||
const item = ensureOutputItem(state, index, () => ({
|
const item = ensureOutputItem(state, index, () => ({
|
||||||
id: ensureOutputItemId('', 'msg', index),
|
id: ensureOutputItemId(itemId, 'msg', index),
|
||||||
type: 'message',
|
type: 'message',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
status: 'in_progress',
|
status: 'in_progress',
|
||||||
content: [],
|
content: [],
|
||||||
}));
|
}));
|
||||||
|
if (itemId) {
|
||||||
|
item.id = ensureOutputItemId(itemId, 'msg', index);
|
||||||
|
rememberOutputId(state, index, item);
|
||||||
|
}
|
||||||
if (!Array.isArray(item.content)) item.content = [];
|
if (!Array.isArray(item.content)) item.content = [];
|
||||||
if (state.messageIndex === null) state.messageIndex = index;
|
state.messageIndex = index;
|
||||||
return { index, item };
|
return { index, item, created };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureMessageOutputTextPart(
|
function ensureMessageOutputTextPart(
|
||||||
state: OpenAiResponsesAggregateState,
|
state: OpenAiResponsesAggregateState,
|
||||||
indexHint?: number,
|
indexHint?: number,
|
||||||
): { index: number; item: AggregateOutputItem; part: AggregateOutputItem; created: boolean } {
|
itemIdRaw?: unknown,
|
||||||
const { index, item } = ensureMessageItem(state, indexHint);
|
): {
|
||||||
|
index: number;
|
||||||
|
item: AggregateOutputItem;
|
||||||
|
part: AggregateOutputItem;
|
||||||
|
created: boolean;
|
||||||
|
itemCreated: boolean;
|
||||||
|
partCreated: boolean;
|
||||||
|
} {
|
||||||
|
const { index, item, created: itemCreated } = ensureMessageItem(state, indexHint, itemIdRaw);
|
||||||
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
|
const content = Array.isArray(item.content) ? item.content as AggregateOutputItem[] : [];
|
||||||
if (!Array.isArray(item.content)) item.content = content;
|
if (!Array.isArray(item.content)) item.content = content;
|
||||||
let part = content[0];
|
let part = content[0];
|
||||||
const created = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text';
|
const partCreated = !isRecord(part) || asTrimmedString(part.type).toLowerCase() !== 'output_text';
|
||||||
if (created) {
|
if (partCreated) {
|
||||||
part = { type: 'output_text', text: '' };
|
part = { type: 'output_text', text: '' };
|
||||||
content[0] = part;
|
content[0] = part;
|
||||||
}
|
}
|
||||||
return { index, item, part, created };
|
return { index, item, part, created: itemCreated || partCreated, itemCreated, partCreated };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureReasoningItem(
|
function ensureReasoningItem(
|
||||||
@@ -239,7 +396,9 @@ function ensureReasoningItem(
|
|||||||
const existingIndex = itemId
|
const existingIndex = itemId
|
||||||
? state.reasoningIndexById[itemId]
|
? state.reasoningIndexById[itemId]
|
||||||
: Object.values(state.reasoningIndexById)[0];
|
: Object.values(state.reasoningIndexById)[0];
|
||||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
|
const index = existingIndex !== undefined
|
||||||
|
? resolveCompatibleOutputIndex(state, 'reasoning', existingIndex, itemId)
|
||||||
|
: resolveCompatibleOutputIndex(state, 'reasoning', indexHint, itemId);
|
||||||
const created = !state.outputItems[index];
|
const created = !state.outputItems[index];
|
||||||
const item = ensureOutputItem(state, index, () => ({
|
const item = ensureOutputItem(state, index, () => ({
|
||||||
id: ensureOutputItemId(itemId, 'rs', index),
|
id: ensureOutputItemId(itemId, 'rs', index),
|
||||||
@@ -297,7 +456,9 @@ function ensureFunctionCallItem(
|
|||||||
const callId = asTrimmedString(callIdRaw);
|
const callId = asTrimmedString(callIdRaw);
|
||||||
const name = asTrimmedString(nameRaw);
|
const name = asTrimmedString(nameRaw);
|
||||||
const existingIndex = callId ? state.functionIndexById[callId] : undefined;
|
const existingIndex = callId ? state.functionIndexById[callId] : undefined;
|
||||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, callId);
|
const index = existingIndex !== undefined
|
||||||
|
? resolveCompatibleOutputIndex(state, 'function_call', existingIndex, callId)
|
||||||
|
: resolveCompatibleOutputIndex(state, 'function_call', indexHint, callId);
|
||||||
const created = !state.outputItems[index];
|
const created = !state.outputItems[index];
|
||||||
const item = ensureOutputItem(state, index, () => ({
|
const item = ensureOutputItem(state, index, () => ({
|
||||||
id: ensureOutputItemId(callId, 'fc', index),
|
id: ensureOutputItemId(callId, 'fc', index),
|
||||||
@@ -326,7 +487,9 @@ function ensureCustomToolItem(
|
|||||||
const existingIndex = (callId && state.customToolIndexById[callId] !== undefined)
|
const existingIndex = (callId && state.customToolIndexById[callId] !== undefined)
|
||||||
? state.customToolIndexById[callId]
|
? state.customToolIndexById[callId]
|
||||||
: (itemId && state.customToolIndexById[itemId] !== undefined ? state.customToolIndexById[itemId] : undefined);
|
: (itemId && state.customToolIndexById[itemId] !== undefined ? state.customToolIndexById[itemId] : undefined);
|
||||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId, callId);
|
const index = existingIndex !== undefined
|
||||||
|
? resolveCompatibleOutputIndex(state, 'custom_tool_call', existingIndex, itemId, callId)
|
||||||
|
: resolveCompatibleOutputIndex(state, 'custom_tool_call', indexHint, itemId, callId);
|
||||||
const created = !state.outputItems[index];
|
const created = !state.outputItems[index];
|
||||||
const item = ensureOutputItem(state, index, () => ({
|
const item = ensureOutputItem(state, index, () => ({
|
||||||
id: ensureOutputItemId(itemId, 'ct', index),
|
id: ensureOutputItemId(itemId, 'ct', index),
|
||||||
@@ -349,7 +512,9 @@ function ensureImageGenerationItem(
|
|||||||
): { index: number; item: AggregateOutputItem; created: boolean } {
|
): { index: number; item: AggregateOutputItem; created: boolean } {
|
||||||
const itemId = asTrimmedString(itemIdRaw);
|
const itemId = asTrimmedString(itemIdRaw);
|
||||||
const existingIndex = itemId ? state.imageGenerationIndexById[itemId] : undefined;
|
const existingIndex = itemId ? state.imageGenerationIndexById[itemId] : undefined;
|
||||||
const index = existingIndex ?? resolveOutputIndex(state, indexHint, itemId);
|
const index = existingIndex !== undefined
|
||||||
|
? resolveCompatibleOutputIndex(state, 'image_generation_call', existingIndex, itemId)
|
||||||
|
: resolveCompatibleOutputIndex(state, 'image_generation_call', indexHint, itemId);
|
||||||
const created = !state.outputItems[index];
|
const created = !state.outputItems[index];
|
||||||
const item = ensureOutputItem(state, index, () => ({
|
const item = ensureOutputItem(state, index, () => ({
|
||||||
id: ensureOutputItemId(itemId, 'img', index),
|
id: ensureOutputItemId(itemId, 'img', index),
|
||||||
@@ -394,6 +559,28 @@ function collectOutputText(state: OpenAiResponsesAggregateState): string {
|
|||||||
return parts.join('');
|
return parts.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hydrateStateFromTerminalResponseOutput(
|
||||||
|
state: OpenAiResponsesAggregateState,
|
||||||
|
responsePayload: Record<string, unknown> | null | undefined,
|
||||||
|
): void {
|
||||||
|
if (!isRecord(responsePayload) || !Array.isArray(responsePayload.output)) return;
|
||||||
|
|
||||||
|
for (let index = 0; index < responsePayload.output.length; index += 1) {
|
||||||
|
const item = responsePayload.output[index];
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (!itemType) continue;
|
||||||
|
const resolvedIndex = resolveCompatibleOutputIndex(
|
||||||
|
state,
|
||||||
|
itemType,
|
||||||
|
index,
|
||||||
|
item.id,
|
||||||
|
item.call_id,
|
||||||
|
);
|
||||||
|
setOutputItem(state, resolvedIndex, cloneJson(item));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function materializeResponse(
|
function materializeResponse(
|
||||||
state: OpenAiResponsesAggregateState,
|
state: OpenAiResponsesAggregateState,
|
||||||
streamContext: StreamTransformContext,
|
streamContext: StreamTransformContext,
|
||||||
@@ -413,7 +600,7 @@ function materializeResponse(
|
|||||||
? base.created_at
|
? base.created_at
|
||||||
: (typeof base.created === 'number' && Number.isFinite(base.created) ? base.created : null)
|
: (typeof base.created === 'number' && Number.isFinite(base.created) ? base.created : null)
|
||||||
) ?? state.createdAt ?? Math.floor(Date.now() / 1000);
|
) ?? state.createdAt ?? Math.floor(Date.now() / 1000);
|
||||||
const output = state.outputItems
|
const aggregatedOutput = state.outputItems
|
||||||
.filter((item): item is AggregateOutputItem => isRecord(item))
|
.filter((item): item is AggregateOutputItem => isRecord(item))
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const status = asTrimmedString(item.status).toLowerCase();
|
const status = asTrimmedString(item.status).toLowerCase();
|
||||||
@@ -424,6 +611,11 @@ function materializeResponse(
|
|||||||
: (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'),
|
: (state.failed ? 'failed' : state.incomplete ? 'incomplete' : 'completed'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const output = aggregatedOutput.length > 0
|
||||||
|
? aggregatedOutput
|
||||||
|
: (Array.isArray(base.output) ? cloneJson(base.output) : []);
|
||||||
|
const outputText = collectOutputText(state)
|
||||||
|
|| (typeof base.output_text === 'string' ? base.output_text : '');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -433,7 +625,7 @@ function materializeResponse(
|
|||||||
status: statusOverride ?? (state.failed ? 'failed' : state.incomplete ? 'incomplete' : '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: outputText,
|
||||||
usage: buildUsagePayload(usage),
|
usage: buildUsagePayload(usage),
|
||||||
...Object.keys(state.usageExtras).length > 0 ? { usage: { ...buildUsagePayload(usage), ...state.usageExtras } } : {},
|
...Object.keys(state.usageExtras).length > 0 ? { usage: { ...buildUsagePayload(usage), ...state.usageExtras } } : {},
|
||||||
};
|
};
|
||||||
@@ -497,6 +689,11 @@ function applyOriginalResponsesPayload(
|
|||||||
const itemType = asTrimmedString(item.type).toLowerCase();
|
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||||
if (itemType === 'reasoning') {
|
if (itemType === 'reasoning') {
|
||||||
const reasoningState = ensureReasoningItem(state, item.id, outputIndex);
|
const reasoningState = ensureReasoningItem(state, item.id, outputIndex);
|
||||||
|
const preservedSummary = (
|
||||||
|
(!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)
|
||||||
|
)
|
||||||
|
? reasoningState.item.summary
|
||||||
|
: null;
|
||||||
const next = {
|
const next = {
|
||||||
...reasoningState.item,
|
...reasoningState.item,
|
||||||
...item,
|
...item,
|
||||||
@@ -504,15 +701,24 @@ function applyOriginalResponsesPayload(
|
|||||||
if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) {
|
if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) {
|
||||||
next.status = 'completed';
|
next.status = 'completed';
|
||||||
}
|
}
|
||||||
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(reasoningState.item.summary)) {
|
if (preservedSummary) {
|
||||||
next.summary = reasoningState.item.summary;
|
next.summary = preservedSummary;
|
||||||
}
|
}
|
||||||
setOutputItem(state, reasoningState.index, next);
|
const stored = setOutputItem(state, reasoningState.index, next);
|
||||||
return serializeOriginalResponsesEvent(eventType, {
|
if (preservedSummary) {
|
||||||
|
stored.summary = preservedSummary;
|
||||||
|
}
|
||||||
|
if (eventType === 'response.output_item.done') {
|
||||||
|
markTerminalMarker(stored, outputItemDoneMarker);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
...(eventType === 'response.output_item.done' ? buildMissingSubordinateDoneEventsForItem(stored, reasoningState.index) : []),
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
...payload,
|
...payload,
|
||||||
output_index: reasoningState.index,
|
output_index: reasoningState.index,
|
||||||
item: state.outputItems[reasoningState.index] ?? payload.item,
|
item: state.outputItems[reasoningState.index] ?? payload.item,
|
||||||
});
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {};
|
const existing = isRecord(state.outputItems[outputIndex]) ? cloneRecord(state.outputItems[outputIndex]) || {} : {};
|
||||||
const next = {
|
const next = {
|
||||||
@@ -522,33 +728,60 @@ function applyOriginalResponsesPayload(
|
|||||||
if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) {
|
if (eventType === 'response.output_item.done' && !asTrimmedString(next.status)) {
|
||||||
next.status = 'completed';
|
next.status = 'completed';
|
||||||
}
|
}
|
||||||
if ((!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)) {
|
const preservedContent = (
|
||||||
next.content = existing.content;
|
(!Array.isArray(item.content) || item.content.length === 0) && Array.isArray(existing.content)
|
||||||
|
)
|
||||||
|
? existing.content
|
||||||
|
: null;
|
||||||
|
const preservedSummary = (
|
||||||
|
(!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary)
|
||||||
|
)
|
||||||
|
? existing.summary
|
||||||
|
: null;
|
||||||
|
const preservedPartialImages = (
|
||||||
|
(!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images)
|
||||||
|
)
|
||||||
|
? existing.partial_images
|
||||||
|
: null;
|
||||||
|
if (preservedContent) next.content = preservedContent;
|
||||||
|
if (preservedSummary) next.summary = preservedSummary;
|
||||||
|
if (preservedPartialImages) next.partial_images = preservedPartialImages;
|
||||||
|
const stored = setOutputItem(state, outputIndex, next);
|
||||||
|
if (preservedContent) stored.content = preservedContent;
|
||||||
|
if (preservedSummary) stored.summary = preservedSummary;
|
||||||
|
if (preservedPartialImages) stored.partial_images = preservedPartialImages;
|
||||||
|
if (eventType === 'response.output_item.done') {
|
||||||
|
markTerminalMarker(stored, outputItemDoneMarker);
|
||||||
}
|
}
|
||||||
if ((!Array.isArray(item.summary) || item.summary.length === 0) && Array.isArray(existing.summary)) {
|
|
||||||
next.summary = existing.summary;
|
|
||||||
}
|
}
|
||||||
if ((!Array.isArray(item.partial_images) || item.partial_images.length === 0) && Array.isArray(existing.partial_images)) {
|
const stored = state.outputItems[outputIndex];
|
||||||
next.partial_images = existing.partial_images;
|
return [
|
||||||
}
|
...(eventType === 'response.output_item.done' && isRecord(stored)
|
||||||
setOutputItem(state, outputIndex, next);
|
? buildMissingSubordinateDoneEventsForItem(stored, outputIndex)
|
||||||
}
|
: []),
|
||||||
return serializeOriginalResponsesEvent(eventType, {
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
...payload,
|
...payload,
|
||||||
item: state.outputItems[outputIndex] ?? payload.item,
|
item: state.outputItems[outputIndex] ?? payload.item,
|
||||||
});
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'response.content_part.added':
|
case 'response.content_part.added':
|
||||||
case 'response.content_part.done': {
|
case 'response.content_part.done': {
|
||||||
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
|
const lines: string[] = [];
|
||||||
|
const requestedOutputIndex = resolveCompatibleOutputIndex(state, 'message', payload.output_index, payload.item_id);
|
||||||
const contentIndex = typeof payload.content_index === 'number' && Number.isFinite(payload.content_index)
|
const contentIndex = typeof payload.content_index === 'number' && Number.isFinite(payload.content_index)
|
||||||
? Math.max(0, Math.trunc(payload.content_index))
|
? Math.max(0, Math.trunc(payload.content_index))
|
||||||
: 0;
|
: 0;
|
||||||
const message = ensureMessageItem(state, outputIndex).item;
|
const messageState = ensureMessageItem(state, requestedOutputIndex, payload.item_id);
|
||||||
|
const message = messageState.item;
|
||||||
|
if (messageState.created) {
|
||||||
|
lines.push(serializeOutputItemAdded(messageState.index, message));
|
||||||
|
}
|
||||||
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
|
const content = Array.isArray(message.content) ? message.content as AggregateOutputItem[] : [];
|
||||||
if (!Array.isArray(message.content)) message.content = content;
|
if (!Array.isArray(message.content)) message.content = content;
|
||||||
const existingPart = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : null;
|
const existingPart = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : null;
|
||||||
const incomingPart = cloneRecord(payload.part);
|
const incomingPart = cloneRecord(payload.part);
|
||||||
|
const partCreated = !existingPart && !!incomingPart;
|
||||||
if (incomingPart) {
|
if (incomingPart) {
|
||||||
content[contentIndex] = existingPart
|
content[contentIndex] = existingPart
|
||||||
? {
|
? {
|
||||||
@@ -558,59 +791,121 @@ function applyOriginalResponsesPayload(
|
|||||||
: incomingPart;
|
: incomingPart;
|
||||||
}
|
}
|
||||||
const part = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : existingPart;
|
const part = isRecord(content[contentIndex]) ? content[contentIndex] as AggregateOutputItem : existingPart;
|
||||||
|
if (eventType === 'response.content_part.done' && partCreated && part) {
|
||||||
|
lines.push(serializeContentPartAdded(messageState.index, asTrimmedString(message.id), contentIndex, part));
|
||||||
|
}
|
||||||
if (eventType === 'response.content_part.done' && part) {
|
if (eventType === 'response.content_part.done' && part) {
|
||||||
markTerminalMarker(part, contentPartDoneMarker);
|
markTerminalMarker(part, contentPartDoneMarker);
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return [
|
||||||
|
...lines,
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
|
...payload,
|
||||||
|
output_index: messageState.index,
|
||||||
|
item_id: asTrimmedString(message.id) || payload.item_id,
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'response.output_text.delta':
|
case 'response.output_text.delta':
|
||||||
case 'response.output_text.done': {
|
case 'response.output_text.done': {
|
||||||
const outputIndex = resolveOutputIndex(state, payload.output_index, payload.item_id);
|
const lines: string[] = [];
|
||||||
const textPart = ensureMessageOutputTextPart(state, outputIndex);
|
const outputIndex = resolveCompatibleOutputIndex(state, 'message', payload.output_index, payload.item_id);
|
||||||
|
const textPart = ensureMessageOutputTextPart(state, outputIndex, payload.item_id);
|
||||||
|
if (textPart.itemCreated) {
|
||||||
|
lines.push(serializeOutputItemAdded(textPart.index, textPart.item));
|
||||||
|
}
|
||||||
|
if (textPart.partCreated) {
|
||||||
|
lines.push(serializeContentPartAdded(textPart.index, asTrimmedString(textPart.item.id), 0, textPart.part));
|
||||||
|
}
|
||||||
if (eventType === 'response.output_text.done') {
|
if (eventType === 'response.output_text.done') {
|
||||||
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
textPart.part.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||||
markTerminalMarker(textPart.part, outputTextDoneMarker);
|
markTerminalMarker(textPart.part, outputTextDoneMarker);
|
||||||
} else {
|
} else {
|
||||||
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
textPart.part.text = `${typeof textPart.part.text === 'string' ? textPart.part.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return [
|
||||||
|
...lines,
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
|
...payload,
|
||||||
|
output_index: textPart.index,
|
||||||
|
item_id: asTrimmedString(textPart.item.id) || payload.item_id,
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'response.function_call_arguments.delta':
|
case 'response.function_call_arguments.delta':
|
||||||
case 'response.function_call_arguments.done': {
|
case 'response.function_call_arguments.done': {
|
||||||
|
const lines: string[] = [];
|
||||||
const entry = ensureFunctionCallItem(
|
const entry = ensureFunctionCallItem(
|
||||||
state,
|
state,
|
||||||
payload.call_id ?? payload.item_id,
|
payload.call_id ?? payload.item_id,
|
||||||
payload.name,
|
payload.name,
|
||||||
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
|
resolveCompatibleOutputIndex(state, 'function_call', payload.output_index, payload.item_id, payload.call_id),
|
||||||
);
|
);
|
||||||
|
if (entry.created) {
|
||||||
|
lines.push(serializeOutputItemAdded(entry.index, entry.item));
|
||||||
|
}
|
||||||
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);
|
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 : ''}`;
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return [
|
||||||
|
...lines,
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
|
...payload,
|
||||||
|
output_index: entry.index,
|
||||||
|
item_id: asTrimmedString(entry.item.id) || payload.item_id,
|
||||||
|
call_id: asTrimmedString(entry.item.call_id) || payload.call_id,
|
||||||
|
...(asTrimmedString(entry.item.name) ? { name: entry.item.name } : {}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'response.custom_tool_call_input.delta':
|
case 'response.custom_tool_call_input.delta':
|
||||||
case 'response.custom_tool_call_input.done': {
|
case 'response.custom_tool_call_input.done': {
|
||||||
|
const lines: string[] = [];
|
||||||
const entry = ensureCustomToolItem(
|
const entry = ensureCustomToolItem(
|
||||||
state,
|
state,
|
||||||
payload.item_id,
|
payload.item_id,
|
||||||
payload.call_id,
|
payload.call_id,
|
||||||
payload.name,
|
payload.name,
|
||||||
resolveOutputIndex(state, payload.output_index, payload.item_id, payload.call_id),
|
resolveCompatibleOutputIndex(state, 'custom_tool_call', payload.output_index, payload.item_id, payload.call_id),
|
||||||
);
|
);
|
||||||
|
if (entry.created) {
|
||||||
|
lines.push(serializeOutputItemAdded(entry.index, entry.item));
|
||||||
|
}
|
||||||
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);
|
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 : ''}`;
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return [
|
||||||
|
...lines,
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
|
...payload,
|
||||||
|
output_index: entry.index,
|
||||||
|
item_id: asTrimmedString(entry.item.id) || payload.item_id,
|
||||||
|
call_id: asTrimmedString(entry.item.call_id) || payload.call_id,
|
||||||
|
...(asTrimmedString(entry.item.name) ? { name: entry.item.name } : {}),
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'response.reasoning_summary_part.added':
|
case 'response.reasoning_summary_part.added':
|
||||||
case 'response.reasoning_summary_part.done': {
|
case 'response.reasoning_summary_part.done': {
|
||||||
|
const lines: string[] = [];
|
||||||
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
||||||
|
if (summaryState.itemCreated) {
|
||||||
|
lines.push(serializeOutputItemAdded(summaryState.index, summaryState.item));
|
||||||
|
}
|
||||||
|
if (summaryState.partCreated && eventType === 'response.reasoning_summary_part.done') {
|
||||||
|
lines.push(serializeReasoningSummaryPartAdded(
|
||||||
|
summaryState.index,
|
||||||
|
asTrimmedString(summaryState.item.id),
|
||||||
|
summaryState.summaryIndex,
|
||||||
|
summaryState.summary,
|
||||||
|
));
|
||||||
|
}
|
||||||
const part = cloneRecord(payload.part);
|
const part = cloneRecord(payload.part);
|
||||||
if (part) {
|
if (part) {
|
||||||
const summary = Array.isArray(summaryState.item.summary) ? summaryState.item.summary as AggregateOutputItem[] : [];
|
const summary = Array.isArray(summaryState.item.summary) ? summaryState.item.summary as AggregateOutputItem[] : [];
|
||||||
@@ -625,28 +920,58 @@ function applyOriginalResponsesPayload(
|
|||||||
} else if (eventType === 'response.reasoning_summary_part.done') {
|
} else if (eventType === 'response.reasoning_summary_part.done') {
|
||||||
markTerminalMarker(summaryState.summary, reasoningSummaryPartDoneMarker);
|
markTerminalMarker(summaryState.summary, reasoningSummaryPartDoneMarker);
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return [
|
||||||
|
...lines,
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
|
...payload,
|
||||||
|
output_index: summaryState.index,
|
||||||
|
item_id: asTrimmedString(summaryState.item.id) || payload.item_id,
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'response.reasoning_summary_text.delta':
|
case 'response.reasoning_summary_text.delta':
|
||||||
case 'response.reasoning_summary_text.done': {
|
case 'response.reasoning_summary_text.done': {
|
||||||
|
const lines: string[] = [];
|
||||||
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
const summaryState = ensureReasoningSummaryPart(state, payload.item_id, payload.summary_index, payload.output_index);
|
||||||
|
if (summaryState.itemCreated) {
|
||||||
|
lines.push(serializeOutputItemAdded(summaryState.index, summaryState.item));
|
||||||
|
}
|
||||||
|
if (summaryState.partCreated) {
|
||||||
|
lines.push(serializeReasoningSummaryPartAdded(
|
||||||
|
summaryState.index,
|
||||||
|
asTrimmedString(summaryState.item.id),
|
||||||
|
summaryState.summaryIndex,
|
||||||
|
summaryState.summary,
|
||||||
|
));
|
||||||
|
}
|
||||||
if (eventType === 'response.reasoning_summary_text.done') {
|
if (eventType === 'response.reasoning_summary_text.done') {
|
||||||
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
summaryState.summary.text = typeof payload.text === 'string' ? payload.text : String(payload.text ?? '');
|
||||||
markTerminalMarker(summaryState.summary, reasoningSummaryTextDoneMarker);
|
markTerminalMarker(summaryState.summary, reasoningSummaryTextDoneMarker);
|
||||||
} else {
|
} else {
|
||||||
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
summaryState.summary.text = `${typeof summaryState.summary.text === 'string' ? summaryState.summary.text : ''}${typeof payload.delta === 'string' ? payload.delta : ''}`;
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return [
|
||||||
|
...lines,
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
|
...payload,
|
||||||
|
output_index: summaryState.index,
|
||||||
|
item_id: asTrimmedString(summaryState.item.id) || payload.item_id,
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
case 'response.image_generation_call.generating':
|
case 'response.image_generation_call.generating':
|
||||||
case 'response.image_generation_call.in_progress':
|
case 'response.image_generation_call.in_progress':
|
||||||
case 'response.image_generation_call.partial_image':
|
case 'response.image_generation_call.partial_image':
|
||||||
case 'response.image_generation_call.completed': {
|
case 'response.image_generation_call.completed': {
|
||||||
|
const lines: string[] = [];
|
||||||
const entry = ensureImageGenerationItem(
|
const entry = ensureImageGenerationItem(
|
||||||
state,
|
state,
|
||||||
payload.item_id,
|
payload.item_id,
|
||||||
resolveOutputIndex(state, payload.output_index, payload.item_id),
|
resolveCompatibleOutputIndex(state, 'image_generation_call', payload.output_index, payload.item_id),
|
||||||
);
|
);
|
||||||
|
if (entry.created) {
|
||||||
|
lines.push(serializeOutputItemAdded(entry.index, entry.item));
|
||||||
|
}
|
||||||
if (eventType === 'response.image_generation_call.partial_image') {
|
if (eventType === 'response.image_generation_call.partial_image') {
|
||||||
const partialImages = Array.isArray(entry.item.partial_images) ? entry.item.partial_images as AggregateOutputItem[] : [];
|
const partialImages = Array.isArray(entry.item.partial_images) ? entry.item.partial_images as AggregateOutputItem[] : [];
|
||||||
partialImages.push({
|
partialImages.push({
|
||||||
@@ -662,13 +987,21 @@ function applyOriginalResponsesPayload(
|
|||||||
if (eventType === 'response.image_generation_call.completed') {
|
if (eventType === 'response.image_generation_call.completed') {
|
||||||
entry.item.status = 'completed';
|
entry.item.status = 'completed';
|
||||||
}
|
}
|
||||||
return serializeOriginalResponsesEvent(eventType, payload);
|
return [
|
||||||
|
...lines,
|
||||||
|
...serializeOriginalResponsesEvent(eventType, {
|
||||||
|
...payload,
|
||||||
|
output_index: entry.index,
|
||||||
|
item_id: asTrimmedString(entry.item.id) || payload.item_id,
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
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 responsePayload = cloneRecord(payload.response);
|
||||||
|
hydrateStateFromTerminalResponseOutput(state, responsePayload);
|
||||||
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'completed');
|
||||||
state.completed = true;
|
state.completed = true;
|
||||||
const responsePayload = cloneRecord(payload.response);
|
|
||||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed');
|
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'completed');
|
||||||
return [
|
return [
|
||||||
...terminalLines,
|
...terminalLines,
|
||||||
@@ -677,9 +1010,10 @@ function applyOriginalResponsesPayload(
|
|||||||
}
|
}
|
||||||
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 responsePayload = cloneRecord(payload.response);
|
||||||
|
hydrateStateFromTerminalResponseOutput(state, responsePayload);
|
||||||
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'failed');
|
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'failed');
|
||||||
state.failed = true;
|
state.failed = true;
|
||||||
const responsePayload = cloneRecord(payload.response);
|
|
||||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed');
|
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'failed');
|
||||||
return [
|
return [
|
||||||
...terminalLines,
|
...terminalLines,
|
||||||
@@ -688,9 +1022,10 @@ function applyOriginalResponsesPayload(
|
|||||||
}
|
}
|
||||||
case 'response.incomplete': {
|
case 'response.incomplete': {
|
||||||
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 responsePayload = cloneRecord(payload.response);
|
||||||
|
hydrateStateFromTerminalResponseOutput(state, responsePayload);
|
||||||
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete');
|
const terminalLines = buildSyntheticTerminalItemDoneEvents(state, 'incomplete');
|
||||||
state.incomplete = true;
|
state.incomplete = true;
|
||||||
const responsePayload = cloneRecord(payload.response);
|
|
||||||
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'incomplete');
|
const materialized = materializeResponse(state, streamContext, usage, responsePayload, 'incomplete');
|
||||||
return [
|
return [
|
||||||
...terminalLines,
|
...terminalLines,
|
||||||
@@ -707,31 +1042,23 @@ function buildSyntheticMessageEvents(
|
|||||||
state: OpenAiResponsesAggregateState,
|
state: OpenAiResponsesAggregateState,
|
||||||
delta: string,
|
delta: string,
|
||||||
): string[] {
|
): string[] {
|
||||||
const { index, item } = ensureMessageItem(state);
|
const textPartState = ensureMessageOutputTextPart(state);
|
||||||
const textPartState = ensureMessageOutputTextPart(state, index);
|
const { index } = textPartState;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const currentText = typeof textPartState.part.text === 'string' ? textPartState.part.text : '';
|
const currentText = typeof textPartState.part.text === 'string' ? textPartState.part.text : '';
|
||||||
const novelDelta = computeNovelDelta(currentText, delta);
|
const novelDelta = computeNovelDelta(currentText, delta);
|
||||||
if (textPartState.created) {
|
if (textPartState.itemCreated) {
|
||||||
lines.push(serializeSse('response.output_item.added', {
|
lines.push(serializeOutputItemAdded(index, textPartState.item));
|
||||||
type: 'response.output_item.added',
|
}
|
||||||
output_index: index,
|
if (textPartState.partCreated) {
|
||||||
item,
|
lines.push(serializeContentPartAdded(index, asTrimmedString(textPartState.item.id), 0, textPartState.part));
|
||||||
}));
|
|
||||||
lines.push(serializeSse('response.content_part.added', {
|
|
||||||
type: 'response.content_part.added',
|
|
||||||
output_index: index,
|
|
||||||
item_id: item.id,
|
|
||||||
content_index: 0,
|
|
||||||
part: { type: 'output_text', text: '' },
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
if (novelDelta) {
|
if (novelDelta) {
|
||||||
textPartState.part.text = `${currentText}${novelDelta}`;
|
textPartState.part.text = `${currentText}${novelDelta}`;
|
||||||
lines.push(serializeSse('response.output_text.delta', {
|
lines.push(serializeSse('response.output_text.delta', {
|
||||||
type: 'response.output_text.delta',
|
type: 'response.output_text.delta',
|
||||||
output_index: index,
|
output_index: index,
|
||||||
item_id: item.id,
|
item_id: textPartState.item.id,
|
||||||
delta: novelDelta,
|
delta: novelDelta,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -748,18 +1075,20 @@ function buildSyntheticReasoningEvents(
|
|||||||
const reasoningState = (signature || delta)
|
const reasoningState = (signature || delta)
|
||||||
? ensureReasoningItem(state, '', state.outputItems.length)
|
? ensureReasoningItem(state, '', state.outputItems.length)
|
||||||
: null;
|
: null;
|
||||||
|
let emittedOutputItemAdded = false;
|
||||||
|
|
||||||
|
const emitOutputItemAdded = (entry: { index: number; item: AggregateOutputItem; created: boolean } | null) => {
|
||||||
|
if (!entry?.created || emittedOutputItemAdded) return;
|
||||||
|
lines.push(serializeOutputItemAdded(entry.index, entry.item));
|
||||||
|
emittedOutputItemAdded = true;
|
||||||
|
};
|
||||||
|
|
||||||
if (reasoningState && signature) {
|
if (reasoningState && signature) {
|
||||||
reasoningState.item.encrypted_content = signature;
|
reasoningState.item.encrypted_content = signature;
|
||||||
if (reasoningState.created) {
|
|
||||||
lines.push(serializeSse('response.output_item.added', {
|
|
||||||
type: 'response.output_item.added',
|
|
||||||
output_index: reasoningState.index,
|
|
||||||
item: reasoningState.item,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emitOutputItemAdded(reasoningState);
|
||||||
|
|
||||||
if (!delta) {
|
if (!delta) {
|
||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
@@ -773,21 +1102,9 @@ function buildSyntheticReasoningEvents(
|
|||||||
const itemId = asTrimmedString(summaryState.item.id);
|
const itemId = asTrimmedString(summaryState.item.id);
|
||||||
const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : '';
|
const currentText = typeof summaryState.summary.text === 'string' ? summaryState.summary.text : '';
|
||||||
const novelDelta = computeNovelDelta(currentText, delta);
|
const novelDelta = computeNovelDelta(currentText, delta);
|
||||||
if (summaryState.itemCreated && !reasoningState?.created) {
|
emitOutputItemAdded(summaryState);
|
||||||
lines.push(serializeSse('response.output_item.added', {
|
|
||||||
type: 'response.output_item.added',
|
|
||||||
output_index: summaryState.index,
|
|
||||||
item: summaryState.item,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
if (summaryState.partCreated) {
|
if (summaryState.partCreated) {
|
||||||
lines.push(serializeSse('response.reasoning_summary_part.added', {
|
lines.push(serializeReasoningSummaryPartAdded(summaryState.index, itemId, 0, summaryState.summary));
|
||||||
type: 'response.reasoning_summary_part.added',
|
|
||||||
item_id: itemId,
|
|
||||||
output_index: summaryState.index,
|
|
||||||
summary_index: 0,
|
|
||||||
part: { type: 'summary_text', text: '' },
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
if (novelDelta) {
|
if (novelDelta) {
|
||||||
summaryState.summary.text = `${currentText}${novelDelta}`;
|
summaryState.summary.text = `${currentText}${novelDelta}`;
|
||||||
@@ -811,11 +1128,7 @@ function buildSyntheticToolEvents(
|
|||||||
for (const toolDelta of event.toolCallDeltas) {
|
for (const toolDelta of event.toolCallDeltas) {
|
||||||
const entry = ensureFunctionCallItem(state, toolDelta.id, toolDelta.name, toolDelta.index);
|
const entry = ensureFunctionCallItem(state, toolDelta.id, toolDelta.name, toolDelta.index);
|
||||||
if (entry.created) {
|
if (entry.created) {
|
||||||
lines.push(serializeSse('response.output_item.added', {
|
lines.push(serializeOutputItemAdded(entry.index, entry.item));
|
||||||
type: 'response.output_item.added',
|
|
||||||
output_index: entry.index,
|
|
||||||
item: entry.item,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) {
|
if (toolDelta.argumentsDelta !== undefined && toolDelta.argumentsDelta.length > 0) {
|
||||||
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${toolDelta.argumentsDelta}`;
|
entry.item.arguments = `${typeof entry.item.arguments === 'string' ? entry.item.arguments : ''}${toolDelta.argumentsDelta}`;
|
||||||
@@ -832,19 +1145,11 @@ function buildSyntheticToolEvents(
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSyntheticTerminalItemDoneEvents(
|
function buildMissingSubordinateDoneEventsForItem(
|
||||||
state: OpenAiResponsesAggregateState,
|
item: AggregateOutputItem,
|
||||||
status: 'completed' | 'failed' | 'incomplete',
|
index: number,
|
||||||
): string[] {
|
): string[] {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
|
|
||||||
for (let index = 0; index < state.outputItems.length; index += 1) {
|
|
||||||
const item = state.outputItems[index];
|
|
||||||
if (!isRecord(item)) continue;
|
|
||||||
|
|
||||||
const currentStatus = asTrimmedString(item.status).toLowerCase();
|
|
||||||
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);
|
||||||
item.id = itemId;
|
item.id = itemId;
|
||||||
@@ -935,12 +1240,34 @@ function buildSyntheticTerminalItemDoneEvents(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSyntheticTerminalItemDoneEvents(
|
||||||
|
state: OpenAiResponsesAggregateState,
|
||||||
|
status: 'completed' | 'failed' | 'incomplete',
|
||||||
|
): string[] {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < state.outputItems.length; index += 1) {
|
||||||
|
const item = state.outputItems[index];
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
|
||||||
|
const currentStatus = asTrimmedString(item.status).toLowerCase();
|
||||||
|
const itemTerminal = isTerminalStatus(currentStatus);
|
||||||
|
lines.push(...buildMissingSubordinateDoneEventsForItem(item, index));
|
||||||
|
|
||||||
|
if (!hasTerminalMarker(item, outputItemDoneMarker)) {
|
||||||
|
if (!itemTerminal) {
|
||||||
item.status = status;
|
item.status = status;
|
||||||
|
}
|
||||||
lines.push(serializeSse('response.output_item.done', {
|
lines.push(serializeSse('response.output_item.done', {
|
||||||
type: 'response.output_item.done',
|
type: 'response.output_item.done',
|
||||||
output_index: index,
|
output_index: index,
|
||||||
item: cloneJson(item),
|
item: cloneJson(item),
|
||||||
}));
|
}));
|
||||||
|
markTerminalMarker(item, outputItemDoneMarker);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
return !!value && typeof value === 'object';
|
return !!value && typeof value === 'object';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asTrimmedString(value: unknown): string {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
}
|
||||||
|
|
||||||
function hasNonEmptyString(value: unknown): boolean {
|
function hasNonEmptyString(value: unknown): boolean {
|
||||||
return typeof value === 'string' && value.trim().length > 0;
|
return typeof value === 'string' && value.trim().length > 0;
|
||||||
}
|
}
|
||||||
@@ -112,6 +116,59 @@ function getResponsesStreamFailureMessage(payload: unknown, fallback = 'upstream
|
|||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preserveMeaningfulTerminalResponsesPayload(
|
||||||
|
lines: string[],
|
||||||
|
eventType: 'response.completed' | 'response.incomplete',
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): string[] {
|
||||||
|
const responsePayload = isRecord(payload.response) ? payload.response : null;
|
||||||
|
if (!responsePayload || !hasMeaningfulResponsesPayloadOutput(responsePayload)) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = openAiResponsesStream.pullSseEvents(lines.join(''));
|
||||||
|
let replaced = false;
|
||||||
|
const nextLines: string[] = [];
|
||||||
|
|
||||||
|
for (const event of parsed.events) {
|
||||||
|
if (event.data === '[DONE]') {
|
||||||
|
nextLines.push('data: [DONE]\n\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedPayload: unknown = null;
|
||||||
|
try {
|
||||||
|
parsedPayload = JSON.parse(event.data);
|
||||||
|
} catch {
|
||||||
|
nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isRecord(parsedPayload)
|
||||||
|
&& asTrimmedString(parsedPayload.type) === eventType
|
||||||
|
&& isRecord(parsedPayload.response)
|
||||||
|
&& !hasMeaningfulResponsesPayloadOutput(parsedPayload.response)
|
||||||
|
) {
|
||||||
|
replaced = true;
|
||||||
|
nextLines.push(
|
||||||
|
`event: ${event.event || eventType}\ndata: ${JSON.stringify({
|
||||||
|
...parsedPayload,
|
||||||
|
response: {
|
||||||
|
...parsedPayload.response,
|
||||||
|
...responsePayload,
|
||||||
|
},
|
||||||
|
})}\n\n`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLines.push(`${event.event ? `event: ${event.event}\n` : ''}data: ${event.data}\n\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return replaced ? nextLines : lines;
|
||||||
|
}
|
||||||
|
|
||||||
export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) {
|
export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSessionInput) {
|
||||||
const streamContext = openAiResponsesStream.createContext(input.modelName);
|
const streamContext = openAiResponsesStream.createContext(input.modelName);
|
||||||
const responsesState = createOpenAiResponsesAggregateState(input.modelName);
|
const responsesState = createOpenAiResponsesAggregateState(input.modelName);
|
||||||
@@ -183,7 +240,7 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
parsedPayload = null;
|
parsedPayload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
if (isRecord(parsedPayload)) {
|
||||||
input.onParsedPayload?.(parsedPayload);
|
input.onParsedPayload?.(parsedPayload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,14 +259,19 @@ export function createResponsesProxyStreamSession(input: ResponsesProxyStreamSes
|
|||||||
}
|
}
|
||||||
const isIncompleteEvent = eventBlock.event === 'response.incomplete' || payloadType === 'response.incomplete';
|
const isIncompleteEvent = eventBlock.event === 'response.incomplete' || payloadType === 'response.incomplete';
|
||||||
|
|
||||||
if (parsedPayload && typeof parsedPayload === 'object') {
|
if (isRecord(parsedPayload)) {
|
||||||
const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName);
|
const normalizedEvent = openAiResponsesStream.normalizeEvent(parsedPayload, streamContext, input.modelName);
|
||||||
const convertedLines = serializeConvertedResponsesEvents({
|
let convertedLines = serializeConvertedResponsesEvents({
|
||||||
state: responsesState,
|
state: responsesState,
|
||||||
streamContext,
|
streamContext,
|
||||||
event: normalizedEvent,
|
event: normalizedEvent,
|
||||||
usage: input.getUsage(),
|
usage: input.getUsage(),
|
||||||
});
|
});
|
||||||
|
if (isIncompleteEvent) {
|
||||||
|
convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.incomplete', parsedPayload);
|
||||||
|
} else if (eventBlock.event === 'response.completed' || payloadType === 'response.completed') {
|
||||||
|
convertedLines = preserveMeaningfulTerminalResponsesPayload(convertedLines, 'response.completed', parsedPayload);
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(eventBlock.event === 'response.completed' || payloadType === 'response.completed')
|
(eventBlock.event === 'response.completed' || payloadType === 'response.completed')
|
||||||
&& shouldFailEmptyResponsesCompletion({
|
&& shouldFailEmptyResponsesCompletion({
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { createStreamTransformContext, normalizeUpstreamStreamEvent, convertClaudeRequestToOpenAiBody } from './chatFormatsCore.js';
|
import {
|
||||||
|
convertClaudeRequestToOpenAiBody,
|
||||||
|
createClaudeDownstreamContext,
|
||||||
|
createStreamTransformContext,
|
||||||
|
normalizeUpstreamStreamEvent,
|
||||||
|
serializeNormalizedStreamEvent,
|
||||||
|
} from './chatFormatsCore.js';
|
||||||
|
|
||||||
describe('chatFormatsCore inline think parsing', () => {
|
describe('chatFormatsCore inline think parsing', () => {
|
||||||
it('tracks split think tags across stream chunks', () => {
|
it('tracks split think tags across stream chunks', () => {
|
||||||
@@ -66,6 +72,275 @@ describe('chatFormatsCore inline think parsing', () => {
|
|||||||
contentDelta: 'visible answer',
|
contentDelta: 'visible answer',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats response.reasoning_summary_text.done as reasoning-only stream output', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.reasoning_summary_text.done',
|
||||||
|
item_id: 'rs_1',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
text: 'plan first',
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
reasoningDelta: 'plan first',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accumulates reasoning summary deltas before reconciling response.reasoning_summary_text.done', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.reasoning_summary_text.delta',
|
||||||
|
item_id: 'rs_multi',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
delta: 'plan ',
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
reasoningDelta: 'plan ',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.reasoning_summary_text.delta',
|
||||||
|
item_id: 'rs_multi',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
delta: 'first',
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
reasoningDelta: 'first',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.reasoning_summary_text.done',
|
||||||
|
item_id: 'rs_multi',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
text: 'plan first',
|
||||||
|
}, context, 'gpt-test')).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves terminal-only native responses output item payloads in stream normalization', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
role: 'assistant',
|
||||||
|
contentDelta: 'hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 1,
|
||||||
|
item: {
|
||||||
|
id: 'fc_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'lookup',
|
||||||
|
arguments: '{"q":"x"}',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookup',
|
||||||
|
argumentsDelta: '{"q":"x"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps responses tool-call indices stable when response.completed replays mixed output arrays', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
const claudeContext = createClaudeDownstreamContext();
|
||||||
|
|
||||||
|
const streamingDelta = normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.function_call_arguments.delta',
|
||||||
|
output_index: 1,
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'lookup',
|
||||||
|
delta: '{"q":"x"}',
|
||||||
|
}, context, 'gpt-test');
|
||||||
|
|
||||||
|
expect(streamingDelta).toEqual({
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'lookup',
|
||||||
|
argumentsDelta: '{"q":"x"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
expect(serializeNormalizedStreamEvent('openai', streamingDelta, context, claudeContext)).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_3',
|
||||||
|
model: 'gpt-test',
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'working on it' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fc_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'lookup',
|
||||||
|
arguments: '{"q":"x"}',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
role: 'assistant',
|
||||||
|
contentDelta: 'working on it',
|
||||||
|
finishReason: 'stop',
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves terminal response.completed payload output when it carries the only final content', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_1',
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
role: 'assistant',
|
||||||
|
contentDelta: 'hello',
|
||||||
|
finishReason: 'stop',
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves streamed trailing whitespace when reconciling response.completed content', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.output_text.delta',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'msg_ws_space',
|
||||||
|
delta: 'hello ',
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
contentDelta: 'hello ',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_space_1',
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_ws_space',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'hello world' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
role: 'assistant',
|
||||||
|
contentDelta: 'world',
|
||||||
|
finishReason: 'stop',
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves terminal response.completed custom tool metadata in stream normalization', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_2',
|
||||||
|
model: 'gpt-test',
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'ct_1',
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
call_id: 'call_custom_1',
|
||||||
|
name: 'Shell',
|
||||||
|
input: '{"command":"pwd"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_custom_1',
|
||||||
|
name: 'Shell',
|
||||||
|
argumentsDelta: '{"command":"pwd"}',
|
||||||
|
}],
|
||||||
|
finishReason: 'tool_calls',
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes custom tool calls through the existing tool-call stream shape', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.output_item.added',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'ct_1',
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
call_id: 'call_custom',
|
||||||
|
name: 'MyTool',
|
||||||
|
input: '',
|
||||||
|
},
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_custom',
|
||||||
|
name: 'MyTool',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.custom_tool_call_input.done',
|
||||||
|
output_index: 0,
|
||||||
|
item_id: 'ct_1',
|
||||||
|
call_id: 'call_custom',
|
||||||
|
name: 'MyTool',
|
||||||
|
input: '{"path":"README.md"}',
|
||||||
|
}, context, 'gpt-test')).toEqual({
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_custom',
|
||||||
|
name: 'MyTool',
|
||||||
|
argumentsDelta: '{"path":"README.md"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('convertClaudeRequestToOpenAiBody', () => {
|
describe('convertClaudeRequestToOpenAiBody', () => {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export type StreamTransformContext = {
|
|||||||
roleSent: boolean;
|
roleSent: boolean;
|
||||||
doneSent: boolean;
|
doneSent: boolean;
|
||||||
toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>;
|
toolCalls: Record<number, { id?: string; name?: string; arguments?: string }>;
|
||||||
|
responsesToolCallIndexByOutputIndex: Record<number, number>;
|
||||||
|
responsesToolCallIndexById: Record<string, number>;
|
||||||
|
nextResponsesToolCallIndex: number;
|
||||||
|
responsesTextByIndex: Record<number, string>;
|
||||||
|
responsesReasoningByIndex: Record<number, string>;
|
||||||
thinkTagParser: ThinkTagParserState;
|
thinkTagParser: ThinkTagParserState;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,6 +110,24 @@ function joinNonEmpty(parts: string[]): string {
|
|||||||
return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n');
|
return parts.map((item) => item.trim()).filter((item) => item.length > 0).join('\n\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function joinIndexedResponsesText(partsByIndex: Record<number, string>): string {
|
||||||
|
const indexedParts = Object.entries(partsByIndex)
|
||||||
|
.map(([rawIndex, text]) => ({
|
||||||
|
index: Number(rawIndex),
|
||||||
|
text,
|
||||||
|
}))
|
||||||
|
.filter((entry) => Number.isFinite(entry.index) && entry.index >= 0 && typeof entry.text === 'string' && entry.text.length > 0)
|
||||||
|
.sort((left, right) => left.index - right.index)
|
||||||
|
.map((entry) => entry.text);
|
||||||
|
|
||||||
|
if (indexedParts.length > 0) {
|
||||||
|
return indexedParts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = partsByIndex[-1];
|
||||||
|
return typeof snapshot === 'string' ? snapshot : '';
|
||||||
|
}
|
||||||
|
|
||||||
function textFromPart(part: unknown): string {
|
function textFromPart(part: unknown): string {
|
||||||
if (typeof part === 'string') return part;
|
if (typeof part === 'string') return part;
|
||||||
if (!isRecord(part)) return '';
|
if (!isRecord(part)) return '';
|
||||||
@@ -256,6 +279,11 @@ export function createStreamTransformContext(modelName: string): StreamTransform
|
|||||||
roleSent: false,
|
roleSent: false,
|
||||||
doneSent: false,
|
doneSent: false,
|
||||||
toolCalls: {},
|
toolCalls: {},
|
||||||
|
responsesToolCallIndexByOutputIndex: {},
|
||||||
|
responsesToolCallIndexById: {},
|
||||||
|
nextResponsesToolCallIndex: 0,
|
||||||
|
responsesTextByIndex: {},
|
||||||
|
responsesReasoningByIndex: {},
|
||||||
thinkTagParser: createThinkTagParserState(),
|
thinkTagParser: createThinkTagParserState(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -465,6 +493,30 @@ function parseResponsesReasoning(payload: Record<string, unknown>): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapTerminalResponsesEnvelope(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const type = asTrimmedString(payload.type).toLowerCase();
|
||||||
|
if (
|
||||||
|
type !== 'response.completed'
|
||||||
|
&& type !== 'response.failed'
|
||||||
|
&& type !== 'response.incomplete'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isRecord(payload.response)) return null;
|
||||||
|
|
||||||
|
const responsePayload = payload.response as Record<string, unknown>;
|
||||||
|
if (isNonEmptyString(responsePayload.status)) {
|
||||||
|
return responsePayload;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...responsePayload,
|
||||||
|
status: type.slice('response.'.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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 '';
|
||||||
@@ -558,7 +610,7 @@ function collectToolCallsFromResponsesPayload(payload: Record<string, unknown>):
|
|||||||
for (let index = 0; index < output.length; index += 1) {
|
for (let index = 0; index < output.length; index += 1) {
|
||||||
const item = output[index];
|
const item = output[index];
|
||||||
if (!isRecord(item)) continue;
|
if (!isRecord(item)) continue;
|
||||||
if (item.type !== 'function_call') continue;
|
if (item.type !== 'function_call' && item.type !== 'custom_tool_call') continue;
|
||||||
|
|
||||||
const id = (
|
const id = (
|
||||||
typeof item.call_id === 'string' && item.call_id.trim().length > 0
|
typeof item.call_id === 'string' && item.call_id.trim().length > 0
|
||||||
@@ -569,7 +621,9 @@ function collectToolCallsFromResponsesPayload(payload: Record<string, unknown>):
|
|||||||
const argumentsText = (
|
const argumentsText = (
|
||||||
typeof item.arguments === 'string'
|
typeof item.arguments === 'string'
|
||||||
? item.arguments
|
? item.arguments
|
||||||
: stringifyUnknownValue(item.arguments)
|
: (typeof item.input === 'string'
|
||||||
|
? item.input
|
||||||
|
: stringifyUnknownValue(item.arguments ?? item.input))
|
||||||
);
|
);
|
||||||
toolCalls.push({
|
toolCalls.push({
|
||||||
id,
|
id,
|
||||||
@@ -581,6 +635,198 @@ function collectToolCallsFromResponsesPayload(payload: Record<string, unknown>):
|
|||||||
return toolCalls;
|
return toolCalls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectIndexedToolCallsFromResponsesPayload(
|
||||||
|
payload: Record<string, unknown>,
|
||||||
|
): Array<{ id: string; name: string; arguments: string; outputIndex: number }> {
|
||||||
|
const output = Array.isArray(payload.output) ? payload.output : [];
|
||||||
|
const toolCalls: Array<{ id: string; name: string; arguments: string; outputIndex: number }> = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < output.length; index += 1) {
|
||||||
|
const item = output[index];
|
||||||
|
if (!isRecord(item)) continue;
|
||||||
|
if (item.type !== 'function_call' && item.type !== 'custom_tool_call') continue;
|
||||||
|
|
||||||
|
const id = (
|
||||||
|
typeof item.call_id === 'string' && item.call_id.trim().length > 0
|
||||||
|
? item.call_id.trim()
|
||||||
|
: (typeof item.id === 'string' && item.id.trim().length > 0 ? item.id.trim() : `call_${index}`)
|
||||||
|
);
|
||||||
|
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
||||||
|
const argumentsText = (
|
||||||
|
typeof item.arguments === 'string'
|
||||||
|
? item.arguments
|
||||||
|
: (typeof item.input === 'string'
|
||||||
|
? item.input
|
||||||
|
: stringifyUnknownValue(item.arguments ?? item.input))
|
||||||
|
);
|
||||||
|
toolCalls.push({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
arguments: argumentsText,
|
||||||
|
outputIndex: index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeNovelResponsesDelta(existingText: string, incomingText: string): string {
|
||||||
|
if (!incomingText) return '';
|
||||||
|
if (!existingText) return incomingText;
|
||||||
|
if (incomingText.startsWith(existingText)) return incomingText.slice(existingText.length);
|
||||||
|
if (existingText.endsWith(incomingText)) return '';
|
||||||
|
|
||||||
|
const maxOverlap = Math.min(existingText.length, incomingText.length);
|
||||||
|
for (let overlap = maxOverlap; overlap > 0; overlap -= 1) {
|
||||||
|
if (existingText.endsWith(incomingText.slice(0, overlap))) {
|
||||||
|
return incomingText.slice(overlap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return incomingText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResponsesOutputIndex(payload: Record<string, unknown>): number {
|
||||||
|
return (
|
||||||
|
typeof payload.output_index === 'number' && Number.isFinite(payload.output_index)
|
||||||
|
? Math.max(0, Math.trunc(payload.output_index))
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractResponsesItemText(item: Record<string, unknown>): string {
|
||||||
|
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (itemType === 'message') {
|
||||||
|
return extractTextAndReasoning(item.content ?? item).content;
|
||||||
|
}
|
||||||
|
if (itemType === 'reasoning') {
|
||||||
|
const parsed = extractTextAndReasoning(item.summary ?? item.content ?? item);
|
||||||
|
return joinNonEmpty([parsed.content, parsed.reasoning]);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberResponsesToolCallIndex(
|
||||||
|
context: StreamTransformContext,
|
||||||
|
canonicalIndex: number,
|
||||||
|
input: {
|
||||||
|
outputIndex?: number;
|
||||||
|
itemId?: unknown;
|
||||||
|
callId?: unknown;
|
||||||
|
},
|
||||||
|
): void {
|
||||||
|
if (typeof input.outputIndex === 'number' && Number.isFinite(input.outputIndex)) {
|
||||||
|
context.responsesToolCallIndexByOutputIndex[Math.max(0, Math.trunc(input.outputIndex))] = canonicalIndex;
|
||||||
|
}
|
||||||
|
const itemId = asTrimmedString(input.itemId);
|
||||||
|
if (itemId) {
|
||||||
|
context.responsesToolCallIndexById[`item:${itemId}`] = canonicalIndex;
|
||||||
|
}
|
||||||
|
const callId = asTrimmedString(input.callId);
|
||||||
|
if (callId) {
|
||||||
|
context.responsesToolCallIndexById[`call:${callId}`] = canonicalIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResponsesToolCallIndex(
|
||||||
|
context: StreamTransformContext,
|
||||||
|
input: {
|
||||||
|
outputIndex?: number;
|
||||||
|
itemId?: unknown;
|
||||||
|
callId?: unknown;
|
||||||
|
},
|
||||||
|
): number {
|
||||||
|
const normalizedOutputIndex = (
|
||||||
|
typeof input.outputIndex === 'number' && Number.isFinite(input.outputIndex)
|
||||||
|
? Math.max(0, Math.trunc(input.outputIndex))
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
normalizedOutputIndex !== undefined
|
||||||
|
&& context.responsesToolCallIndexByOutputIndex[normalizedOutputIndex] !== undefined
|
||||||
|
) {
|
||||||
|
const canonicalIndex = context.responsesToolCallIndexByOutputIndex[normalizedOutputIndex]!;
|
||||||
|
rememberResponsesToolCallIndex(context, canonicalIndex, {
|
||||||
|
outputIndex: normalizedOutputIndex,
|
||||||
|
itemId: input.itemId,
|
||||||
|
callId: input.callId,
|
||||||
|
});
|
||||||
|
return canonicalIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemId = asTrimmedString(input.itemId);
|
||||||
|
if (itemId && context.responsesToolCallIndexById[`item:${itemId}`] !== undefined) {
|
||||||
|
const canonicalIndex = context.responsesToolCallIndexById[`item:${itemId}`]!;
|
||||||
|
rememberResponsesToolCallIndex(context, canonicalIndex, {
|
||||||
|
outputIndex: normalizedOutputIndex,
|
||||||
|
itemId,
|
||||||
|
callId: input.callId,
|
||||||
|
});
|
||||||
|
return canonicalIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callId = asTrimmedString(input.callId);
|
||||||
|
if (callId && context.responsesToolCallIndexById[`call:${callId}`] !== undefined) {
|
||||||
|
const canonicalIndex = context.responsesToolCallIndexById[`call:${callId}`]!;
|
||||||
|
rememberResponsesToolCallIndex(context, canonicalIndex, {
|
||||||
|
outputIndex: normalizedOutputIndex,
|
||||||
|
itemId: input.itemId,
|
||||||
|
callId,
|
||||||
|
});
|
||||||
|
return canonicalIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalIndex = context.nextResponsesToolCallIndex;
|
||||||
|
context.nextResponsesToolCallIndex += 1;
|
||||||
|
rememberResponsesToolCallIndex(context, canonicalIndex, {
|
||||||
|
outputIndex: normalizedOutputIndex,
|
||||||
|
itemId: input.itemId,
|
||||||
|
callId: input.callId,
|
||||||
|
});
|
||||||
|
return canonicalIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResponsesToolCallDeltaFromItem(
|
||||||
|
item: Record<string, unknown>,
|
||||||
|
outputIndex: number,
|
||||||
|
context: StreamTransformContext,
|
||||||
|
): NormalizedStreamEvent | null {
|
||||||
|
const itemType = asTrimmedString(item.type).toLowerCase();
|
||||||
|
if (itemType !== 'function_call' && itemType !== 'custom_tool_call') return null;
|
||||||
|
|
||||||
|
const toolCallId = (
|
||||||
|
isNonEmptyString(item.call_id) ? item.call_id
|
||||||
|
: (isNonEmptyString(item.id) ? item.id : undefined)
|
||||||
|
);
|
||||||
|
const toolName = isNonEmptyString(item.name) ? item.name : undefined;
|
||||||
|
const rawArguments = itemType === 'custom_tool_call'
|
||||||
|
? (typeof item.input === 'string' ? item.input : stringifyUnknownValue(item.input))
|
||||||
|
: (typeof item.arguments === 'string' ? item.arguments : stringifyUnknownValue(item.arguments));
|
||||||
|
const canonicalIndex = resolveResponsesToolCallIndex(context, {
|
||||||
|
outputIndex,
|
||||||
|
itemId: item.id,
|
||||||
|
callId: item.call_id,
|
||||||
|
});
|
||||||
|
const existingArguments = context.toolCalls[canonicalIndex]?.arguments || '';
|
||||||
|
const argumentsDelta = computeNovelResponsesDelta(existingArguments, rawArguments);
|
||||||
|
const knownTool = context.toolCalls[canonicalIndex] || {};
|
||||||
|
const shouldBackfillId = !!toolCallId && !knownTool.id;
|
||||||
|
const shouldBackfillName = !!toolName && !knownTool.name;
|
||||||
|
|
||||||
|
if (!argumentsDelta && !shouldBackfillId && !shouldBackfillName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: canonicalIndex,
|
||||||
|
id: toolCallId,
|
||||||
|
name: toolName,
|
||||||
|
argumentsDelta: argumentsDelta || undefined,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatAnthropicBase64DataUrl(mimeType: string, data: string): string {
|
function formatAnthropicBase64DataUrl(mimeType: string, data: string): string {
|
||||||
return `data:${mimeType};base64,${data}`;
|
return `data:${mimeType};base64,${data}`;
|
||||||
}
|
}
|
||||||
@@ -1067,6 +1313,13 @@ export function normalizeUpstreamFinalResponse(
|
|||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const fallbackId = `chatcmpl-meta-${Date.now()}`;
|
const fallbackId = `chatcmpl-meta-${Date.now()}`;
|
||||||
|
|
||||||
|
if (isRecord(payload)) {
|
||||||
|
const terminalResponsesPayload = unwrapTerminalResponsesEnvelope(payload);
|
||||||
|
if (terminalResponsesPayload) {
|
||||||
|
return normalizeUpstreamFinalResponse(terminalResponsesPayload, fallbackModel, fallbackText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isRecord(payload) && Array.isArray(payload.choices)) {
|
if (isRecord(payload) && Array.isArray(payload.choices)) {
|
||||||
const choice = payload.choices[0] ?? {};
|
const choice = payload.choices[0] ?? {};
|
||||||
const content = extractAssistantContent(choice) || extractAssistantContent(payload);
|
const content = extractAssistantContent(choice) || extractAssistantContent(payload);
|
||||||
@@ -1238,19 +1491,39 @@ export function normalizeUpstreamStreamEvent(
|
|||||||
|
|
||||||
const type = typeof payload.type === 'string' ? payload.type : '';
|
const type = typeof payload.type === 'string' ? payload.type : '';
|
||||||
if (type.startsWith('response.output_text')) {
|
if (type.startsWith('response.output_text')) {
|
||||||
const parsed = extractStreamingTextAndReasoning(payload.delta, context.thinkTagParser);
|
const outputIndex = extractResponsesOutputIndex(payload);
|
||||||
|
const rawText = typeof payload.delta === 'string'
|
||||||
|
? payload.delta
|
||||||
|
: (typeof (payload as any).text === 'string' ? (payload as any).text : '');
|
||||||
|
const parsed = extractStreamingTextAndReasoning(rawText, context.thinkTagParser);
|
||||||
|
const nextContent = type === 'response.output_text.done'
|
||||||
|
? (parsed.content || context.responsesTextByIndex[outputIndex] || '')
|
||||||
|
: `${context.responsesTextByIndex[outputIndex] || ''}${parsed.content || ''}`;
|
||||||
|
const novelContent = type === 'response.output_text.done'
|
||||||
|
? computeNovelResponsesDelta(context.responsesTextByIndex[outputIndex] || '', parsed.content || '')
|
||||||
|
: (parsed.content || '');
|
||||||
|
if (nextContent) context.responsesTextByIndex[outputIndex] = nextContent;
|
||||||
return {
|
return {
|
||||||
contentDelta: parsed.content || undefined,
|
contentDelta: novelContent || undefined,
|
||||||
reasoningDelta: parsed.reasoning || undefined,
|
reasoningDelta: parsed.reasoning || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'response.reasoning_summary_text.delta') {
|
if (type === 'response.reasoning_summary_text.delta' || type === 'response.reasoning_summary_text.done') {
|
||||||
const deltaText = typeof payload.delta === 'string'
|
const outputIndex = extractResponsesOutputIndex(payload);
|
||||||
? payload.delta
|
const deltaText = type === 'response.reasoning_summary_text.done'
|
||||||
: extractTextAndReasoning(payload.delta).content;
|
? (typeof (payload as any).text === 'string' ? (payload as any).text : extractTextAndReasoning(payload.text).content)
|
||||||
|
: (typeof payload.delta === 'string' ? payload.delta : extractTextAndReasoning(payload.delta).content);
|
||||||
|
const previousReasoning = context.responsesReasoningByIndex[outputIndex] || '';
|
||||||
|
const novelDelta = computeNovelResponsesDelta(previousReasoning, deltaText);
|
||||||
|
const nextReasoning = type === 'response.reasoning_summary_text.done'
|
||||||
|
? (deltaText || previousReasoning)
|
||||||
|
: `${previousReasoning}${novelDelta}`;
|
||||||
|
if (nextReasoning) {
|
||||||
|
context.responsesReasoningByIndex[outputIndex] = nextReasoning;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
reasoningDelta: deltaText || undefined,
|
reasoningDelta: novelDelta || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1267,40 +1540,44 @@ export function normalizeUpstreamStreamEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'response.output_item.added' && isRecord((payload as any).item)) {
|
if ((type === 'response.output_item.added' || type === 'response.output_item.done') && isRecord((payload as any).item)) {
|
||||||
|
const outputIndex = extractResponsesOutputIndex(payload as Record<string, unknown>);
|
||||||
const item = (payload as any).item as Record<string, unknown>;
|
const item = (payload as any).item as Record<string, unknown>;
|
||||||
if (item.type === 'reasoning' && isNonEmptyString(item.encrypted_content)) {
|
if (item.type === 'reasoning' && isNonEmptyString(item.encrypted_content)) {
|
||||||
|
const reasoningText = extractResponsesItemText(item);
|
||||||
|
const novelReasoning = computeNovelResponsesDelta(context.responsesReasoningByIndex[outputIndex] || '', reasoningText);
|
||||||
|
if (reasoningText) {
|
||||||
|
context.responsesReasoningByIndex[outputIndex] = reasoningText;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
reasoningSignature: item.encrypted_content,
|
reasoningSignature: item.encrypted_content,
|
||||||
|
reasoningDelta: novelReasoning || undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (item.type === 'function_call') {
|
const toolCallEvent = buildResponsesToolCallDeltaFromItem(item, outputIndex, context);
|
||||||
const outputIndex = (
|
if (toolCallEvent) {
|
||||||
typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index)
|
return toolCallEvent;
|
||||||
? Math.max(0, Math.trunc((payload as any).output_index))
|
}
|
||||||
: 0
|
if (item.type === 'message') {
|
||||||
);
|
const fullText = extractResponsesItemText(item);
|
||||||
const toolCallId = (
|
const novelDelta = computeNovelResponsesDelta(context.responsesTextByIndex[outputIndex] || '', fullText);
|
||||||
isNonEmptyString(item.call_id) ? item.call_id
|
if (fullText) {
|
||||||
: (isNonEmptyString(item.id) ? item.id : undefined)
|
context.responsesTextByIndex[outputIndex] = fullText;
|
||||||
);
|
}
|
||||||
const toolName = isNonEmptyString(item.name) ? item.name : undefined;
|
|
||||||
return {
|
return {
|
||||||
toolCallDeltas: [{
|
role: item.role === 'assistant' ? 'assistant' : undefined,
|
||||||
index: outputIndex,
|
contentDelta: novelDelta || undefined,
|
||||||
id: toolCallId,
|
|
||||||
name: toolName,
|
|
||||||
}],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'response.function_call_arguments.delta' || type === 'response.function_call_arguments.done') {
|
if (type === 'response.function_call_arguments.delta' || type === 'response.function_call_arguments.done') {
|
||||||
const outputIndex = (
|
const outputIndex = extractResponsesOutputIndex(payload as Record<string, unknown>);
|
||||||
typeof (payload as any).output_index === 'number' && Number.isFinite((payload as any).output_index)
|
const canonicalIndex = resolveResponsesToolCallIndex(context, {
|
||||||
? Math.max(0, Math.trunc((payload as any).output_index))
|
outputIndex,
|
||||||
: 0
|
itemId: (payload as any).item_id,
|
||||||
);
|
callId: (payload as any).call_id,
|
||||||
|
});
|
||||||
const toolCallId = (
|
const toolCallId = (
|
||||||
isNonEmptyString((payload as any).call_id) ? (payload as any).call_id
|
isNonEmptyString((payload as any).call_id) ? (payload as any).call_id
|
||||||
: (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined)
|
: (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined)
|
||||||
@@ -1317,7 +1594,7 @@ export function normalizeUpstreamStreamEvent(
|
|||||||
);
|
);
|
||||||
let argumentsDelta = rawArguments;
|
let argumentsDelta = rawArguments;
|
||||||
if (type === 'response.function_call_arguments.done' && typeof rawArguments === 'string') {
|
if (type === 'response.function_call_arguments.done' && typeof rawArguments === 'string') {
|
||||||
const existingArguments = context.toolCalls[outputIndex]?.arguments || '';
|
const existingArguments = context.toolCalls[canonicalIndex]?.arguments || '';
|
||||||
if (existingArguments && rawArguments.startsWith(existingArguments)) {
|
if (existingArguments && rawArguments.startsWith(existingArguments)) {
|
||||||
const missingSuffix = rawArguments.slice(existingArguments.length);
|
const missingSuffix = rawArguments.slice(existingArguments.length);
|
||||||
argumentsDelta = missingSuffix.length > 0 ? missingSuffix : undefined;
|
argumentsDelta = missingSuffix.length > 0 ? missingSuffix : undefined;
|
||||||
@@ -1326,7 +1603,7 @@ export function normalizeUpstreamStreamEvent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const knownTool = context.toolCalls[outputIndex] || {};
|
const knownTool = context.toolCalls[canonicalIndex] || {};
|
||||||
const shouldBackfillId = !!toolCallId && !knownTool.id;
|
const shouldBackfillId = !!toolCallId && !knownTool.id;
|
||||||
const shouldBackfillName = !!toolName && !knownTool.name;
|
const shouldBackfillName = !!toolName && !knownTool.name;
|
||||||
if (argumentsDelta === undefined && !shouldBackfillId && !shouldBackfillName) {
|
if (argumentsDelta === undefined && !shouldBackfillId && !shouldBackfillName) {
|
||||||
@@ -1335,7 +1612,7 @@ export function normalizeUpstreamStreamEvent(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
toolCallDeltas: [{
|
toolCallDeltas: [{
|
||||||
index: outputIndex,
|
index: canonicalIndex,
|
||||||
id: toolCallId,
|
id: toolCallId,
|
||||||
name: toolName,
|
name: toolName,
|
||||||
argumentsDelta,
|
argumentsDelta,
|
||||||
@@ -1343,12 +1620,97 @@ export function normalizeUpstreamStreamEvent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'response.custom_tool_call_input.delta' || type === 'response.custom_tool_call_input.done') {
|
||||||
|
const outputIndex = extractResponsesOutputIndex(payload as Record<string, unknown>);
|
||||||
|
const canonicalIndex = resolveResponsesToolCallIndex(context, {
|
||||||
|
outputIndex,
|
||||||
|
itemId: (payload as any).item_id,
|
||||||
|
callId: (payload as any).call_id,
|
||||||
|
});
|
||||||
|
const toolCallId = (
|
||||||
|
isNonEmptyString((payload as any).call_id) ? (payload as any).call_id
|
||||||
|
: (isNonEmptyString((payload as any).item_id) ? (payload as any).item_id : undefined)
|
||||||
|
);
|
||||||
|
const toolName = isNonEmptyString((payload as any).name) ? (payload as any).name : undefined;
|
||||||
|
const rawArguments = (
|
||||||
|
type === 'response.custom_tool_call_input.done'
|
||||||
|
? (typeof (payload as any).input === 'string' ? (payload as any).input : stringifyUnknownValue((payload as any).input))
|
||||||
|
: (
|
||||||
|
typeof payload.delta === 'string'
|
||||||
|
? payload.delta
|
||||||
|
: stringifyUnknownValue((payload as any).input)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const existingArguments = context.toolCalls[canonicalIndex]?.arguments || '';
|
||||||
|
const argumentsDelta = computeNovelResponsesDelta(existingArguments, rawArguments);
|
||||||
|
const knownTool = context.toolCalls[canonicalIndex] || {};
|
||||||
|
const shouldBackfillId = !!toolCallId && !knownTool.id;
|
||||||
|
const shouldBackfillName = !!toolName && !knownTool.name;
|
||||||
|
if (!argumentsDelta && !shouldBackfillId && !shouldBackfillName) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: canonicalIndex,
|
||||||
|
id: toolCallId,
|
||||||
|
name: toolName,
|
||||||
|
argumentsDelta: argumentsDelta || undefined,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (type === 'response.completed' && isRecord((payload as any).response)) {
|
if (type === 'response.completed' && isRecord((payload as any).response)) {
|
||||||
const responsePayload = (payload as any).response as Record<string, unknown>;
|
const responsePayload = (payload as any).response as Record<string, unknown>;
|
||||||
if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id;
|
if (isNonEmptyString(responsePayload.id)) context.id = responsePayload.id;
|
||||||
if (isNonEmptyString(responsePayload.model)) context.model = responsePayload.model;
|
if (isNonEmptyString(responsePayload.model)) context.model = responsePayload.model;
|
||||||
|
const content = parseResponsesOutputText(responsePayload);
|
||||||
|
const contentDelta = computeNovelResponsesDelta(joinIndexedResponsesText(context.responsesTextByIndex), content);
|
||||||
|
if (content) {
|
||||||
|
context.responsesTextByIndex = { ...context.responsesTextByIndex, [-1]: content } as Record<number, string>;
|
||||||
|
}
|
||||||
|
const responsesReasoning = parseResponsesReasoning(responsePayload);
|
||||||
|
const reasoningDelta = computeNovelResponsesDelta(
|
||||||
|
joinIndexedResponsesText(context.responsesReasoningByIndex),
|
||||||
|
responsesReasoning.reasoningContent,
|
||||||
|
);
|
||||||
|
if (responsesReasoning.reasoningContent) {
|
||||||
|
context.responsesReasoningByIndex = {
|
||||||
|
...context.responsesReasoningByIndex,
|
||||||
|
[-1]: responsesReasoning.reasoningContent,
|
||||||
|
} as Record<number, string>;
|
||||||
|
}
|
||||||
|
const toolCalls = collectIndexedToolCallsFromResponsesPayload(responsePayload);
|
||||||
|
const toolCallDeltas = toolCalls
|
||||||
|
.map((toolCall) => {
|
||||||
|
const canonicalIndex = resolveResponsesToolCallIndex(context, {
|
||||||
|
outputIndex: toolCall.outputIndex,
|
||||||
|
callId: toolCall.id,
|
||||||
|
});
|
||||||
|
const knownTool = context.toolCalls[canonicalIndex] || {};
|
||||||
|
const argumentsDelta = computeNovelResponsesDelta(knownTool.arguments || '', toolCall.arguments);
|
||||||
|
const shouldBackfillId = !!toolCall.id && !knownTool.id;
|
||||||
|
const shouldBackfillName = !!toolCall.name && !knownTool.name;
|
||||||
|
if (!argumentsDelta && !shouldBackfillId && !shouldBackfillName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
finishReason: normalizeStopReason(responsePayload.status) || 'stop',
|
index: canonicalIndex,
|
||||||
|
id: toolCall.id,
|
||||||
|
name: toolCall.name,
|
||||||
|
argumentsDelta: argumentsDelta || undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item): item is NonNullable<typeof item> => !!item);
|
||||||
|
return {
|
||||||
|
...(contentDelta || reasoningDelta ? { role: 'assistant' as const } : {}),
|
||||||
|
...(contentDelta ? { contentDelta } : {}),
|
||||||
|
...(reasoningDelta ? { reasoningDelta } : {}),
|
||||||
|
...(responsesReasoning.reasoningSignature ? { reasoningSignature: responsesReasoning.reasoningSignature } : {}),
|
||||||
|
...(toolCallDeltas.length > 0 ? { toolCallDeltas } : {}),
|
||||||
|
finishReason: toolCallDeltas.length > 0
|
||||||
|
? 'tool_calls'
|
||||||
|
: (normalizeStopReason(responsePayload.status) || 'stop'),
|
||||||
done: true,
|
done: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
fromTransformerMetadataRecord,
|
fromTransformerMetadataRecord,
|
||||||
|
createStreamTransformContext,
|
||||||
normalizeStopReason,
|
normalizeStopReason,
|
||||||
normalizeUpstreamFinalResponse,
|
normalizeUpstreamFinalResponse,
|
||||||
|
normalizeUpstreamStreamEvent,
|
||||||
parseDownstreamChatRequest,
|
parseDownstreamChatRequest,
|
||||||
pullSseEventsWithDone,
|
pullSseEventsWithDone,
|
||||||
serializeFinalResponse,
|
serializeFinalResponse,
|
||||||
@@ -79,6 +81,97 @@ describe('shared normalized helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normalizes custom tool calls from responses payloads through the existing tool-call shape', () => {
|
||||||
|
expect(normalizeUpstreamFinalResponse({
|
||||||
|
object: 'response',
|
||||||
|
id: 'resp_custom_tool_1',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created: 123,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
call_id: 'call_custom',
|
||||||
|
name: 'MyTool',
|
||||||
|
input: '{"path":"README.md"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: 'completed',
|
||||||
|
}, 'fallback-model')).toEqual({
|
||||||
|
id: 'resp_custom_tool_1',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created: 123,
|
||||||
|
content: '',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'tool_calls',
|
||||||
|
toolCalls: [{
|
||||||
|
id: 'call_custom',
|
||||||
|
name: 'MyTool',
|
||||||
|
arguments: '{"path":"README.md"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps terminal response.completed envelopes when normalizing final responses', () => {
|
||||||
|
expect(normalizeUpstreamFinalResponse({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_terminal_1',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created_at: 123,
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
content: [{ type: 'output_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
call_id: 'call_custom_1',
|
||||||
|
name: 'Shell',
|
||||||
|
input: '{"command":"pwd"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, 'fallback-model')).toEqual({
|
||||||
|
id: 'resp_terminal_1',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created: 123,
|
||||||
|
content: 'hello',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'tool_calls',
|
||||||
|
toolCalls: [{
|
||||||
|
id: 'call_custom_1',
|
||||||
|
name: 'Shell',
|
||||||
|
arguments: '{"command":"pwd"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unwraps terminal response.incomplete envelopes when normalizing final responses', () => {
|
||||||
|
expect(normalizeUpstreamFinalResponse({
|
||||||
|
type: 'response.incomplete',
|
||||||
|
response: {
|
||||||
|
id: 'resp_terminal_2',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created: 456,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
type: 'message',
|
||||||
|
content: [{ type: 'output_text', text: 'partial answer' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, 'fallback-model')).toEqual({
|
||||||
|
id: 'resp_terminal_2',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created: 456,
|
||||||
|
content: 'partial answer',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'length',
|
||||||
|
toolCalls: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves responses reasoning summaries and encrypted reasoning signatures in final normalization', () => {
|
it('preserves responses reasoning summaries and encrypted reasoning signatures in final normalization', () => {
|
||||||
expect(normalizeUpstreamFinalResponse({
|
expect(normalizeUpstreamFinalResponse({
|
||||||
object: 'response',
|
object: 'response',
|
||||||
@@ -137,6 +230,142 @@ describe('shared normalized helpers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats response.reasoning_summary_text.done as reasoning-only stream output', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.reasoning_summary_text.done',
|
||||||
|
item_id: 'rs_1',
|
||||||
|
output_index: 0,
|
||||||
|
summary_index: 0,
|
||||||
|
text: 'plan first',
|
||||||
|
}, context, 'fallback-model')).toEqual({
|
||||||
|
reasoningDelta: 'plan first',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes terminal-only responses output_item.done message content into visible stream content', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
}, context, 'fallback-model')).toEqual({
|
||||||
|
role: 'assistant',
|
||||||
|
contentDelta: 'hello',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes terminal-only responses output_item.done tool metadata into tool deltas', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'fc_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
name: 'Glob',
|
||||||
|
arguments: '{"pattern":"README*"}',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
}, context, 'fallback-model')).toEqual({
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_1',
|
||||||
|
name: 'Glob',
|
||||||
|
argumentsDelta: '{"pattern":"README*"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes terminal-only custom tool responses into tool deltas and final tool calls', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.output_item.done',
|
||||||
|
output_index: 0,
|
||||||
|
item: {
|
||||||
|
id: 'ct_1',
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
call_id: 'call_custom_1',
|
||||||
|
name: 'MyTool',
|
||||||
|
input: '{"foo":"bar"}',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
}, context, 'fallback-model')).toEqual({
|
||||||
|
toolCallDeltas: [{
|
||||||
|
index: 0,
|
||||||
|
id: 'call_custom_1',
|
||||||
|
name: 'MyTool',
|
||||||
|
argumentsDelta: '{"foo":"bar"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeUpstreamFinalResponse({
|
||||||
|
object: 'response',
|
||||||
|
id: 'resp_custom_tool_1',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created: 321,
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'ct_1',
|
||||||
|
type: 'custom_tool_call',
|
||||||
|
call_id: 'call_custom_1',
|
||||||
|
name: 'MyTool',
|
||||||
|
input: '{"foo":"bar"}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: 'completed',
|
||||||
|
}, 'fallback-model')).toEqual({
|
||||||
|
id: 'resp_custom_tool_1',
|
||||||
|
model: 'gpt-test',
|
||||||
|
created: 321,
|
||||||
|
content: '',
|
||||||
|
reasoningContent: '',
|
||||||
|
finishReason: 'tool_calls',
|
||||||
|
toolCalls: [{
|
||||||
|
id: 'call_custom_1',
|
||||||
|
name: 'MyTool',
|
||||||
|
arguments: '{"foo":"bar"}',
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes terminal-only responses output payloads carried on response.completed', () => {
|
||||||
|
const context = createStreamTransformContext('gpt-test');
|
||||||
|
|
||||||
|
expect(normalizeUpstreamStreamEvent({
|
||||||
|
type: 'response.completed',
|
||||||
|
response: {
|
||||||
|
id: 'resp_done_only',
|
||||||
|
model: 'gpt-test',
|
||||||
|
status: 'completed',
|
||||||
|
output: [
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'hello' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}, context, 'fallback-model')).toMatchObject({
|
||||||
|
contentDelta: 'hello',
|
||||||
|
finishReason: 'stop',
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('maps claude tools, tool choice, metadata, and reasoning when parsing downstream requests', () => {
|
it('maps claude tools, tool choice, metadata, and reasoning when parsing downstream requests', () => {
|
||||||
const result = parseDownstreamChatRequest({
|
const result = parseDownstreamChatRequest({
|
||||||
model: 'gpt-5',
|
model: 'gpt-5',
|
||||||
|
|||||||
Reference in New Issue
Block a user