forked from boos/BoosAPI
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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\'');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}]`);
|
||||
|
||||
|
||||
@@ -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,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'))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user