[codex] fix responses lifecycle parity and codex session serialization (#278)
* 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 * fix responses lifecycle parity and codex session serialization * fix review follow-up edge cases --------- Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { zstdCompressSync } from 'node:zlib';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -191,4 +192,166 @@ describe('testRoutes proxy tester transport', () => {
|
||||
expect(url).toMatch(/\/v1\/files$/);
|
||||
expect(requestInit.body?.constructor?.name).toBe('FormData');
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed buffered proxy test responses', async () => {
|
||||
const payload = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '你好,来自压缩响应',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '你好,来自压缩响应',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed non-SSE stream fallback responses', async () => {
|
||||
const payload = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '你好,来自流式回退',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy/stream',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.payload).toBe(`data: ${payload}\n\ndata: [DONE]\n\n`);
|
||||
});
|
||||
|
||||
it('encodes multiline non-SSE stream fallback responses as valid SSE', async () => {
|
||||
const payload = 'line one\nline two\n';
|
||||
fetchMock.mockResolvedValue(new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy/stream',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.payload).toBe('data: line one\ndata: line two\ndata: \n\ndata: [DONE]\n\n');
|
||||
});
|
||||
|
||||
it('cancels and releases SSE readers when stream forwarding fails', async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const releaseLock = vi.fn();
|
||||
const read = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
}),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read,
|
||||
cancel,
|
||||
releaseLock,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy/stream',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.payload).toContain('event: error');
|
||||
expect(response.payload).toContain('"message":"boom"');
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(releaseLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fetch, File as UndiciFile, FormData as UndiciFormData } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
|
||||
|
||||
type UndiciRequestInit = Parameters<typeof fetch>[1];
|
||||
|
||||
@@ -521,7 +522,7 @@ async function fetchProxyBuffered(
|
||||
});
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
const text = await upstream.text();
|
||||
const text = await readRuntimeResponseText(upstream);
|
||||
|
||||
if (!upstream.ok) {
|
||||
throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text));
|
||||
@@ -656,14 +657,14 @@ async function sendStreamingEnvelope(
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const text = await upstream.text();
|
||||
const text = await readRuntimeResponseText(upstream);
|
||||
cleanupClientListeners();
|
||||
return reply.code(upstream.status).send(normalizeErrorPayload(text));
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
const reader = contentType.includes('text/event-stream') ? upstream.body?.getReader() : null;
|
||||
if (contentType.includes('text/event-stream') && !reader) {
|
||||
cleanupClientListeners();
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
@@ -682,25 +683,30 @@ async function sendStreamingEnvelope(
|
||||
|
||||
try {
|
||||
if (contentType.includes('text/event-stream')) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
reply.raw.write(Buffer.from(value));
|
||||
const streamReader = reader!;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
reply.raw.write(Buffer.from(value));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await streamReader.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
} finally {
|
||||
streamReader.releaseLock();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let text = '';
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
const text = await readRuntimeResponseText(upstream);
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
reply.raw.write(`data: ${line}\n`);
|
||||
}
|
||||
text += decoder.decode();
|
||||
reply.raw.write(`data: ${text}\n\n`);
|
||||
reply.raw.write('\n');
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -711,11 +717,6 @@ async function sendStreamingEnvelope(
|
||||
reply.raw.write(`event: error\ndata: ${message}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
cleanupClientListeners();
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
|
||||
Reference in New Issue
Block a user