feat: Implement compatibility strategies for responses and chat endpoints

- Added `routeCompatibility.ts` to handle response compatibility for different endpoints.
- Introduced `chatEndpointStrategy.ts` to manage chat endpoint recovery and downgrading logic.
- Created tests for chat formats and input file handling in `chatFormatsCore.test.ts` and `inputFile.test.ts`.
- Developed `endpointCompatibility.ts` to define compatibility checks and header building for various endpoints.
- Implemented protocol lifecycle management in `protocolLifecycle.ts` and corresponding tests in `protocolModel.test.ts`.
- Enhanced tooltip functionality with a new `TooltipLayer` component and associated tests.
- Refactored existing components to improve tooltip handling and ensure proper rendering in the application.
This commit is contained in:
cita
2026-03-09 22:48:10 +08:00
parent 15d4b207cc
commit 0f8176cd04
74 changed files with 4645 additions and 1565 deletions
@@ -119,6 +119,44 @@ describe('/api/models/token-candidates', () => {
expect(body.modelsWithoutToken['claude-haiku-4-5-20251001']).toBeUndefined();
});
it('does not report apikey connections as missing account tokens', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-apikey',
url: 'https://site-apikey.example.com',
platform: 'new-api',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'shenmo-direct',
accessToken: '',
apiToken: 'sk-shenmo-direct',
status: 'active',
extraConfig: JSON.stringify({ credentialMode: 'apikey' }),
}).returning().get();
await db.insert(schema.modelAvailability).values({
accountId: account.id,
modelName: 'gpt-5.2-codex',
available: true,
}).run();
const response = await app.inject({
method: 'GET',
url: '/api/models/token-candidates',
});
expect(response.statusCode).toBe(200);
const body = response.json() as {
modelsWithoutToken: Record<string, Array<{ accountId: number }>>;
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(body.modelsWithoutToken['gpt-5.2-codex']).toBeUndefined();
expect(body.modelsMissingTokenGroups['gpt-5.2-codex']).toBeUndefined();
});
it('returns modelsMissingTokenGroups when account has partial group token coverage', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-b',
@@ -364,7 +402,7 @@ describe('/api/models/token-candidates', () => {
]);
});
it('does not pass deprecated site apiKey into pricing catalog lookup', async () => {
it('does not request token-group hints for apikey connections', async () => {
const site = await db.insert(schema.sites).values({
name: 'site-e',
url: 'https://site-e.example.com',
@@ -425,19 +463,11 @@ describe('/api/models/token-candidates', () => {
});
expect(response.statusCode).toBe(200);
expect(fetchModelPricingCatalogMock).toHaveBeenCalled();
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]).toMatchObject({
site: {
id: site.id,
url: site.url,
platform: site.platform,
},
account: {
id: account.id,
accessToken: '',
apiToken: null,
},
});
expect(fetchModelPricingCatalogMock.mock.calls[0]?.[0]?.site?.apiKey).toBeUndefined();
const body = response.json() as {
modelsMissingTokenGroups: Record<string, Array<{ accountId: number }>>;
};
expect(fetchModelPricingCatalogMock).not.toHaveBeenCalled();
expect(body.modelsMissingTokenGroups['claude-opus-4-6']).toBeUndefined();
});
});
+16 -1
View File
@@ -17,6 +17,7 @@ import {
parseProxyLogBillingDetails,
withProxyLogSelectFields,
} from '../../services/proxyLogStore.js';
import { getCredentialModeFromExtraConfig } from '../../services/accountExtraConfig.js';
import {
formatUtcSqlDateTime,
getLocalDayRangeUtc,
@@ -30,6 +31,12 @@ function parseBooleanFlag(raw?: string): boolean {
return normalized === '1' || normalized === 'true' || normalized === 'yes';
}
function isApiKeyConnection(account: { accessToken?: string | null; extraConfig?: string | null }): boolean {
const explicit = getCredentialModeFromExtraConfig(account.extraConfig);
if (explicit && explicit !== 'auto') return explicit === 'apikey';
return !(account.accessToken || '').trim();
}
const MODELS_MARKETPLACE_BASE_TTL_MS = 15_000;
const MODELS_MARKETPLACE_PRICING_TTL_MS = 90_000;
@@ -711,6 +718,8 @@ export async function statsRoutes(app: FastifyInstance) {
username: schema.accounts.username,
siteId: schema.sites.id,
siteName: schema.sites.name,
accessToken: schema.accounts.accessToken,
extraConfig: schema.accounts.extraConfig,
})
.from(schema.modelAvailability)
.innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id))
@@ -788,6 +797,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const coverageKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -802,7 +812,11 @@ export async function statsRoutes(app: FastifyInstance) {
});
}
const accountIdsForGroupHints = new Set(availableModelRows.map((row) => row.accountId));
const accountIdsForGroupHints = new Set(
availableModelRows
.filter((row) => !isApiKeyConnection(row))
.map((row) => row.accountId),
);
const requiredGroupsByAccountModel = new Map<string, Map<string, string>>();
const hasPotentialGroupHints = hasAnyTokenGroupSignals || unknownGroupCoverageByAccountModel.size > 0;
@@ -862,6 +876,7 @@ export async function statsRoutes(app: FastifyInstance) {
}
for (const row of availableModelRows) {
if (isApiKeyConnection(row)) continue;
const modelName = (row.modelName || '').trim();
if (!modelName) continue;
const accountModelKey = `${row.accountId}::${modelName.toLowerCase()}`;
@@ -25,9 +25,23 @@ describe('proxy route architecture boundaries', () => {
expect(source).not.toContain('const promoteResponsesCandidate =');
expect(source).not.toContain('shouldRetryClaudeMessagesWithNormalizedBody(');
expect(source).not.toContain('buildOpenAiSyntheticFinalStream(');
expect(source).toContain('anthropicMessagesTransformer.consumeSseEventBlock(');
expect(source).toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream(');
expect(source).toContain('openAiChatTransformer.serializeUpstreamFinalAsStream(');
expect(source).not.toContain('anthropicMessagesTransformer.consumeSseEventBlock(');
expect(source).not.toContain('anthropicMessagesTransformer.serializeUpstreamFinalAsStream(');
expect(source).not.toContain('openAiChatTransformer.serializeUpstreamFinalAsStream(');
expect(source).toContain('openAiChatTransformer.proxyStream.createSession(');
expect(source).toContain('streamSession.consumeUpstreamFinalPayload(');
expect(source).toContain('streamSession.run(');
});
it('keeps chat endpoint retry and downgrade strategy out of the route', () => {
const source = readSource('./chat.ts');
expect(source).toContain('downstreamTransformer.compatibility.createEndpointStrategy(');
expect(source).not.toContain('anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody(');
expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility(');
expect(source).not.toContain('promoteResponsesCandidateAfterLegacyChatError(');
expect(source).not.toContain('isEndpointDowngradeError(');
expect(source).not.toContain('isEndpointDispatchDeniedError(');
expect(source).not.toContain('isUnsupportedMediaTypeError(');
});
it('keeps responses protocol assembly out of responses route', () => {
@@ -44,16 +58,27 @@ describe('proxy route architecture boundaries', () => {
expect(source).not.toContain('function shouldDowngradeFromChatToMessagesForResponses(');
expect(source).not.toContain('function normalizeText(');
expect(source).toContain('openAiResponsesTransformer.inbound.toOpenAiBody(');
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryBodies(');
expect(source).toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders(');
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldRetry(');
expect(source).toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(');
expect(source).toContain('openAiResponsesTransformer.aggregator.createState(');
expect(source).toContain('openAiResponsesTransformer.aggregator.serialize(');
expect(source).toContain('openAiResponsesTransformer.aggregator.complete(');
expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
expect(source).not.toContain('openAiResponsesTransformer.aggregator.createState(');
expect(source).not.toContain('openAiResponsesTransformer.aggregator.serialize(');
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
expect(source).toContain('streamSession.run(');
expect(source).toContain('openAiResponsesTransformer.outbound.serializeFinal(');
});
it('keeps responses endpoint retry and downgrade strategy out of the route', () => {
const source = readSource('./responses.ts');
expect(source).toContain('openAiResponsesTransformer.compatibility.createEndpointStrategy(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldRetry(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryBodies(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.buildRetryHeaders(');
expect(source).not.toContain('openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(');
expect(source).not.toContain('buildMinimalJsonHeadersForCompatibility(');
expect(source).not.toContain('isEndpointDowngradeError(');
expect(source).not.toContain('isUnsupportedMediaTypeError(');
});
it('removes normalizeContentText from upstream endpoint routing', () => {
const source = readSource('./upstreamEndpoint.ts');
expect(source).not.toContain('function normalizeContentText(');
@@ -71,5 +96,25 @@ describe('proxy route architecture boundaries', () => {
expect(source).toContain('stream.consumeUpstreamSseBuffer(');
expect(source).toContain('stream.serializeUpstreamJsonPayload(');
});
it('keeps chat stream lifecycle behind transformer-owned facade', () => {
const source = readSource('./chat.ts');
expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'");
expect(source).not.toContain('createProxyStreamLifecycle');
expect(source).not.toContain('let shouldTerminateEarly = false;');
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
expect(source).not.toContain('writeDone();');
expect(source).toContain('openAiChatTransformer.proxyStream.createSession(');
});
it('keeps responses stream lifecycle behind transformer-owned facade', () => {
const source = readSource('./responses.ts');
expect(source).not.toContain("from '../../transformers/shared/protocolLifecycle.js'");
expect(source).not.toContain('createProxyStreamLifecycle');
expect(source).not.toContain('const consumeSseBuffer = (incoming: string): string => {');
expect(source).not.toContain('openAiResponsesTransformer.aggregator.complete(');
expect(source).not.toContain('reply.raw.end();');
expect(source).toContain('openAiResponsesTransformer.proxyStream.createSession(');
});
});
@@ -0,0 +1,24 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), 'utf8');
}
describe('proxy route semantic ownership boundaries', () => {
it('keeps chat stream closeout semantics out of the route', () => {
const source = readSource('./chat.ts');
expect(source).not.toContain('const finalizeChatStream =');
expect(source).not.toContain('openAiChatTransformer.buildSyntheticChunks(');
expect(source).not.toContain('openAiChatTransformer.aggregator.finalize(');
});
it('keeps responses stream closeout semantics out of the route', () => {
const source = readSource('./responses.ts');
expect(source).not.toContain('const finalizeResponsesSse =');
expect(source).not.toContain("reply.raw.write('data: [DONE]");
expect(source).not.toContain('successfulUpstreamPath === \'/v1/responses\'');
});
});
+150 -2
View File
@@ -201,6 +201,117 @@ describe('chat proxy stream behavior', () => {
expect(response.body).toContain('data: [DONE]');
});
it('normalizes inline think tags into reasoning_content for /v1/chat/completions streams', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"<think>plan quietly</think>"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","model":"upstream-gpt","choices":[{"delta":{"content":"visible answer"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think","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/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'show your work and answer' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"reasoning_content":"plan quietly"');
expect(response.body).toContain('"content":"visible answer"');
expect(response.body).not.toContain('<think>');
expect(response.body).not.toContain('</think>');
expect(response.body).toContain('data: [DONE]');
});
it('tracks split inline think tags across SSE chunks for /v1/chat/completions streams', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"<thin"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"k>plan "},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"quietly</th"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"ink>visible "},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","model":"upstream-gpt","choices":[{"delta":{"content":"answer"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-think-split","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/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'show your work and answer' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('"reasoning_content":"plan "');
expect(response.body).toContain('"reasoning_content":"quietly"');
expect(response.body).toContain('"content":"visible "');
expect(response.body).toContain('"content":"answer"');
expect(response.body).not.toContain('<think>');
expect(response.body).not.toContain('</think>');
expect(response.body).not.toContain('<thin');
expect(response.body).not.toContain('quietly</th');
expect(response.body).not.toContain('ink>visible');
expect(response.body).toContain('data: [DONE]');
});
it('synthesizes a terminal finish chunk when /v1/chat/completions upstream EOFs after visible content', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"role":"assistant"},"finish_reason":null}]}\n\n'));
controller.enqueue(encoder.encode('data: {"id":"chatcmpl-eof","model":"upstream-gpt","choices":[{"delta":{"content":"tail before eof"},"finish_reason":null}]}\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/chat/completions',
payload: {
model: 'gpt-4o-mini',
stream: true,
messages: [{ role: 'user', content: 'finish cleanly' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('tail before eof');
expect(response.body).toContain('"finish_reason":"stop"');
expect(response.body).toContain('data: [DONE]');
});
it('normalizes anthropic-style SSE events into OpenAI chunks for clients like OpenWebUI', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
@@ -486,6 +597,40 @@ describe('chat proxy stream behavior', () => {
expect(response.body).not.toContain('event: ping');
});
it('does not synthesize message_stop when anthropic upstream EOFs before terminal event on /v1/messages', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode('event: message_start\ndata: {"type":"message_start","message":{"id":"msg_eof_early","model":"claude-opus-4-6"}}\n\n'));
controller.enqueue(encoder.encode('event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n'));
controller.enqueue(encoder.encode('event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\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/messages',
payload: {
model: 'claude-opus-4-6',
stream: true,
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
},
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain('event: message_start');
expect(response.body).toContain('event: content_block_delta');
expect(response.body).not.toContain('event: message_stop');
expect(response.body).not.toContain('"stop_reason":"end_turn"');
});
it('normalizes Claude thinking adaptive type for legacy upstreams on /v1/messages', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
id: 'msg_headers_adaptive',
@@ -839,7 +984,7 @@ describe('chat proxy stream behavior', () => {
expect(body.output_text).toContain('ok from messages fallback');
});
it('passes through /v1/responses SSE payloads', async () => {
it('canonicalizes native /v1/responses SSE payloads instead of passing them through raw', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
start(controller) {
@@ -867,6 +1012,9 @@ describe('chat proxy stream behavior', () => {
expect(response.statusCode).toBe(200);
expect(response.headers['content-type']).toContain('text/event-stream');
expect(response.body).toContain('response.output_text.delta');
expect(response.body).toContain('response.output_text.done');
expect(response.body).toContain('response.output_item.done');
expect(response.body).toContain('response.completed');
expect(response.body).toContain('[DONE]');
});
@@ -1524,7 +1672,7 @@ describe('chat proxy stream behavior', () => {
expect(deltaMatches.length).toBe(1);
const textMatches = response.body.match(/I'm Claude, an AI assistant made by Anthropic\./g) || [];
expect(textMatches.length).toBeGreaterThan(0);
expect(textMatches.length).toBeLessThanOrEqual(4);
expect(textMatches.length).toBeLessThanOrEqual(6);
});
it('deduplicates overlapping text windows when /v1/responses is converted from /v1/messages stream', async () => {
+131 -275
View File
@@ -11,12 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
buildMinimalJsonHeadersForCompatibility,
buildUpstreamEndpointRequest,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
import {
@@ -25,11 +20,12 @@ import {
recordDownstreamCostUsage,
} from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
import { executeEndpointFlow } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { openAiChatTransformer } from '../../transformers/openai/chat/index.js';
import { anthropicMessagesTransformer } from '../../transformers/anthropic/messages/index.js';
import { detectDownstreamClientContext, type DownstreamClientContext } from './downstreamClientContext.js';
import { getProxyResourceOwner } from '../../middleware/auth.js';
import {
ProxyInputFileResolutionError,
@@ -39,10 +35,6 @@ import {
const MAX_RETRIES = 2;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
export async function chatProxyRoute(app: FastifyInstance) {
app.post('/v1/chat/completions', async (request: FastifyRequest, reply: FastifyReply) =>
handleChatProxyRequest(request, reply, 'openai'));
@@ -61,13 +53,24 @@ async function handleChatProxyRequest(
const downstreamTransformer = downstreamFormat === 'claude'
? anthropicMessagesTransformer
: openAiChatTransformer;
const parsedRequest = downstreamTransformer.transformRequest(request.body);
if (parsedRequest.error) {
return reply.code(parsedRequest.error.statusCode).send(parsedRequest.error.payload);
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body: request.body,
});
const parsedRequestEnvelope = downstreamTransformer.transformRequest(request.body);
if (parsedRequestEnvelope.error) {
return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload);
}
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
const requestEnvelope = parsedRequestEnvelope.value!;
const {
requestedModel,
isStream,
upstreamBody,
claudeOriginalBody,
} = requestEnvelope.parsed;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const owner = getProxyResourceOwner(request);
@@ -110,8 +113,8 @@ async function handleChatProxyRequest(
excludeChannelIds.push(selected.channel.id);
const modelName = selected.actualModel || requestedModel;
const endpointCandidates = [
...await resolveUpstreamEndpointCandidates(
const endpointCandidates = [
...await resolveUpstreamEndpointCandidates(
{
site: selected.site,
account: selected.account,
@@ -122,8 +125,52 @@ async function handleChatProxyRequest(
{
hasNonImageFileInput,
},
),
];
),
];
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
options: { forceNormalizeClaudeBody?: boolean } = {},
) => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: resolvedOpenAiBody,
downstreamFormat,
claudeOriginalBody,
forceNormalizeClaudeBody: options.forceNormalizeClaudeBody,
downstreamHeaders: request.headers as Record<string, unknown>,
});
return {
endpoint,
path: endpointRequest.path,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
};
};
const endpointStrategy = downstreamTransformer.compatibility.createEndpointStrategy({
downstreamFormat,
endpointCandidates,
modelName,
requestedModelHint: requestedModel,
sitePlatform: selected.site.platform,
isStream,
buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest(
endpoint,
{ forceNormalizeClaudeBody },
),
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: compatibilityRequest.headers,
body: JSON.stringify(compatibilityRequest.body),
}),
),
});
let startTime = Date.now();
try {
@@ -131,126 +178,9 @@ async function handleChatProxyRequest(
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: resolvedOpenAiBody,
downstreamFormat,
claudeOriginalBody,
downstreamHeaders: request.headers as Record<string, unknown>,
});
return {
endpoint,
path: endpointRequest.path,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
};
},
tryRecover: async (ctx) => {
if (anthropicMessagesTransformer.compatibility.shouldRetryNormalizedBody({
downstreamFormat,
endpointPath: ctx.request.path,
status: ctx.response.status,
upstreamErrorText: ctx.rawErrText,
})) {
const normalizedClaudeRequest = buildUpstreamEndpointRequest({
endpoint: ctx.request.endpoint,
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: resolvedOpenAiBody,
downstreamFormat,
claudeOriginalBody,
forceNormalizeClaudeBody: true,
downstreamHeaders: request.headers as Record<string, unknown>,
});
const normalizedTargetUrl = `${selected.site.url}${normalizedClaudeRequest.path}`;
const normalizedResponse = await fetch(normalizedTargetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: normalizedClaudeRequest.headers,
body: JSON.stringify(normalizedClaudeRequest.body),
}));
if (normalizedResponse.ok) {
return {
upstream: normalizedResponse,
upstreamPath: normalizedClaudeRequest.path,
};
}
ctx.request = {
...ctx.request,
path: normalizedClaudeRequest.path,
headers: normalizedClaudeRequest.headers,
body: normalizedClaudeRequest.body as Record<string, unknown>,
};
ctx.response = normalizedResponse;
ctx.rawErrText = await normalizedResponse.text().catch(() => 'unknown error');
}
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
return null;
}
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
headers: ctx.request.headers,
endpoint: ctx.request.endpoint,
stream: isStream,
});
const normalizedCurrentHeaders = Object.fromEntries(
Object.entries(ctx.request.headers).map(([key, value]) => [key.toLowerCase(), value]),
);
if (JSON.stringify(minimalHeaders) === JSON.stringify(normalizedCurrentHeaders)) {
return null;
}
const minimalResponse = await fetch(ctx.targetUrl, withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: minimalHeaders,
body: JSON.stringify(ctx.request.body),
}));
if (minimalResponse.ok) {
return {
upstream: minimalResponse,
upstreamPath: ctx.request.path,
};
}
ctx.request = {
...ctx.request,
headers: minimalHeaders,
};
ctx.response = minimalResponse;
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
return null;
},
shouldDowngrade: (ctx) => (
(() => {
promoteResponsesCandidateAfterLegacyChatError(endpointCandidates, {
status: ctx.response.status,
upstreamErrorText: ctx.rawErrText,
downstreamFormat,
sitePlatform: selected.site.platform,
modelName,
requestedModelHint: requestedModel,
currentEndpoint: ctx.request.endpoint,
});
return (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| anthropicMessagesTransformer.compatibility.isMessagesRequiredError(ctx.rawErrText)
|| isEndpointDispatchDeniedError(ctx.response.status, ctx.rawErrText)
);
})()
),
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
selected,
@@ -261,6 +191,13 @@ async function handleChatProxyRequest(
ctx.errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
},
});
@@ -269,7 +206,23 @@ async function handleChatProxyRequest(
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
logProxy(
selected,
requestedModel,
'failed',
status,
Date.now() - startTime,
errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -306,8 +259,6 @@ async function handleChatProxyRequest(
reply.raw.setHeader('Connection', 'keep-alive');
reply.raw.setHeader('X-Accel-Buffering', 'no');
const streamContext = downstreamTransformer.createStreamContext(modelName);
const claudeContext = anthropicMessagesTransformer.createDownstreamContext();
let parsedUsage: ReturnType<typeof parseProxyUsage> = {
promptTokens: 0,
completionTokens: 0,
@@ -322,10 +273,19 @@ async function handleChatProxyRequest(
reply.raw.write(line);
}
};
const writeDone = () => {
writeLines(downstreamTransformer.serializeDone(streamContext, claudeContext));
};
const streamSession = openAiChatTransformer.proxyStream.createSession({
downstreamFormat,
modelName,
onParsedPayload: (payload) => {
if (payload && typeof payload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
}
},
writeLines,
writeRaw: (chunk) => {
reply.raw.write(chunk);
},
});
const upstreamContentType = (upstream.headers.get('content-type') || '').toLowerCase();
if (!upstreamContentType.includes('text/event-stream')) {
@@ -336,29 +296,7 @@ async function handleChatProxyRequest(
} catch {
fallbackData = fallbackText;
}
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(fallbackData));
if (downstreamFormat === 'openai') {
const syntheticLines = openAiChatTransformer.serializeUpstreamFinalAsStream(
fallbackData,
modelName,
fallbackText,
streamContext,
);
writeLines(syntheticLines);
} else {
writeLines(
anthropicMessagesTransformer.serializeUpstreamFinalAsStream(
fallbackData,
modelName,
fallbackText,
streamContext,
claudeContext,
),
);
}
writeDone();
reply.raw.end();
streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
@@ -407,111 +345,7 @@ async function handleChatProxyRequest(
}
const reader = upstream.body?.getReader();
if (!reader) {
writeDone();
reply.raw.end();
return;
}
const decoder = new TextDecoder();
let sseBuffer = '';
let shouldTerminateEarly = false;
const consumeSseBuffer = (incoming: string): string => {
const pulled = downstreamTransformer.pullSseEvents(incoming);
for (const eventBlock of pulled.events) {
if (eventBlock.data === '[DONE]') {
writeDone();
shouldTerminateEarly = true;
continue;
}
let parsedPayload: unknown = null;
if (downstreamFormat === 'claude') {
const consumed = anthropicMessagesTransformer.consumeSseEventBlock(
eventBlock,
streamContext,
claudeContext,
modelName,
);
parsedPayload = consumed.parsedPayload;
if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
}
if (consumed.handled) {
writeLines(consumed.lines);
if (consumed.done) {
shouldTerminateEarly = true;
break;
}
continue;
}
} else {
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
}
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = downstreamTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
writeLines(downstreamTransformer.serializeStreamEvent(normalizedEvent, streamContext, claudeContext));
if (downstreamFormat === 'claude' && claudeContext.doneSent) {
shouldTerminateEarly = true;
break;
}
if (streamContext.doneSent) {
shouldTerminateEarly = true;
break;
}
continue;
}
if (downstreamFormat === 'openai') {
reply.raw.write(`data: ${eventBlock.data}\n\n`);
} else {
writeLines(anthropicMessagesTransformer.serializeStreamEvent({
contentDelta: eventBlock.data,
}, streamContext, claudeContext));
if (claudeContext.doneSent) {
shouldTerminateEarly = true;
break;
}
}
}
return pulled.rest;
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
sseBuffer += decoder.decode(value, { stream: true });
sseBuffer = consumeSseBuffer(sseBuffer);
if (shouldTerminateEarly) {
await reader.cancel().catch(() => {});
break;
}
}
if (!shouldTerminateEarly) {
sseBuffer += decoder.decode();
}
if (!shouldTerminateEarly && sseBuffer.trim().length > 0) {
sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`);
}
} finally {
reader.releaseLock();
writeDone();
reply.raw.end();
}
await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
@@ -618,7 +452,23 @@ async function handleChatProxyRequest(
return reply.send(downstreamResponse);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount, downstreamPath);
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err?.message || 'network error',
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -655,10 +505,16 @@ async function logProxy(
estimatedCost = 0,
billingDetails: unknown = null,
upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
upstreamPath,
errorMessage,
@@ -0,0 +1,246 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn();
const recordFailureMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn();
const reportProxyAllFailedMock = vi.fn();
const reportTokenExpiredMock = vi.fn();
const estimateProxyCostMock = vi.fn(async (_arg?: any) => 0);
const buildProxyBillingDetailsMock = vi.fn(async (_arg?: any) => null);
const fetchModelPricingCatalogMock = vi.fn(async (_arg?: any): Promise<any> => null);
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) => ({
...usage,
estimatedCostFromQuota: 0,
recoveredFromSelfLog: false,
}));
const dbValuesMock = vi.fn((_arg?: any) => ({
run: () => undefined,
}));
const dbInsertMock = vi.fn((_arg?: any) => ({
values: (arg: any) => dbValuesMock(arg),
}));
vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args),
selectNextChannel: (...args: unknown[]) => selectNextChannelMock(...args),
recordSuccess: (...args: unknown[]) => recordSuccessMock(...args),
recordFailure: (...args: unknown[]) => recordFailureMock(...args),
},
}));
vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
}));
vi.mock('../../services/alertService.js', () => ({
reportProxyAllFailed: (...args: unknown[]) => reportProxyAllFailedMock(...args),
reportTokenExpired: (...args: unknown[]) => reportTokenExpiredMock(...args),
}));
vi.mock('../../services/alertRules.js', () => ({
isTokenExpiredError: () => false,
}));
vi.mock('../../services/modelPricingService.js', () => ({
estimateProxyCost: (arg: any) => estimateProxyCostMock(arg),
buildProxyBillingDetails: (arg: any) => buildProxyBillingDetailsMock(arg),
fetchModelPricingCatalog: (arg: any) => fetchModelPricingCatalogMock(arg),
}));
vi.mock('../../services/proxyRetryPolicy.js', () => ({
shouldRetryProxyRequest: () => false,
}));
vi.mock('../../services/proxyUsageFallbackService.js', () => ({
resolveProxyUsageWithSelfLogFallback: (arg: any) => resolveProxyUsageWithSelfLogFallbackMock(arg),
}));
vi.mock('../../db/index.js', () => ({
db: {
insert: (arg: any) => dbInsertMock(arg),
},
schema: {
proxyLogs: {},
},
}));
describe('downstream client context route logging', () => {
let app: FastifyInstance;
beforeAll(async () => {
const { claudeMessagesProxyRoute } = await import('./chat.js');
const { responsesProxyRoute } = await import('./responses.js');
app = Fastify();
await app.register(claudeMessagesProxyRoute);
await app.register(responsesProxyRoute);
});
beforeEach(() => {
fetchMock.mockReset();
selectChannelMock.mockReset();
selectNextChannelMock.mockReset();
recordSuccessMock.mockReset();
recordFailureMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset();
reportProxyAllFailedMock.mockReset();
reportTokenExpiredMock.mockReset();
estimateProxyCostMock.mockClear();
buildProxyBillingDetailsMock.mockClear();
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
dbValuesMock.mockClear();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
site: { id: 44, name: 'demo-site', url: 'https://upstream.example.com', platform: 'openai' },
account: { id: 33, username: 'demo-user' },
tokenName: 'default',
tokenValue: 'sk-demo',
actualModel: 'upstream-gpt',
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
});
afterAll(async () => {
await app.close();
});
it('includes Codex client and session metadata in /v1/responses failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'bad request',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
headers: {
originator: 'codex_cli_rs',
Session_id: 'codex-session-123',
},
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[client:codex]');
expect(insertedLog.errorMessage).toContain('[session:codex-session-123]');
expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses]');
});
it('reuses the same Codex detection on /v1/responses/compact failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'compact failed',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses/compact',
headers: {
'x-stainless-lang': 'typescript',
Session_id: 'codex-session-compact',
},
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[client:codex]');
expect(insertedLog.errorMessage).toContain('[session:codex-session-compact]');
expect(insertedLog.errorMessage).toContain('[downstream:/v1/responses/compact]');
});
it('includes Claude Code client and session metadata in /v1/messages failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'messages failed',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
metadata: {
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
},
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[client:claude_code]');
expect(insertedLog.errorMessage).toContain('[session:f25958b8-e75c-455d-8b40-f006d87cc2a4]');
expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]');
});
it('keeps invalid Claude metadata.user_id requests generic in failure logs', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({
error: {
message: 'messages failed',
type: 'upstream_error',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/messages',
payload: {
model: 'claude-opus-4-6',
max_tokens: 256,
messages: [{ role: 'user', content: 'hello' }],
metadata: {
user_id: 'user_123',
},
},
});
expect(response.statusCode).toBe(400);
expect(dbValuesMock).toHaveBeenCalled();
const insertedLog = dbValuesMock.mock.calls.at(-1)?.[0];
expect(insertedLog.errorMessage).toContain('[downstream:/v1/messages]');
expect(insertedLog.errorMessage).not.toContain('[client:');
expect(insertedLog.errorMessage).not.toContain('[session:');
});
});
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest';
import {
detectDownstreamClientContext,
extractClaudeCodeSessionId,
isCodexResponsesSurface,
} from './downstreamClientContext.js';
describe('extractClaudeCodeSessionId', () => {
it('extracts session uuid from axonhub-compatible Claude Code user ids', () => {
expect(extractClaudeCodeSessionId(
'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
)).toBe('f25958b8-e75c-455d-8b40-f006d87cc2a4');
});
it('returns null for non-Claude-Code user ids', () => {
expect(extractClaudeCodeSessionId('user_123')).toBe(null);
expect(extractClaudeCodeSessionId('session_f25958b8-e75c-455d-8b40-f006d87cc2a4')).toBe(null);
});
});
describe('isCodexResponsesSurface', () => {
it('detects Codex responses surface from originator and stainless headers', () => {
expect(isCodexResponsesSurface({
originator: 'codex_cli_rs',
})).toBe(true);
expect(isCodexResponsesSurface({
'x-stainless-lang': 'typescript',
})).toBe(true);
});
it('returns false for generic responses clients', () => {
expect(isCodexResponsesSurface({
'content-type': 'application/json',
})).toBe(false);
});
});
describe('detectDownstreamClientContext', () => {
it('recognizes Codex requests and attaches Session_id as session and trace hint', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses',
headers: {
originator: 'codex_cli_rs',
Session_id: 'codex-session-123',
},
})).toEqual({
clientKind: 'codex',
sessionId: 'codex-session-123',
traceHint: 'codex-session-123',
});
});
it('keeps Codex requests without Session_id as client-only context', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/responses/compact',
headers: {
'x-stainless-lang': 'typescript',
},
})).toEqual({
clientKind: 'codex',
});
});
it('recognizes Claude Code requests from metadata.user_id without mutating the body', () => {
const body = {
model: 'claude-opus-4-6',
metadata: {
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
},
};
expect(detectDownstreamClientContext({
downstreamPath: '/v1/messages',
body,
})).toEqual({
clientKind: 'claude_code',
sessionId: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
traceHint: 'f25958b8-e75c-455d-8b40-f006d87cc2a4',
});
expect(body).toEqual({
model: 'claude-opus-4-6',
metadata: {
user_id: 'user_20836b5653ed68aa981604f502c0a491397f6053826a93c953423632578d38ad_account__session_f25958b8-e75c-455d-8b40-f006d87cc2a4',
},
});
});
it('falls back to generic when Claude metadata.user_id is missing or invalid', () => {
expect(detectDownstreamClientContext({
downstreamPath: '/v1/messages',
body: {
metadata: {
user_id: 'user_123',
},
},
})).toEqual({
clientKind: 'generic',
});
expect(detectDownstreamClientContext({
downstreamPath: '/v1/messages',
body: {
metadata: {
session_id: 'abc123',
},
},
})).toEqual({
clientKind: 'generic',
});
});
});
@@ -0,0 +1,120 @@
export type DownstreamClientKind = 'generic' | 'codex' | 'claude_code';
export type DownstreamClientContext = {
clientKind: DownstreamClientKind;
sessionId?: string;
traceHint?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
function headerValueToString(value: unknown): string | null {
if (typeof value === 'string') {
const trimmed = value.trim();
return trimmed || null;
}
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item !== 'string') continue;
const trimmed = item.trim();
if (trimmed) return trimmed;
}
}
return null;
}
function getHeaderValue(headers: Record<string, unknown> | undefined, targetKey: string): string | null {
if (!headers) return null;
const normalizedTarget = targetKey.trim().toLowerCase();
for (const [rawKey, rawValue] of Object.entries(headers)) {
if (rawKey.trim().toLowerCase() !== normalizedTarget) continue;
return headerValueToString(rawValue);
}
return null;
}
export function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
if (!headers) return false;
let sawOpenAiBeta = false;
let sawStainless = false;
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
const value = headerValueToString(rawValue);
if (!key || !value) continue;
if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') {
return true;
}
if (key === 'openai-beta') {
sawOpenAiBeta = true;
}
if (key.startsWith('x-stainless-')) {
sawStainless = true;
}
}
return sawOpenAiBeta || sawStainless;
}
const claudeCodeUserIdPattern = /^user_[0-9a-f]{64}_account__session_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function extractClaudeCodeSessionId(userId: string): string | null {
const trimmed = userId.trim();
if (!claudeCodeUserIdPattern.test(trimmed)) return null;
const sessionPrefix = '__session_';
const sessionIndex = trimmed.lastIndexOf(sessionPrefix);
if (sessionIndex === -1) return null;
const sessionId = trimmed.slice(sessionIndex + sessionPrefix.length).trim();
return sessionId || null;
}
export function detectDownstreamClientContext(input: {
downstreamPath: string;
headers?: Record<string, unknown>;
body?: unknown;
}): DownstreamClientContext {
const normalizedPath = input.downstreamPath.trim().toLowerCase();
if (normalizedPath === '/v1/messages' || normalizedPath === '/anthropic/v1/messages') {
if (isRecord(input.body) && isRecord(input.body.metadata)) {
const userId = typeof input.body.metadata.user_id === 'string'
? input.body.metadata.user_id.trim()
: '';
const sessionId = userId ? extractClaudeCodeSessionId(userId) : null;
if (sessionId) {
return {
clientKind: 'claude_code',
sessionId,
traceHint: sessionId,
};
}
}
return { clientKind: 'generic' };
}
if (normalizedPath.startsWith('/v1/responses') && isCodexResponsesSurface(input.headers)) {
const sessionId = getHeaderValue(input.headers, 'session_id') || getHeaderValue(input.headers, 'session-id');
if (sessionId) {
return {
clientKind: 'codex',
sessionId,
traceHint: sessionId,
};
}
return { clientKind: 'codex' };
}
return { clientKind: 'generic' };
}
@@ -31,4 +31,22 @@ describe('composeProxyLogMessage', () => {
errorMessage: '',
})).toBe(null);
});
it('adds client and session metadata ahead of path metadata', () => {
expect(composeProxyLogMessage({
clientKind: 'codex',
sessionId: 'codex-session-123',
downstreamPath: '/v1/responses',
errorMessage: 'upstream failed',
})).toBe('[client:codex] [session:codex-session-123] [downstream:/v1/responses] upstream failed');
});
it('reuses existing client and session metadata without duplication', () => {
expect(composeProxyLogMessage({
clientKind: 'claude_code',
sessionId: 'session-123',
downstreamPath: '/v1/messages',
errorMessage: '[client:claude_code] [session:session-123] [upstream:/v1/messages] bad request',
})).toBe('[client:claude_code] [session:session-123] [downstream:/v1/messages] [upstream:/v1/messages] bad request');
});
});
+20 -1
View File
@@ -1,20 +1,32 @@
type ComposeProxyLogMessageArgs = {
clientKind?: string | null;
sessionId?: string | null;
traceHint?: string | null;
downstreamPath?: string | null;
upstreamPath?: string | null;
errorMessage?: string | null;
};
type ParsedPathMeta = {
clientKind: string | null;
sessionId: string | null;
downstreamPath: string | null;
upstreamPath: string | null;
messageText: string;
};
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
const clientMatch = rawMessage.match(/\[client:([^\]]+)\]/i);
const sessionMatch = rawMessage.match(/\[session:([^\]]+)\]/i);
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
const messageText = rawMessage.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
const messageText = rawMessage.replace(
/^\s*(?:\[(?:client|session|downstream|upstream):[^\]]+\]\s*)+/i,
'',
).trim();
return {
clientKind: clientMatch?.[1]?.trim() || null,
sessionId: sessionMatch?.[1]?.trim() || null,
downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null,
messageText,
@@ -22,17 +34,24 @@ function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
}
export function composeProxyLogMessage({
clientKind,
sessionId,
traceHint,
downstreamPath,
upstreamPath,
errorMessage,
}: ComposeProxyLogMessageArgs): string | null {
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
const parsed = parseExistingPathMeta(rawMessage);
const finalClientKind = (clientKind || parsed.clientKind || '').trim();
const finalSessionId = (sessionId || traceHint || parsed.sessionId || '').trim();
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
const finalMessageText = parsed.messageText.trim();
const prefixParts: string[] = [];
if (finalClientKind) prefixParts.push(`[client:${finalClientKind}]`);
if (finalSessionId) prefixParts.push(`[session:${finalSessionId}]`);
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
+124 -262
View File
@@ -11,10 +11,7 @@ import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParse
import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import {
buildMinimalJsonHeadersForCompatibility,
buildUpstreamEndpointRequest,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
@@ -23,6 +20,7 @@ import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { resolveProxyLogBilling } from './proxyBilling.js';
import { getProxyResourceOwner } from '../../middleware/auth.js';
import { detectDownstreamClientContext, isCodexResponsesSurface, type DownstreamClientContext } from './downstreamClientContext.js';
import { normalizeInputFileBlock } from '../../transformers/shared/inputFile.js';
import {
ProxyInputFileResolutionError,
@@ -85,42 +83,6 @@ function carriesResponsesReasoningContinuity(value: unknown): boolean {
|| carriesResponsesReasoningContinuity(value.content);
}
function isCodexResponsesSurface(headers?: Record<string, unknown>): boolean {
if (!headers) return false;
const normalizeHeaderValue = (value: unknown): string => {
if (typeof value === 'string') return value.trim();
if (Array.isArray(value)) {
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.find((item) => item.length > 0) || '';
}
return '';
};
let sawOpenAiBeta = false;
let sawStainless = false;
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
const value = normalizeHeaderValue(rawValue);
if (!key || !value) continue;
if (key === 'originator' && value.toLowerCase() === 'codex_cli_rs') {
return true;
}
if (key === 'openai-beta') {
sawOpenAiBeta = true;
}
if (key.startsWith('x-stainless-')) {
sawStainless = true;
}
}
return sawOpenAiBeta || sawStainless;
}
function wantsNativeResponsesReasoning(body: unknown): boolean {
if (!isRecord(body)) return false;
const include = normalizeIncludeList(body.include);
@@ -156,16 +118,27 @@ export async function responsesProxyRoute(app: FastifyInstance) {
reply: FastifyReply,
downstreamPath: string,
) => {
const body = request.body as any;
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
const body = request.body as Record<string, unknown>;
const clientContext = detectDownstreamClientContext({
downstreamPath,
headers: request.headers as Record<string, unknown>,
body,
});
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
request.headers as Record<string, unknown>,
);
const parsedRequestEnvelope = openAiResponsesTransformer.transformRequest(body, {
defaultEncryptedReasoningInclude,
});
if (parsedRequestEnvelope.error) {
return reply.code(parsedRequestEnvelope.error.statusCode).send(parsedRequestEnvelope.error.payload);
}
const requestEnvelope = parsedRequestEnvelope.value!;
const requestedModel = requestEnvelope.model;
const isStream = requestEnvelope.stream;
if (!await ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
const isCompactRequest = downstreamPath === '/v1/responses/compact';
const isStream = body.stream === true;
const excludeChannelIds: number[] = [];
let retryCount = 0;
@@ -193,15 +166,11 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const modelName = selected.actualModel || requestedModel;
const owner = getProxyResourceOwner(request);
const defaultEncryptedReasoningInclude = isCodexResponsesSurface(
request.headers as Record<string, unknown>,
);
let normalizedResponsesBody = openAiResponsesTransformer.inbound.sanitizeProxyBody(
body,
modelName,
isStream,
{ defaultEncryptedReasoningInclude },
);
let normalizedResponsesBody: Record<string, unknown> = {
...requestEnvelope.parsed.normalizedBody,
model: modelName,
stream: isStream,
};
if (owner) {
try {
normalizedResponsesBody = await resolveResponsesBodyInputFiles(normalizedResponsesBody, owner);
@@ -248,6 +217,43 @@ export async function responsesProxyRoute(app: FastifyInstance) {
if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages');
}
const buildEndpointRequest = (endpoint: 'chat' | 'messages' | 'responses') => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: openAiBody,
downstreamFormat: 'responses',
responsesOriginalBody: normalizedResponsesBody,
downstreamHeaders: request.headers as Record<string, unknown>,
});
const upstreamPath = (
isCompactRequest && endpoint === 'responses'
? `${endpointRequest.path}/compact`
: endpointRequest.path
);
return {
endpoint,
path: upstreamPath,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
};
};
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
isStream,
requiresNativeResponsesFileUrl,
dispatchRequest: (compatibilityRequest, targetUrl) => fetch(
targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: compatibilityRequest.headers,
body: JSON.stringify(compatibilityRequest.body),
}),
),
});
const startTime = Date.now();
@@ -256,114 +262,9 @@ export async function responsesProxyRoute(app: FastifyInstance) {
siteUrl: selected.site.url,
proxyUrl: resolveProxyUrlForSite(selected.site),
endpointCandidates,
buildRequest: (endpoint) => {
const endpointRequest = buildUpstreamEndpointRequest({
endpoint,
modelName,
stream: isStream,
tokenValue: selected.tokenValue,
sitePlatform: selected.site.platform,
siteUrl: selected.site.url,
openaiBody: openAiBody,
downstreamFormat: 'responses',
responsesOriginalBody: normalizedResponsesBody,
downstreamHeaders: request.headers as Record<string, unknown>,
});
const upstreamPath = (
isCompactRequest && endpoint === 'responses'
? `${endpointRequest.path}/compact`
: endpointRequest.path
);
return {
endpoint,
path: upstreamPath,
headers: endpointRequest.headers,
body: endpointRequest.body as Record<string, unknown>,
};
},
tryRecover: async (ctx) => {
if (openAiResponsesTransformer.compatibility.shouldRetry({
endpoint: ctx.request.endpoint,
status: ctx.response.status,
rawErrText: ctx.rawErrText,
})) {
const compatibilityBodies = openAiResponsesTransformer.compatibility.buildRetryBodies(ctx.request.body);
const compatibilityHeaders = openAiResponsesTransformer.compatibility.buildRetryHeaders(
ctx.request.headers,
isStream,
);
for (const compatibilityHeadersCandidate of compatibilityHeaders) {
for (const compatibilityBody of compatibilityBodies) {
const compatibilityResponse = await fetch(
ctx.targetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: compatibilityHeadersCandidate,
body: JSON.stringify(compatibilityBody),
}),
);
if (compatibilityResponse.ok) {
return {
upstream: compatibilityResponse,
upstreamPath: ctx.request.path,
};
}
ctx.request = {
...ctx.request,
headers: compatibilityHeadersCandidate,
body: compatibilityBody,
};
ctx.response = compatibilityResponse;
ctx.rawErrText = await compatibilityResponse.text().catch(() => 'unknown error');
}
}
}
if (!isUnsupportedMediaTypeError(ctx.response.status, ctx.rawErrText)) {
return null;
}
const minimalHeaders = buildMinimalJsonHeadersForCompatibility({
headers: ctx.request.headers,
endpoint: ctx.request.endpoint,
stream: isStream,
});
const minimalResponse = await fetch(
ctx.targetUrl,
withSiteRecordProxyRequestInit(selected.site, {
method: 'POST',
headers: minimalHeaders,
body: JSON.stringify(ctx.request.body),
}),
);
if (minimalResponse.ok) {
return {
upstream: minimalResponse,
upstreamPath: ctx.request.path,
};
}
ctx.request = {
...ctx.request,
headers: minimalHeaders,
};
ctx.response = minimalResponse;
ctx.rawErrText = await minimalResponse.text().catch(() => 'unknown error');
return null;
},
shouldDowngrade: (ctx) => (
!requiresNativeResponsesFileUrl && (
ctx.response.status >= 500
|| isEndpointDowngradeError(ctx.response.status, ctx.rawErrText)
|| openAiResponsesTransformer.compatibility.shouldDowngradeChatToMessages(
ctx.request.path,
ctx.response.status,
ctx.rawErrText,
)
)
),
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
tryRecover: endpointStrategy.tryRecover,
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
selected,
@@ -374,6 +275,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
ctx.errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
},
});
@@ -382,7 +290,23 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
logProxy(
selected,
requestedModel,
'failed',
status,
Date.now() - startTime,
errText,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -416,12 +340,6 @@ export async function responsesProxyRoute(app: FastifyInstance) {
reply.raw.setHeader('X-Accel-Buffering', 'no');
const reader = upstream.body?.getReader();
if (!reader) {
reply.raw.end();
return;
}
const decoder = new TextDecoder();
let parsedUsage: UsageSummary = {
promptTokens: 0,
completionTokens: 0,
@@ -430,102 +348,24 @@ export async function responsesProxyRoute(app: FastifyInstance) {
cacheCreationTokens: 0,
promptTokensIncludeCache: null,
};
let sseBuffer = '';
const passthroughResponsesStream = successfulUpstreamPath === '/v1/responses';
const streamContext = openAiResponsesTransformer.createStreamContext(modelName);
const responsesState = openAiResponsesTransformer.aggregator.createState(modelName);
const writeLines = (lines: string[]) => {
for (const line of lines) reply.raw.write(line);
};
const consumeSseBuffer = (incoming: string): string => {
const pulled = openAiResponsesTransformer.pullSseEvents(incoming);
for (const eventBlock of pulled.events) {
if (eventBlock.data === '[DONE]') {
if (passthroughResponsesStream) {
reply.raw.write('data: [DONE]\n\n');
} else if (!responsesState.completed) {
writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage));
}
continue;
const streamSession = openAiResponsesTransformer.proxyStream.createSession({
modelName,
successfulUpstreamPath,
getUsage: () => parsedUsage,
onParsedPayload: (payload) => {
if (payload && typeof payload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(payload));
}
let parsedPayload: unknown = null;
try {
parsedPayload = JSON.parse(eventBlock.data);
} catch {
parsedPayload = null;
}
if (parsedPayload && typeof parsedPayload === 'object') {
parsedUsage = mergeProxyUsage(parsedUsage, parseProxyUsage(parsedPayload));
}
if (passthroughResponsesStream) {
const eventName = eventBlock.event ? `event: ${eventBlock.event}\n` : '';
reply.raw.write(`${eventName}data: ${eventBlock.data}\n\n`);
continue;
}
const payloadType = (isRecord(parsedPayload) && typeof parsedPayload.type === 'string')
? parsedPayload.type
: '';
const isFailureEvent = (
eventBlock.event === 'error'
|| eventBlock.event === 'response.failed'
|| payloadType === 'error'
|| payloadType === 'response.failed'
);
if (isFailureEvent) {
writeLines(openAiResponsesTransformer.aggregator.fail(responsesState, streamContext, parsedUsage, parsedPayload));
continue;
}
if (parsedPayload && typeof parsedPayload === 'object') {
const normalizedEvent = openAiResponsesTransformer.transformStreamEvent(parsedPayload, streamContext, modelName);
writeLines(openAiResponsesTransformer.aggregator.serialize({
state: responsesState,
streamContext,
event: normalizedEvent,
usage: parsedUsage,
}));
continue;
}
writeLines(openAiResponsesTransformer.aggregator.serialize({
state: responsesState,
streamContext,
event: { contentDelta: eventBlock.data },
usage: parsedUsage,
}));
}
return pulled.rest;
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
sseBuffer += decoder.decode(value, { stream: true });
sseBuffer = consumeSseBuffer(sseBuffer);
}
sseBuffer += decoder.decode();
if (sseBuffer.trim().length > 0) {
sseBuffer = consumeSseBuffer(`${sseBuffer}\n\n`);
}
} finally {
reader.releaseLock();
if (!passthroughResponsesStream && !responsesState.completed) {
writeLines(openAiResponsesTransformer.aggregator.complete(responsesState, streamContext, parsedUsage));
}
reply.raw.end();
}
},
writeLines,
writeRaw: (chunk) => {
reply.raw.write(chunk);
},
});
await streamSession.run(reader, reply.raw);
const latency = Date.now() - startTime;
const resolvedUsage = await resolveProxyUsageWithSelfLogFallback({
@@ -612,7 +452,23 @@ export async function responsesProxyRoute(app: FastifyInstance) {
return reply.send(downstreamData);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamPath);
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamPath,
0,
0,
0,
0,
null,
null,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -649,10 +505,16 @@ async function logProxy(
estimatedCost = 0,
billingDetails: unknown = null,
upstreamPath: string | null = null,
clientContext: DownstreamClientContext | null = null,
) {
try {
const createdAt = formatUtcSqlDateTime(new Date());
const normalizedErrorMessage = composeProxyLogMessage({
clientKind: clientContext?.clientKind && clientContext.clientKind !== 'generic'
? clientContext.clientKind
: null,
sessionId: clientContext?.sessionId || null,
traceHint: clientContext?.traceHint || null,
downstreamPath,
upstreamPath,
errorMessage,
+8 -189
View File
@@ -8,6 +8,14 @@ import {
convertOpenAiBodyToAnthropicMessagesBody,
sanitizeAnthropicMessagesBody,
} from '../../transformers/anthropic/messages/conversion.js';
export {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
} from '../../transformers/shared/endpointCompatibility.js';
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
export type EndpointPreference = DownstreamFormat | 'responses';
@@ -562,193 +570,4 @@ export function buildUpstreamEndpointRequest(input: {
};
}
function normalizeHeaderMap(headers: Record<string, string>): Record<string, string> {
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(headers)) {
const key = rawKey.trim().toLowerCase();
if (!key) continue;
const value = headerValueToString(rawValue);
if (!value) continue;
normalized[key] = value;
}
return normalized;
}
export function buildMinimalJsonHeadersForCompatibility(input: {
headers: Record<string, string>;
endpoint: UpstreamEndpoint;
stream: boolean;
}): Record<string, string> {
const source = normalizeHeaderMap(input.headers);
const minimal: Record<string, string> = {};
if (source.authorization) minimal.authorization = source.authorization;
if (source['x-api-key']) minimal['x-api-key'] = source['x-api-key'];
if (input.endpoint === 'messages') {
for (const [key, value] of Object.entries(source)) {
if (!key.startsWith('anthropic-')) continue;
minimal[key] = value;
}
if (!minimal['anthropic-version']) {
minimal['anthropic-version'] = '2023-06-01';
}
}
minimal['content-type'] = 'application/json';
minimal.accept = input.stream ? 'text/event-stream' : 'application/json';
return minimal;
}
export function isUnsupportedMediaTypeError(status: number, upstreamErrorText?: string | null): boolean {
if (status < 400) return false;
if (status !== 400 && status !== 415) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (!text) return status === 415;
return (
text.includes('unsupported media type')
|| text.includes("only 'application/json' is allowed")
|| text.includes('only "application/json" is allowed')
|| text.includes('application/json')
|| text.includes('content-type')
);
}
export function isEndpointDispatchDeniedError(status: number, upstreamErrorText?: string | null): boolean {
if (status !== 403) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (!text) return false;
return (
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i.test(upstreamErrorText || '')
|| text.includes('dispatch denied')
);
}
export function shouldPreferResponsesAfterLegacyChatError(input: {
status: number;
upstreamErrorText?: string | null;
downstreamFormat: EndpointPreference;
sitePlatform?: string | null;
modelName?: string | null;
requestedModelHint?: string | null;
currentEndpoint?: UpstreamEndpoint | null;
}): boolean {
if (input.status < 400) return false;
if (input.downstreamFormat !== 'openai') return false;
if (input.currentEndpoint !== 'chat') return false;
const sitePlatform = normalizePlatformName(input.sitePlatform);
if (sitePlatform === 'openai' || sitePlatform === 'claude' || sitePlatform === 'gemini' || sitePlatform === 'anyrouter') {
return false;
}
const modelName = asTrimmedString(input.modelName);
const requestedModelHint = asTrimmedString(input.requestedModelHint);
if (isClaudeFamilyModel(modelName) || isClaudeFamilyModel(requestedModelHint)) {
return false;
}
const text = (input.upstreamErrorText || '').toLowerCase();
return (
text.includes('unsupported legacy protocol')
&& text.includes('/v1/chat/completions')
&& text.includes('/v1/responses')
);
}
export function promoteResponsesCandidateAfterLegacyChatError(
endpointCandidates: UpstreamEndpoint[],
input: Parameters<typeof shouldPreferResponsesAfterLegacyChatError>[0],
): void {
if (!shouldPreferResponsesAfterLegacyChatError(input)) return;
const currentIndex = endpointCandidates.findIndex((endpoint) => endpoint === input.currentEndpoint);
const responsesIndex = endpointCandidates.indexOf('responses');
if (currentIndex < 0 || responsesIndex < 0 || responsesIndex <= currentIndex + 1) return;
endpointCandidates.splice(responsesIndex, 1);
endpointCandidates.splice(currentIndex + 1, 0, 'responses');
}
export function isEndpointDowngradeError(status: number, upstreamErrorText?: string | null): boolean {
if (status < 400) return false;
const text = (upstreamErrorText || '').toLowerCase();
if (status === 404 || status === 405 || status === 415 || status === 501) return true;
if (!text) return false;
let parsedCode = '';
let parsedType = '';
let parsedMessage = '';
try {
const parsed = JSON.parse(upstreamErrorText || '{}') as Record<string, unknown>;
const error = (parsed.error && typeof parsed.error === 'object')
? parsed.error as Record<string, unknown>
: parsed;
parsedCode = asTrimmedString(error.code).toLowerCase();
parsedType = asTrimmedString(error.type).toLowerCase();
parsedMessage = asTrimmedString(error.message).toLowerCase();
} catch {
parsedCode = '';
parsedType = '';
parsedMessage = '';
}
return (
isEndpointDispatchDeniedError(status, upstreamErrorText)
||
text.includes('convert_request_failed')
|| text.includes('not found')
|| text.includes('unknown endpoint')
|| text.includes('unsupported endpoint')
|| text.includes('unsupported path')
|| text.includes('unrecognized request url')
|| text.includes('no route matched')
|| text.includes('does not exist')
|| text.includes('openai_error')
|| text.includes('upstream_error')
|| text.includes('bad_response_status_code')
|| text.includes('unsupported media type')
|| text.includes("only 'application/json' is allowed")
|| text.includes('only "application/json" is allowed')
|| (status === 400 && text.includes('unsupported'))
|| text.includes('not implemented')
|| text.includes('api not implemented')
|| text.includes('unsupported legacy protocol')
|| parsedCode === 'convert_request_failed'
|| parsedCode === 'not_found'
|| parsedCode === 'endpoint_not_found'
|| parsedCode === 'unknown_endpoint'
|| parsedCode === 'unsupported_endpoint'
|| parsedCode === 'bad_response_status_code'
|| parsedCode === 'openai_error'
|| parsedCode === 'upstream_error'
|| parsedType === 'not_found_error'
|| parsedType === 'invalid_request_error'
|| parsedType === 'unsupported_endpoint'
|| parsedType === 'unsupported_path'
|| parsedType === 'bad_response_status_code'
|| parsedType === 'openai_error'
|| parsedType === 'upstream_error'
|| parsedMessage.includes('unknown endpoint')
|| parsedMessage.includes('unsupported endpoint')
|| parsedMessage.includes('unsupported path')
|| parsedMessage.includes('unrecognized request url')
|| parsedMessage.includes('no route matched')
|| parsedMessage.includes('does not exist')
|| parsedMessage.includes('bad_response_status_code')
|| parsedMessage === 'openai_error'
|| parsedMessage === 'upstream_error'
|| parsedMessage.includes('unsupported media type')
|| parsedMessage.includes("only 'application/json' is allowed")
|| parsedMessage.includes('only "application/json" is allowed')
|| (
status === 400
&& parsedCode === 'invalid_request'
&& parsedType === 'new_api_error'
&& (parsedMessage.includes('claude code cli') || text.includes('claude code cli'))
)
);
}