diff --git a/README.md b/README.md
index 33f526f..44d0fe6 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@
-->


diff --git a/README_EN.md b/README_EN.md
index 41fb7a4..67f8c71 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -26,7 +26,7 @@ into one API Key, one endpoint, with automatic model discovery,
-->


diff --git a/package.json b/package.json
index b5a4ea5..c5f3f4d 100644
--- a/package.json
+++ b/package.json
@@ -22,7 +22,7 @@
},
"license": "MIT",
"engines": {
- "node": "^20.19.0 || >=22.12.0"
+ "node": ">=22.15.0"
},
"type": "module",
"scripts": {
diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts
index ae8992b..f606dde 100644
--- a/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts
+++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.test.ts
@@ -60,6 +60,16 @@ describe('conversationFileCapabilities', () => {
document: 'inline_only',
preservesRemoteDocumentUrl: false,
});
+
+ expect(resolveConversationFileEndpointCapability({
+ sitePlatform: 'new-api',
+ endpoint: 'messages',
+ })).toMatchObject({
+ image: 'native',
+ audio: 'unsupported',
+ document: 'inline_only',
+ preservesRemoteDocumentUrl: false,
+ });
});
it('ranks document-capable endpoints ahead of lossy fallbacks', () => {
diff --git a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts
index 38a7cfc..b4b4882 100644
--- a/src/server/proxy-core/capabilities/conversationFileCapabilities.ts
+++ b/src/server/proxy-core/capabilities/conversationFileCapabilities.ts
@@ -241,19 +241,23 @@ export function rankConversationFileEndpoints(input: {
return [...input.requestedOrder];
}
+ const isDocumentCompatible = (endpoint: ConversationFileEndpoint) => {
+ const capability = resolveConversationFileEndpointCapability({
+ sitePlatform: input.sitePlatform,
+ endpoint,
+ });
+ if (capability.document === 'unsupported') return false;
+ if (input.summary.hasRemoteDocumentUrl && !capability.preservesRemoteDocumentUrl) return false;
+ return true;
+ };
+
const preferredDocumentOrder = input.preferMessagesForClaudeModel === true
? ['messages', 'responses', 'chat']
: ['responses', 'messages', 'chat'];
const supportedDocumentEndpoints = preferredDocumentOrder.filter((endpoint) => {
if (!input.requestedOrder.includes(endpoint as ConversationFileEndpoint)) return false;
- const capability = resolveConversationFileEndpointCapability({
- sitePlatform: input.sitePlatform,
- endpoint: endpoint as ConversationFileEndpoint,
- });
- if (capability.document === 'unsupported') return false;
- if (input.summary.hasRemoteDocumentUrl && !capability.preservesRemoteDocumentUrl) return false;
- return true;
+ return isDocumentCompatible(endpoint as ConversationFileEndpoint);
}) as ConversationFileEndpoint[];
if (supportedDocumentEndpoints.length <= 0) {
@@ -262,6 +266,9 @@ export function rankConversationFileEndpoints(input: {
return [
...supportedDocumentEndpoints,
- ...input.requestedOrder.filter((endpoint) => !supportedDocumentEndpoints.includes(endpoint)),
+ ...input.requestedOrder.filter((endpoint) => (
+ !supportedDocumentEndpoints.includes(endpoint)
+ && isDocumentCompatible(endpoint)
+ )),
];
}
diff --git a/src/server/proxy-core/executors/types.test.ts b/src/server/proxy-core/executors/types.test.ts
new file mode 100644
index 0000000..c1f3682
--- /dev/null
+++ b/src/server/proxy-core/executors/types.test.ts
@@ -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');
+ });
+});
diff --git a/src/server/proxy-core/executors/types.ts b/src/server/proxy-core/executors/types.ts
index b609cfa..00b1169 100644
--- a/src/server/proxy-core/executors/types.ts
+++ b/src/server/proxy-core/executors/types.ts
@@ -1,3 +1,9 @@
+import {
+ brotliDecompressSync,
+ gunzipSync,
+ inflateSync,
+ zstdDecompressSync,
+} from 'node:zlib';
import {
Response,
fetch,
@@ -63,13 +69,79 @@ export async function performFetch(
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 {
+ 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(
response: RuntimeResponse,
): Promise {
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, {
status: response.status,
- headers: response.headers,
+ headers,
});
}
diff --git a/src/server/proxy-core/providers/claudeProviderProfile.ts b/src/server/proxy-core/providers/claudeProviderProfile.ts
index 57eafe2..fdb428b 100644
--- a/src/server/proxy-core/providers/claudeProviderProfile.ts
+++ b/src/server/proxy-core/providers/claudeProviderProfile.ts
@@ -83,7 +83,7 @@ function buildClaudeRuntimeHeaders(input: {
'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT,
Connection: 'keep-alive',
Accept: input.stream ? 'text/event-stream' : 'application/json',
- 'Accept-Encoding': 'identity',
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
};
if (input.isClaudeOauthUpstream) {
headers.Authorization = `Bearer ${input.tokenValue}`;
diff --git a/src/server/proxy-core/providers/registry.test.ts b/src/server/proxy-core/providers/registry.test.ts
index e8e149c..cd1fa65 100644
--- a/src/server/proxy-core/providers/registry.test.ts
+++ b/src/server/proxy-core/providers/registry.test.ts
@@ -70,7 +70,7 @@ describe('resolveProviderProfile', () => {
expect(result.headers.Authorization).toBe('Bearer oauth-access-token');
expect(result.headers['x-api-key']).toBeUndefined();
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({
executor: 'claude',
modelName: 'claude-opus-4-6',
diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts
index 03fae42..a77d6e6 100644
--- a/src/server/proxy-core/surfaces/geminiSurface.ts
+++ b/src/server/proxy-core/surfaces/geminiSurface.ts
@@ -34,6 +34,7 @@ import { dispatchRuntimeRequest } from '../../routes/proxy/runtimeExecutor.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
import { insertProxyLog } from '../../services/proxyLogStore.js';
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
+import { readRuntimeResponseText } from '../executors/types.js';
const MAX_RETRIES = 2;
const GEMINI_MODEL_PROBES = [
@@ -294,7 +295,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
geminiGenerateContentTransformer.resolveModelsUrl(selected.site.url, apiVersion, selected.tokenValue),
{ method: 'GET' },
);
- const text = await upstream.text();
+ const text = await readRuntimeResponseText(upstream);
if (!upstream.ok) {
lastStatus = upstream.status;
lastText = text;
@@ -505,7 +506,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
if (!upstream.ok) {
lastStatus = upstream.status;
lastContentType = contentType;
- lastText = await upstream.text();
+ lastText = await readRuntimeResponseText(upstream);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: upstream.status,
errorText: lastText,
@@ -613,7 +614,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
return;
}
- const text = await upstream.text();
+ const text = await readRuntimeResponseText(upstream);
const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState();
let parsedUsage = EMPTY_PROXY_USAGE;
try {
@@ -824,7 +825,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
upstreamPath = endpointResult.upstreamPath;
const upstream = endpointResult.upstream;
- const rawText = await upstream.text();
+ const rawText = await readRuntimeResponseText(upstream);
let upstreamData: unknown = rawText;
try {
upstreamData = JSON.parse(rawText);
diff --git a/src/server/routes/proxy/chat.count-tokens.test.ts b/src/server/routes/proxy/chat.count-tokens.test.ts
index 8c1c2b1..831485b 100644
--- a/src/server/routes/proxy/chat.count-tokens.test.ts
+++ b/src/server/routes/proxy/chat.count-tokens.test.ts
@@ -145,7 +145,7 @@ describe('claude count_tokens proxy route', () => {
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-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));
expect(forwardedBody.model).toBe('claude-opus-4-6');
diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts
index 41588fa..ba2ec5b 100644
--- a/src/server/routes/proxy/endpointFlow.ts
+++ b/src/server/routes/proxy/endpointFlow.ts
@@ -1,4 +1,5 @@
import { fetch } from 'undici';
+import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
import { withSiteProxyRequestInit } from '../../services/siteProxy.js';
import { summarizeUpstreamError } from './upstreamError.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 = {
endpointIndex,
endpointCount,
diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts
index 3b52b2f..56971bf 100644
--- a/src/server/routes/proxy/upstreamEndpoint.test.ts
+++ b/src/server/routes/proxy/upstreamEndpoint.test.ts
@@ -252,7 +252,7 @@ describe('resolveUpstreamEndpointCandidates', () => {
},
);
- expect(order).toEqual(['responses', 'messages', 'chat']);
+ expect(order).toEqual(['responses']);
});
it('does not remember messages fallback success for generic /v1/responses requests', async () => {
@@ -864,7 +864,7 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers['User-Agent']).toBe('claude-cli/2.1.63 (external, cli)');
expect(request.headers.Connection).toBe('keep-alive');
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', () => {
@@ -887,7 +887,7 @@ describe('buildUpstreamEndpointRequest', () => {
expect(request.headers['anthropic-beta']).toContain('oauth-2025-04-20');
expect(request.headers['anthropic-beta']).toContain('context-management-2025-06-27');
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', () => {
diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts
index 0f73d46..1aa0484 100644
--- a/src/server/routes/proxy/upstreamEndpoint.ts
+++ b/src/server/routes/proxy/upstreamEndpoint.ts
@@ -443,7 +443,7 @@ function buildClaudeRuntimeHeaders(input: {
'User-Agent': getInputHeader(input.claudeHeaders, 'user-agent') || CLAUDE_DEFAULT_USER_AGENT,
Connection: 'keep-alive',
Accept: input.stream ? 'text/event-stream' : 'application/json',
- 'Accept-Encoding': 'identity',
+ 'Accept-Encoding': 'gzip, deflate, br, zstd',
};
if (input.isClaudeOauthUpstream) {
headers.Authorization = `Bearer ${input.tokenValue}`;