fix: decode zstd responses while restoring claude encoding

This commit is contained in:
ciat-777
2026-03-21 15:38:11 +08:00
parent 17a4281117
commit 7a2953ac36
12 changed files with 146 additions and 16 deletions
+1 -1
View File
@@ -26,7 +26,7 @@
--><a href="LICENSE"> --><a href="LICENSE">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat"> <img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</a><!-- </a><!--
--><img alt="Node.js" src="https://img.shields.io/badge/Node.js-20+-339933?logo=node.js&style=flat"><!-- --><img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat"><!--
--><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat"><!-- --><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat"><!--
--><a href="https://zeabur.com/templates/DOX5PR"> --><a href="https://zeabur.com/templates/DOX5PR">
<img alt="Deploy on Zeabur" src="https://zeabur.com/button.svg" height="28"> <img alt="Deploy on Zeabur" src="https://zeabur.com/button.svg" height="28">
+1 -1
View File
@@ -26,7 +26,7 @@ into <strong>one API Key, one endpoint</strong>, with automatic model discovery,
--><a href="LICENSE"> --><a href="LICENSE">
<img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat"> <img alt="License" src="https://img.shields.io/badge/license-MIT-brightgreen?style=flat">
</a><!-- </a><!--
--><img alt="Node.js" src="https://img.shields.io/badge/Node.js-20+-339933?logo=node.js&style=flat"><!-- --><img alt="Node.js" src="https://img.shields.io/badge/Node.js-22.15%2B-339933?logo=node.js&style=flat"><!--
--><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat"><!-- --><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&style=flat"><!--
--><a href="https://zeabur.com/templates/DOX5PR"> --><a href="https://zeabur.com/templates/DOX5PR">
<img alt="Deploy on Zeabur" src="https://zeabur.com/button.svg" height="28"> <img alt="Deploy on Zeabur" src="https://zeabur.com/button.svg" height="28">
+1 -1
View File
@@ -22,7 +22,7 @@
}, },
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": ">=22.15.0"
}, },
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -0,0 +1,56 @@
import { gzipSync, zstdCompressSync } from 'node:zlib';
import { Response } from 'undici';
import { describe, expect, it } from 'vitest';
import { materializeErrorResponse, readRuntimeResponseText } from './types.js';
describe('readRuntimeResponseText', () => {
it('decompresses zstd responses before reading the body text', async () => {
const payload = JSON.stringify({ ok: true, text: 'hello zstd' });
const response = new Response(zstdCompressSync(Buffer.from(payload)), {
status: 200,
headers: {
'content-encoding': 'zstd',
'content-type': 'application/json; charset=utf-8',
},
});
await expect(readRuntimeResponseText(response)).resolves.toBe(payload);
});
it('decompresses stacked content-encodings in reverse order', async () => {
const payload = JSON.stringify({ ok: true, text: 'stacked' });
const response = new Response(
zstdCompressSync(gzipSync(Buffer.from(payload))),
{
status: 200,
headers: {
'content-encoding': 'gzip, zstd',
'content-type': 'application/json; charset=utf-8',
},
},
);
await expect(readRuntimeResponseText(response)).resolves.toBe(payload);
});
});
describe('materializeErrorResponse', () => {
it('decodes compressed error bodies and strips compression headers', async () => {
const payload = JSON.stringify({ error: { message: 'upstream failed' } });
const response = new Response(zstdCompressSync(Buffer.from(payload)), {
status: 503,
headers: {
'content-encoding': 'zstd',
'content-length': '999',
'content-type': 'application/json; charset=utf-8',
},
});
const materialized = await materializeErrorResponse(response);
await expect(materialized.text()).resolves.toBe(payload);
expect(materialized.headers.get('content-encoding')).toBeNull();
expect(materialized.headers.get('content-length')).toBeNull();
expect(materialized.headers.get('content-type')).toBe('application/json; charset=utf-8');
});
});
+74 -2
View File
@@ -1,3 +1,9 @@
import {
brotliDecompressSync,
gunzipSync,
inflateSync,
zstdDecompressSync,
} from 'node:zlib';
import { import {
Response, Response,
fetch, fetch,
@@ -63,13 +69,79 @@ export async function performFetch(
return fetch(requestUrl, init); return fetch(requestUrl, init);
} }
function hasZstdContentEncoding(contentEncoding: string | null): boolean {
if (!contentEncoding) return false;
return contentEncoding
.split(',')
.some((encoding) => encoding.trim().toLowerCase() === 'zstd');
}
function looksLikeZstdFrame(buffer: Buffer): boolean {
return buffer.length >= 4
&& buffer[0] === 0x28
&& buffer[1] === 0xb5
&& buffer[2] === 0x2f
&& buffer[3] === 0xfd;
}
function decodeRuntimeResponseBuffer(buffer: Buffer, contentEncoding: string | null): Buffer {
if (!contentEncoding) return buffer;
let decoded = buffer;
const encodings = contentEncoding
.split(',')
.map((encoding) => encoding.trim().toLowerCase())
.filter(Boolean)
.reverse();
for (const encoding of encodings) {
if (encoding === 'zstd') {
decoded = zstdDecompressSync(decoded);
continue;
}
if (encoding === 'br') {
decoded = brotliDecompressSync(decoded);
continue;
}
if (encoding === 'gzip' || encoding === 'x-gzip') {
decoded = gunzipSync(decoded);
continue;
}
if (encoding === 'deflate') {
decoded = inflateSync(decoded);
continue;
}
}
return decoded;
}
export async function readRuntimeResponseText(
response: RuntimeResponse,
): Promise<string> {
const contentEncoding = response.headers.get('content-encoding');
if (!hasZstdContentEncoding(contentEncoding)) {
return response.text().catch(() => '');
}
const rawBuffer = Buffer.from(await response.arrayBuffer());
try {
return decodeRuntimeResponseBuffer(rawBuffer, contentEncoding).toString('utf8');
} catch {
return looksLikeZstdFrame(rawBuffer) ? '' : rawBuffer.toString('utf8');
}
}
export async function materializeErrorResponse( export async function materializeErrorResponse(
response: RuntimeResponse, response: RuntimeResponse,
): Promise<RuntimeResponse> { ): Promise<RuntimeResponse> {
if (response.ok) return response; if (response.ok) return response;
const text = await response.text().catch(() => ''); const text = await readRuntimeResponseText(response);
const headers = new Headers(response.headers);
headers.delete('content-encoding');
headers.delete('content-length');
return new Response(text, { return new Response(text, {
status: response.status, status: response.status,
headers: response.headers, headers,
}); });
} }
@@ -83,7 +83,7 @@ function buildClaudeRuntimeHeaders(input: {
'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT, 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT,
Connection: 'keep-alive', Connection: 'keep-alive',
Accept: input.stream ? 'text/event-stream' : 'application/json', Accept: input.stream ? 'text/event-stream' : 'application/json',
'Accept-Encoding': 'identity', 'Accept-Encoding': 'gzip, deflate, br, zstd',
}; };
if (input.isClaudeOauthUpstream) { if (input.isClaudeOauthUpstream) {
headers.Authorization = `Bearer ${input.tokenValue}`; headers.Authorization = `Bearer ${input.tokenValue}`;
@@ -70,7 +70,7 @@ describe('resolveProviderProfile', () => {
expect(result.headers.Authorization).toBe('Bearer oauth-access-token'); expect(result.headers.Authorization).toBe('Bearer oauth-access-token');
expect(result.headers['x-api-key']).toBeUndefined(); expect(result.headers['x-api-key']).toBeUndefined();
expect(result.headers['anthropic-version']).toBe('2023-06-01'); expect(result.headers['anthropic-version']).toBe('2023-06-01');
expect(result.headers['Accept-Encoding']).toBe('identity'); expect(result.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
expect(result.runtime).toMatchObject({ expect(result.runtime).toMatchObject({
executor: 'claude', executor: 'claude',
modelName: 'claude-opus-4-6', modelName: 'claude-opus-4-6',
@@ -34,6 +34,7 @@ import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js'; import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js'; import { insertProxyLog } from '../../services/proxyLogStore.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js'; import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
import { readRuntimeResponseText } from '../executors/types.js';
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
const GEMINI_MODEL_PROBES = [ const GEMINI_MODEL_PROBES = [
@@ -294,7 +295,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue), geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
{ method: 'GET' }, { method: 'GET' },
); );
const text = await upstream.text(); const text = await readRuntimeResponseText(upstream);
if (!upstream.ok) { if (!upstream.ok) {
lastStatus = upstream.status; lastStatus = upstream.status;
lastText = text; lastText = text;
@@ -504,7 +505,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
if (!upstream.ok) { if (!upstream.ok) {
lastStatus = upstream.status; lastStatus = upstream.status;
lastContentType = contentType; lastContentType = contentType;
lastText = await upstream.text(); lastText = await readRuntimeResponseText(upstream);
await tokenRouter.recordFailure?.(selected.channel.id, { await tokenRouter.recordFailure?.(selected.channel.id, {
status: upstream.status, status: upstream.status,
errorText: lastText, errorText: lastText,
@@ -612,7 +613,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
return; return;
} }
const text = await upstream.text(); const text = await readRuntimeResponseText(upstream);
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
let parsedUsage = EMPTY_PROXY_USAGE; let parsedUsage = EMPTY_PROXY_USAGE;
try { try {
@@ -822,7 +823,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
upstreamPath = endpointResult.upstreamPath; upstreamPath = endpointResult.upstreamPath;
const upstream = endpointResult.upstream; const upstream = endpointResult.upstream;
const rawText = await upstream.text(); const rawText = await readRuntimeResponseText(upstream);
let upstreamData: unknown = rawText; let upstreamData: unknown = rawText;
try { try {
upstreamData = JSON.parse(rawText); upstreamData = JSON.parse(rawText);
@@ -145,7 +145,7 @@ describe('claude count_tokens proxy route', () => {
expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true'); expect(targetUrl).toBe('https://api.anthropic.com/v1/messages/count_tokens?beta=true');
expect(options.headers['anthropic-version']).toBe('2023-06-01'); expect(options.headers['anthropic-version']).toBe('2023-06-01');
expect(options.headers['anthropic-beta']).toContain('claude-code-20250219'); expect(options.headers['anthropic-beta']).toContain('claude-code-20250219');
expect(options.headers['Accept-Encoding']).toBe('identity'); expect(options.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
const forwardedBody = JSON.parse(String(options.body)); const forwardedBody = JSON.parse(String(options.body));
expect(forwardedBody.model).toBe('claude-opus-4-6'); expect(forwardedBody.model).toBe('claude-opus-4-6');
+2 -1
View File
@@ -1,4 +1,5 @@
import { fetch } from 'undici'; import { fetch } from 'undici';
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { summarizeUpstreamError } from './upstreamError.js'; import { summarizeUpstreamError } from './upstreamError.js';
import type { UpstreamEndpoint } from './upstreamEndpoint.js'; import type { UpstreamEndpoint } from './upstreamEndpoint.js';
@@ -114,7 +115,7 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
}; };
} }
let rawErrText = await response.text().catch(() => 'unknown error'); let rawErrText = await readRuntimeResponseText(response).catch(() => 'unknown error');
const baseContext: EndpointAttemptContext = { const baseContext: EndpointAttemptContext = {
endpointIndex, endpointIndex,
endpointCount, endpointCount,
@@ -864,7 +864,7 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers['User-Agent']).toBe('claude-cli/2.1.63 (external, cli)'); expect(request.headers['User-Agent']).toBe('claude-cli/2.1.63 (external, cli)');
expect(request.headers.Connection).toBe('keep-alive'); expect(request.headers.Connection).toBe('keep-alive');
expect(request.headers.Accept).toBe('text/event-stream'); expect(request.headers.Accept).toBe('text/event-stream');
expect(request.headers['Accept-Encoding']).toBe('identity'); expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
}); });
it('uses claude-code beta headers and uncompressed non-stream responses for claude upstream requests', () => { it('uses claude-code beta headers and uncompressed non-stream responses for claude upstream requests', () => {
@@ -887,7 +887,7 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers['anthropic-beta']).toContain('oauth-2025-04-20'); expect(request.headers['anthropic-beta']).toContain('oauth-2025-04-20');
expect(request.headers['anthropic-beta']).toContain('context-management-2025-06-27'); expect(request.headers['anthropic-beta']).toContain('context-management-2025-06-27');
expect(request.headers.Accept).toBe('application/json'); expect(request.headers.Accept).toBe('application/json');
expect(request.headers['Accept-Encoding']).toBe('identity'); expect(request.headers['Accept-Encoding']).toBe('gzip, deflate, br, zstd');
}); });
it('converts system roles to developer in native codex responses bodies', () => { it('converts system roles to developer in native codex responses bodies', () => {
+1 -1
View File
@@ -443,7 +443,7 @@ function buildClaudeRuntimeHeaders(input: {
'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT, 'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT,
Connection: 'keep-alive', Connection: 'keep-alive',
Accept: input.stream ? 'text/event-stream' : 'application/json', Accept: input.stream ? 'text/event-stream' : 'application/json',
'Accept-Encoding': 'identity', 'Accept-Encoding': 'gzip, deflate, br, zstd',
}; };
if (input.isClaudeOauthUpstream) { if (input.isClaudeOauthUpstream) {
headers.Authorization = `Bearer ${input.tokenValue}`; headers.Authorization = `Bearer ${input.tokenValue}`;