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:
@@ -1154,6 +1154,112 @@ describe('chat proxy stream behavior', () => {
|
||||
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 () => {
|
||||
fetchModelPricingCatalogMock.mockResolvedValue({
|
||||
models: [
|
||||
|
||||
@@ -279,7 +279,8 @@ describe('responses proxy compact upstream routing', () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
const body = response.json();
|
||||
expect(body).toMatchObject({
|
||||
id: 'cmp_sse_123',
|
||||
object: 'response.compaction',
|
||||
input_tokens: 12,
|
||||
@@ -292,6 +293,12 @@ describe('responses proxy compact upstream routing', () => {
|
||||
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);
|
||||
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({
|
||||
type: 'response.create',
|
||||
@@ -546,8 +579,13 @@ describe('responses websocket transport', () => {
|
||||
socket.close();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
|
||||
expect(messages[0]?.response?.id).toBe('resp_http_fallback');
|
||||
expect(messages.map((message) => message?.type)).toEqual([
|
||||
'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 () => {
|
||||
@@ -572,7 +610,10 @@ describe('responses websocket transport', () => {
|
||||
|
||||
const socket = createClientSocket(baseUrl);
|
||||
await waitForSocketOpen(socket);
|
||||
const messagesPromise = waitForSocketMessages(socket, 1);
|
||||
const messagesPromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed',
|
||||
);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
@@ -580,12 +621,58 @@ describe('responses websocket transport', () => {
|
||||
input: [],
|
||||
}));
|
||||
|
||||
const messages = await messagesPromise;
|
||||
const message = await messagesPromise;
|
||||
socket.close();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(messages.map((message) => message.type)).toEqual(['response.completed']);
|
||||
expect(messages[0]?.response?.id).toBe('resp_http_fallback_401');
|
||||
expect(message?.type).toBe('response.completed');
|
||||
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 () => {
|
||||
@@ -617,7 +704,10 @@ describe('responses websocket transport', () => {
|
||||
|
||||
const socket = createClientSocket(baseUrl);
|
||||
await waitForSocketOpen(socket);
|
||||
const firstResponsePromise = waitForSocketMessages(socket, 1);
|
||||
const firstResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed',
|
||||
);
|
||||
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
@@ -626,8 +716,11 @@ describe('responses websocket transport', () => {
|
||||
input: [],
|
||||
}));
|
||||
|
||||
const firstMessages = await firstResponsePromise;
|
||||
const secondResponsePromise = waitForSocketMessages(socket, 1);
|
||||
const firstMessage = await firstResponsePromise;
|
||||
const secondResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_fallback_2',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
@@ -645,7 +738,7 @@ describe('responses websocket transport', () => {
|
||||
await secondResponsePromise;
|
||||
socket.close();
|
||||
|
||||
expect(firstMessages[0]?.type).toBe('response.completed');
|
||||
expect(firstMessage?.type).toBe('response.completed');
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
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 () => {
|
||||
rejectedUpgradeStatus = 401;
|
||||
rejectedUpgradeStatusText = 'Unauthorized';
|
||||
@@ -770,7 +1038,10 @@ describe('responses websocket transport', () => {
|
||||
const socket = createClientSocket(baseUrl);
|
||||
await waitForSocketOpen(socket);
|
||||
|
||||
const firstResponsePromise = waitForSocketMessages(socket, 1);
|
||||
const firstResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-4.1',
|
||||
@@ -786,7 +1057,10 @@ describe('responses websocket transport', () => {
|
||||
}));
|
||||
await firstResponsePromise;
|
||||
|
||||
const secondResponsePromise = waitForSocketMessages(socket, 1);
|
||||
const secondResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
previous_response_id: 'resp_ws_1',
|
||||
@@ -812,33 +1086,116 @@ describe('responses websocket transport', () => {
|
||||
expect(secondBody.previous_response_id).toBeUndefined();
|
||||
expect(secondBody.model).toBe('gpt-4.1');
|
||||
expect(secondBody.instructions).toBe('be helpful');
|
||||
expect(secondBody.input).toEqual([
|
||||
{
|
||||
id: 'msg_user_1',
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||
},
|
||||
{
|
||||
id: 'fc_1',
|
||||
type: 'function_call',
|
||||
call_id: 'call_1',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'call tool' }],
|
||||
},
|
||||
{
|
||||
id: 'tool_out_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: 'tool result',
|
||||
},
|
||||
]);
|
||||
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',
|
||||
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',
|
||||
model: 'gpt-4.1',
|
||||
instructions: 'be helpful',
|
||||
previous_response_id: 'resp_ws_empty_output',
|
||||
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.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 () => {
|
||||
@@ -924,33 +1281,31 @@ describe('responses websocket transport', () => {
|
||||
});
|
||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||
const secondBody = JSON.parse(String(secondOptions.body));
|
||||
expect(secondBody.input).toEqual([
|
||||
{
|
||||
id: 'msg_user_1',
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||
},
|
||||
{
|
||||
id: 'fc_1',
|
||||
type: 'function_call',
|
||||
call_id: 'call_1',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'call tool' }],
|
||||
},
|
||||
{
|
||||
id: 'tool_out_1',
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_1',
|
||||
output: 'tool result',
|
||||
},
|
||||
]);
|
||||
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('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);
|
||||
await waitForSocketOpen(socket);
|
||||
|
||||
const firstResponsePromise = waitForSocketMessages(socket, 1);
|
||||
const firstResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
@@ -1100,7 +1458,10 @@ describe('responses websocket transport', () => {
|
||||
|
||||
const socket = createClientSocket(baseUrl);
|
||||
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({
|
||||
type: 'response.create',
|
||||
@@ -1109,8 +1470,11 @@ describe('responses websocket transport', () => {
|
||||
input: [],
|
||||
}));
|
||||
|
||||
const firstMessages = await firstResponsePromise;
|
||||
const secondResponsePromise = waitForSocketMessages(socket, 1);
|
||||
const firstMessage = await firstResponsePromise;
|
||||
const secondResponsePromise = waitForSocketMessageMatching(
|
||||
socket,
|
||||
(message) => message?.type === 'response.completed' && message?.response?.id === 'resp_http_2',
|
||||
);
|
||||
socket.send(JSON.stringify({
|
||||
type: 'response.create',
|
||||
model: 'gpt-5.4',
|
||||
@@ -1128,13 +1492,20 @@ describe('responses websocket transport', () => {
|
||||
await secondResponsePromise;
|
||||
socket.close();
|
||||
|
||||
expect(firstMessages[0]?.type).toBe('response.completed');
|
||||
expect(firstMessage?.type).toBe('response.completed');
|
||||
expect(upstreamConnectionCount).toBe(0);
|
||||
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.input).toEqual([
|
||||
{
|
||||
id: 'msg_1',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
status: 'completed',
|
||||
content: [{ type: 'output_text', text: 'first' }],
|
||||
},
|
||||
{
|
||||
id: 'tool_out_1',
|
||||
type: 'function_call_output',
|
||||
@@ -1282,4 +1653,238 @@ describe('responses websocket transport', () => {
|
||||
]);
|
||||
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 { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
|
||||
type ResponsesTerminalStatus = 'completed' | 'incomplete';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
function parseResponsesSsePayload(data: string): Record<string, unknown> | null {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
@@ -72,6 +78,27 @@ function hasMeaningfulFinalResponsesPayload(payload: Record<string, unknown>): b
|
||||
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(
|
||||
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||
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>,
|
||||
streamContext: ReturnType<typeof openAiResponsesTransformer.createStreamContext>,
|
||||
usage: ReturnType<typeof parseProxyUsage>,
|
||||
): Record<string, unknown> | null {
|
||||
const lines = openAiResponsesTransformer.aggregator.complete(
|
||||
terminalStatus: ResponsesTerminalStatus,
|
||||
): 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,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
);
|
||||
const { events } = openAiResponsesTransformer.pullSseEvents(lines.join(''));
|
||||
for (const event of events) {
|
||||
if (event.data === '[DONE]') continue;
|
||||
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;
|
||||
|
||||
if (!hasMeaningfulResponsesOutput(next.output) && materialized && hasMeaningfulResponsesOutput(materialized.output)) {
|
||||
next.output = materialized.output;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -169,18 +299,20 @@ export function collectResponsesFinalPayloadFromSseText(
|
||||
promptTokensIncludeCache: null as boolean | null,
|
||||
};
|
||||
let completedPayload: Record<string, unknown> | null = null;
|
||||
let terminalStatus: ResponsesTerminalStatus = 'completed';
|
||||
|
||||
const captureCompletedPayloadFromEvent = (
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
) => {
|
||||
if (completedPayload) return;
|
||||
if (eventType === 'response.failed' || eventType === 'response.incomplete' || eventType === 'error') {
|
||||
if (eventType === 'response.failed' || eventType === 'error') {
|
||||
throw new Error(getResponsesFailureMessage(payload));
|
||||
}
|
||||
if (eventType !== 'response.completed') {
|
||||
if (eventType !== 'response.completed' && eventType !== 'response.incomplete') {
|
||||
return;
|
||||
}
|
||||
terminalStatus = eventType === 'response.incomplete' ? 'incomplete' : 'completed';
|
||||
if (isRecord(payload.response) && hasCompleteFinalResponsesPayload(payload.response)) {
|
||||
completedPayload = payload.response;
|
||||
return;
|
||||
@@ -236,11 +368,36 @@ export function collectResponsesFinalPayloadFromSseText(
|
||||
&& !hasMeaningfulFinalResponsesPayload(completedPayload)
|
||||
&& 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) {
|
||||
const materialized = materializeCompletedPayloadFromAggregate(aggregateState, streamContext, usage);
|
||||
const materialized = materializeTerminalPayloadFromAggregate(
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
);
|
||||
if (materialized) {
|
||||
completedPayload = materialized;
|
||||
}
|
||||
@@ -248,12 +405,18 @@ export function collectResponsesFinalPayloadFromSseText(
|
||||
|
||||
if (completedPayload) {
|
||||
return {
|
||||
payload: completedPayload,
|
||||
payload: enrichTerminalPayload(
|
||||
completedPayload,
|
||||
aggregateState,
|
||||
streamContext,
|
||||
usage,
|
||||
terminalStatus,
|
||||
),
|
||||
rawText,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('stream disconnected before response.completed');
|
||||
throw new Error('stream disconnected before terminal responses event');
|
||||
}
|
||||
|
||||
export async function collectResponsesFinalPayloadFromSse(
|
||||
|
||||
@@ -312,6 +312,11 @@ function synthesizePrewarmResponsePayloads(request: Record<string, unknown>) {
|
||||
function collectResponsesOutput(payloads: unknown[]): unknown[] {
|
||||
const outputByIndex = new Map<number, unknown>();
|
||||
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) {
|
||||
if (!isRecord(payload)) continue;
|
||||
@@ -322,8 +327,54 @@ function collectResponsesOutput(payloads: unknown[]): unknown[] {
|
||||
outputByIndex.set(Number(payload.output_index), cloneJsonObject(payload.item));
|
||||
continue;
|
||||
}
|
||||
if (type === 'response.completed' && isRecord(payload.response) && Array.isArray(payload.response.output)) {
|
||||
completedOutput = cloneJsonObject(payload.response.output);
|
||||
if (
|
||||
(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')) {
|
||||
try {
|
||||
const payload = JSON.parse(response.body);
|
||||
const output = collectResponsesOutput([payload]);
|
||||
input.socket.send(JSON.stringify(payload));
|
||||
return isRecord(payload?.response) && Array.isArray(payload.response.output)
|
||||
? cloneJsonObject(payload.response.output)
|
||||
: [];
|
||||
return output;
|
||||
} catch {
|
||||
writeResponsesWebsocketError(input.socket, 502, 'Unexpected non-JSON websocket proxy response');
|
||||
return null;
|
||||
@@ -400,7 +450,7 @@ async function forwardResponsesRequestViaHttp(input: {
|
||||
const payload = JSON.parse(event.data);
|
||||
forwardedPayloads.push(payload);
|
||||
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;
|
||||
}
|
||||
input.socket.send(JSON.stringify(payload));
|
||||
@@ -597,7 +647,7 @@ async function handleResponsesWebsocketConnection(
|
||||
? error
|
||||
: new CodexWebsocketRuntimeError('upstream websocket request failed');
|
||||
if (runtimeError.status && runtimeError.events.length === 0) {
|
||||
const fallbackOutput = await forwardResponsesRequestViaHttp({
|
||||
const forwarded = await forwardResponsesRequestViaHttp({
|
||||
app,
|
||||
socket,
|
||||
request,
|
||||
@@ -605,8 +655,8 @@ async function handleResponsesWebsocketConnection(
|
||||
preserveIncrementalMode: supportsIncrementalInput,
|
||||
authToken: authContext.token,
|
||||
});
|
||||
if (fallbackOutput) {
|
||||
lastResponseOutput = fallbackOutput;
|
||||
if (forwarded) {
|
||||
lastResponseOutput = forwarded;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -614,17 +664,24 @@ async function handleResponsesWebsocketConnection(
|
||||
for (const payload of runtimeError.events) {
|
||||
socket.send(JSON.stringify(payload));
|
||||
}
|
||||
writeResponsesWebsocketError(
|
||||
socket,
|
||||
runtimeError.status || 408,
|
||||
runtimeError.message,
|
||||
runtimeError.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(
|
||||
socket,
|
||||
runtimeError.status || 408,
|
||||
runtimeError.message,
|
||||
runtimeError.payload,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const forwardedOutput = await forwardResponsesRequestViaHttp({
|
||||
const forwarded = await forwardResponsesRequestViaHttp({
|
||||
app,
|
||||
socket,
|
||||
request,
|
||||
@@ -632,8 +689,8 @@ async function handleResponsesWebsocketConnection(
|
||||
preserveIncrementalMode: supportsIncrementalInput,
|
||||
authToken: authContext.token,
|
||||
});
|
||||
if (forwardedOutput) {
|
||||
lastResponseOutput = forwardedOutput;
|
||||
if (forwarded) {
|
||||
lastResponseOutput = forwarded;
|
||||
}
|
||||
} catch {
|
||||
writeResponsesWebsocketError(socket, 500, 'internal websocket proxy error');
|
||||
|
||||
Reference in New Issue
Block a user