fix: decode zstd-compressed SSE proxy surfaces (#335)
* Fix zstd-compressed SSE decoding in proxy surfaces * Fix stream/web type bridge for zstd SSE * Guard zstd stream readers against decoded bodies * Handle stacked zstd stream encodings
This commit is contained in:
@@ -244,6 +244,38 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed native SSE chat streams before converting downstream chunks', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from([
|
||||
'data: {"id":"chatcmpl-zstd-native","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"chatcmpl-zstd-native","model":"upstream-gpt","choices":[{"delta":{"content":"你好,来自 zstd 原生 SSE"},"finish_reason":null}]}\n\n',
|
||||
'data: {"id":"chatcmpl-zstd-native","model":"upstream-gpt","choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
].join(''))), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('"chat.completion.chunk"');
|
||||
expect(response.body).toContain('你好,来自 zstd 原生 SSE');
|
||||
expect(response.body).not.toContain('(�/�');
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('returns upstream_error for empty non-stream chat responses when empty-content failure is enabled', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
|
||||
@@ -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';
|
||||
import { config } from '../../config.js';
|
||||
@@ -130,6 +131,17 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
});
|
||||
};
|
||||
|
||||
const createCompressedSseResponse = (chunks: string[], status = 200) => new Response(
|
||||
zstdCompressSync(Buffer.from(chunks.join(''))),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
beforeAll(async () => {
|
||||
const { responsesProxyRoute } = await import('./responses.js');
|
||||
app = Fastify();
|
||||
@@ -712,6 +724,36 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed codex responses SSE before relaying native downstream streams', async () => {
|
||||
fetchMock.mockResolvedValue(createCompressedSseResponse([
|
||||
'event: response.created\n',
|
||||
'data: {"type":"response.created","response":{"id":"resp_codex_zstd_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n',
|
||||
'event: response.output_item.added\n',
|
||||
'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_zstd_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n',
|
||||
'event: response.output_text.delta\n',
|
||||
'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_zstd_stream","delta":"你好,来自 zstd responses SSE"}\n\n',
|
||||
'event: response.completed\n',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_codex_zstd_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
payload: {
|
||||
model: 'gpt-5.4',
|
||||
input: 'hello codex',
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain('event: response.output_text.delta');
|
||||
expect(response.body).toContain('你好,来自 zstd responses SSE');
|
||||
expect(response.body).not.toContain('(�/�');
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('preserves codex-required instructions and store fields across responses compatibility retries', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
error: { message: 'upstream_error', type: 'upstream_error' },
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { zstdCompressSync } from 'node:zlib';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Response } from 'undici';
|
||||
|
||||
import { collectResponsesFinalPayloadFromSse } from './responsesSseFinal.js';
|
||||
|
||||
@@ -73,6 +75,42 @@ describe('collectResponsesFinalPayloadFromSse', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed responses SSE before aggregating the final payload', async () => {
|
||||
const upstream = new Response(zstdCompressSync(Buffer.from([
|
||||
'event: response.created',
|
||||
'data: {"type":"response.created","response":{"id":"resp_zstd_completed","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_zstd_completed","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_zstd_completed","delta":"你好,来自 zstd 聚合 SSE"}',
|
||||
'',
|
||||
'event: response.completed',
|
||||
'data: {"type":"response.completed","response":{"id":"resp_zstd_completed","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n'))), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
await expect(collectResponsesFinalPayloadFromSse(upstream, 'gpt-5.4'))
|
||||
.resolves
|
||||
.toMatchObject({
|
||||
payload: {
|
||||
id: 'resp_zstd_completed',
|
||||
status: 'completed',
|
||||
output_text: '你好,来自 zstd 聚合 SSE',
|
||||
},
|
||||
rawText: expect.stringContaining('你好,来自 zstd 聚合 SSE'),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns response.incomplete payloads instead of treating them as upstream failures', async () => {
|
||||
const upstream = {
|
||||
async text() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
|
||||
|
||||
type ResponsesTerminalStatus = 'completed' | 'incomplete';
|
||||
|
||||
@@ -420,8 +421,11 @@ export function collectResponsesFinalPayloadFromSseText(
|
||||
}
|
||||
|
||||
export async function collectResponsesFinalPayloadFromSse(
|
||||
upstream: { text(): Promise<string> },
|
||||
upstream: { text(): Promise<string>; headers?: { get(name: string): string | null } },
|
||||
modelName: string,
|
||||
): Promise<{ payload: Record<string, unknown>; rawText: string }> {
|
||||
return collectResponsesFinalPayloadFromSseText(await upstream.text(), modelName);
|
||||
const rawText = typeof upstream.headers?.get === 'function'
|
||||
? await readRuntimeResponseText(upstream as Parameters<typeof readRuntimeResponseText>[0])
|
||||
: await upstream.text();
|
||||
return collectResponsesFinalPayloadFromSseText(rawText, modelName);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user