feat: add comprehensive tests for token candidates and proxy log path metadata

- Implemented tests for the `/api/models/token-candidates` endpoint to verify model availability and token coverage scenarios.
- Added tests for `composeProxyLogMessage` to ensure proper formatting of log messages with downstream and upstream metadata.
- Created utility functions for parsing proxy log path metadata and handling missing token hints.
- Developed tests for the `Sites` and `TokenRoutes` components to validate UI behavior and interactions, including scrolling and group expansion.
- Introduced a custom hook `useAnimatedVisibility` for managing visibility animations in components.
- Enhanced the documentation link for site resources.
This commit is contained in:
cita
2026-03-04 13:46:31 +08:00
parent d49fcefa05
commit e281e45e84
71 changed files with 5435 additions and 636 deletions
+113
View File
@@ -531,6 +531,61 @@ describe('chat proxy stream behavior', () => {
expect(secondBody.messages[0]?.content).toContain('hello');
});
it('downgrades to next endpoint when normalized Claude fallback still returns messages is required', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
type: '<nil>',
message: 'messages is required',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
type: '<nil>',
message: 'messages is required',
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'chatcmpl_downgraded_ok',
object: 'chat.completion',
model: 'upstream-gpt',
choices: [{
index: 0,
message: { role: 'assistant', content: 'ok after endpoint downgrade' },
finish_reason: 'stop',
}],
}), {
status: 200,
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' }],
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock.mock.calls.length).toBe(3);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
expect(firstUrl).toContain('/v1/messages');
expect(secondUrl).toContain('/v1/messages');
expect(thirdUrl).toContain('/v1/chat/completions');
expect(response.json()?.type).toBe('message');
});
it('passes through Claude tool_use SSE events on /v1/messages for CLI tool execution', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
@@ -603,6 +658,64 @@ describe('chat proxy stream behavior', () => {
expect(forwarded.input).toBe('hello');
});
it('continues downgrade to /v1/messages when /v1/chat/completions returns messages is required for /v1/responses', async () => {
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'openai_error',
type: 'bad_response_status_code',
code: 'bad_response_status_code',
},
}), {
status: 404,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
error: {
message: 'messages is required',
type: 'upstream_error',
code: null,
},
}), {
status: 400,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
id: 'msg_responses_retry_messages',
type: 'message',
model: 'upstream-gpt',
content: [{ type: 'text', text: 'ok from messages fallback' }],
stop_reason: 'end_turn',
usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 },
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1/responses',
payload: {
model: 'gpt-5.2',
input: 'hello',
},
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(3);
const [firstUrl] = fetchMock.mock.calls[0] as [string, any];
const [secondUrl] = fetchMock.mock.calls[1] as [string, any];
const [thirdUrl] = fetchMock.mock.calls[2] as [string, any];
expect(firstUrl).toContain('/v1/responses');
expect(secondUrl).toContain('/v1/chat/completions');
expect(thirdUrl).toContain('/v1/messages');
const body = response.json();
expect(body.object).toBe('response');
expect(body.output_text).toContain('ok from messages fallback');
});
it('passes through /v1/responses SSE payloads', async () => {
const encoder = new TextEncoder();
const upstreamBody = new ReadableStream<Uint8Array>({
+44 -5
View File
@@ -33,6 +33,7 @@ import {
getDownstreamRoutingPolicy,
recordDownstreamCostUsage,
} from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
const CLAUDE_SSE_EVENT_NAMES = new Set([
@@ -62,6 +63,10 @@ function shouldRetryClaudeMessagesWithNormalizedBody(
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function isMessagesRequiredError(upstreamErrorText: string): boolean {
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -134,6 +139,7 @@ async function handleChatProxyRequest(
}
const { requestedModel, isStream, upstreamBody, claudeOriginalBody } = parsedRequest.value!;
const downstreamPath = downstreamFormat === 'claude' ? '/v1/messages' : '/v1/chat/completions';
if (!ensureModelAllowedForDownstreamKey(request, reply, requestedModel)) return;
const downstreamPolicy = getDownstreamRoutingPolicy(request);
@@ -238,6 +244,32 @@ async function handleChatProxyRequest(
}
const normalizedErrText = await normalizedResponse.text().catch(() => 'unknown error');
const shouldDowngradeEndpoint = (
endpointIndex < endpointCandidates.length - 1
&& (
isEndpointDowngradeError(normalizedResponse.status, normalizedErrText)
|| isMessagesRequiredError(normalizedErrText)
)
);
if (shouldDowngradeEndpoint) {
const errText = withUpstreamPath(
normalizedClaudeRequest.path,
`normalized-claude-body fallback failed: ${normalizedErrText}`,
);
logProxy(
selected,
requestedModel,
'failed',
normalizedResponse.status,
Date.now() - startTime,
errText,
retryCount,
downstreamPath,
);
continue;
}
finalStatus = normalizedResponse.status;
finalErrText = withUpstreamPath(
normalizedClaudeRequest.path,
@@ -253,7 +285,7 @@ async function handleChatProxyRequest(
);
if (shouldDowngradeEndpoint) {
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount, downstreamPath);
continue;
}
@@ -266,7 +298,7 @@ async function handleChatProxyRequest(
const status = finalStatus || 502;
const errText = finalErrText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -400,6 +432,7 @@ async function handleChatProxyRequest(
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
@@ -539,6 +572,7 @@ async function handleChatProxyRequest(
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
@@ -600,6 +634,7 @@ async function handleChatProxyRequest(
latency,
null,
retryCount,
downstreamPath,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
@@ -610,7 +645,7 @@ 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);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err?.message || 'network error', retryCount, downstreamPath);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
@@ -640,6 +675,7 @@ function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamPath: string,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -647,8 +683,11 @@ function logProxy(
upstreamPath: string | null = null,
) {
try {
const normalizedErrorMessage = errorMessage
|| (upstreamPath ? `[upstream:${upstreamPath}]` : null);
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath,
upstreamPath,
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
+8 -3
View File
@@ -11,6 +11,7 @@ import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageF
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -239,9 +240,13 @@ function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
) {
) {
try {
db.insert(schema.proxyLogs).values({
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/completions',
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
@@ -254,7 +259,7 @@ function logProxy(
completionTokens,
totalTokens,
estimatedCost,
errorMessage,
errorMessage: normalizedErrorMessage,
retryCount,
}).run();
} catch (error) {
+8 -3
View File
@@ -11,6 +11,7 @@ import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageF
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -154,9 +155,13 @@ function logProxy(
completionTokens = 0,
totalTokens = 0,
estimatedCost = 0,
) {
) {
try {
db.insert(schema.proxyLogs).values({
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/embeddings',
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
@@ -169,7 +174,7 @@ function logProxy(
completionTokens,
totalTokens,
estimatedCost,
errorMessage,
errorMessage: normalizedErrorMessage,
retryCount,
}).run();
} catch (error) {
+8 -3
View File
@@ -9,6 +9,7 @@ import { estimateProxyCost } from '../../services/modelPricingService.js';
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -124,9 +125,13 @@ function logProxy(
errorMessage: string | null,
retryCount: number,
estimatedCost = 0,
) {
) {
try {
db.insert(schema.proxyLogs).values({
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath: '/v1/images/generations',
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,
accountId: selected.account.id,
@@ -139,7 +144,7 @@ function logProxy(
completionTokens: 0,
totalTokens: 0,
estimatedCost,
errorMessage,
errorMessage: normalizedErrorMessage,
retryCount,
}).run();
} catch (error) {
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { composeProxyLogMessage } from './logPathMeta.js';
describe('composeProxyLogMessage', () => {
it('adds downstream path metadata before plain errors', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/responses',
errorMessage: 'upstream failed',
})).toBe('[downstream:/v1/responses] upstream failed');
});
it('keeps existing upstream metadata and avoids duplicate prefixes', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/responses',
errorMessage: '[upstream:/v1/chat/completions] bad request',
})).toBe('[downstream:/v1/responses] [upstream:/v1/chat/completions] bad request');
});
it('returns metadata-only message when no error text exists', () => {
expect(composeProxyLogMessage({
downstreamPath: '/v1/chat/completions',
upstreamPath: '/v1/messages',
errorMessage: null,
})).toBe('[downstream:/v1/chat/completions] [upstream:/v1/messages]');
});
it('returns null when nothing can be recorded', () => {
expect(composeProxyLogMessage({
downstreamPath: null,
upstreamPath: null,
errorMessage: '',
})).toBe(null);
});
});
+42
View File
@@ -0,0 +1,42 @@
type ComposeProxyLogMessageArgs = {
downstreamPath?: string | null;
upstreamPath?: string | null;
errorMessage?: string | null;
};
type ParsedPathMeta = {
downstreamPath: string | null;
upstreamPath: string | null;
messageText: string;
};
function parseExistingPathMeta(rawMessage: string): ParsedPathMeta {
const downstreamMatch = rawMessage.match(/\[downstream:([^\]]+)\]/i);
const upstreamMatch = rawMessage.match(/\[upstream:([^\]]+)\]/i);
const messageText = rawMessage.replace(/^\s*(?:\[(?:downstream|upstream):[^\]]+\]\s*)+/i, '').trim();
return {
downstreamPath: downstreamMatch?.[1]?.trim() || null,
upstreamPath: upstreamMatch?.[1]?.trim() || null,
messageText,
};
}
export function composeProxyLogMessage({
downstreamPath,
upstreamPath,
errorMessage,
}: ComposeProxyLogMessageArgs): string | null {
const rawMessage = typeof errorMessage === 'string' ? errorMessage.trim() : '';
const parsed = parseExistingPathMeta(rawMessage);
const finalDownstreamPath = (downstreamPath || parsed.downstreamPath || '').trim();
const finalUpstreamPath = (upstreamPath || parsed.upstreamPath || '').trim();
const finalMessageText = parsed.messageText.trim();
const prefixParts: string[] = [];
if (finalDownstreamPath) prefixParts.push(`[downstream:${finalDownstreamPath}]`);
if (finalUpstreamPath) prefixParts.push(`[upstream:${finalUpstreamPath}]`);
if (prefixParts.length === 0 && !finalMessageText) return null;
if (finalMessageText) return `${prefixParts.join(' ')} ${finalMessageText}`.trim();
return prefixParts.join(' ');
}
+31 -8
View File
@@ -24,6 +24,7 @@ import {
type UpstreamEndpoint,
} from './upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from './downstreamPolicy.js';
import { composeProxyLogMessage } from './logPathMeta.js';
const MAX_RETRIES = 2;
@@ -31,6 +32,16 @@ function withUpstreamPath(path: string, message: string): string {
return `[upstream:${path}] ${message}`;
}
function shouldDowngradeFromChatToMessagesForResponses(
endpointPath: string,
status: number,
upstreamErrorText: string,
): boolean {
if (!endpointPath.includes('/chat/completions')) return false;
if (status < 400 || status >= 500) return false;
return /messages\s+is\s+required/i.test(upstreamErrorText);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -927,6 +938,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
app.post('/v1/responses', async (request: FastifyRequest, reply: FastifyReply) => {
const body = request.body as any;
const requestedModel = typeof body?.model === 'string' ? body.model.trim() : '';
const downstreamPath = '/v1/responses';
if (!requestedModel) {
return reply.code(400).send({ error: { message: 'model is required', type: 'invalid_request_error' } });
}
@@ -1014,11 +1026,18 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const errText = withUpstreamPath(endpointRequest.path, rawErrText);
const shouldDowngradeEndpoint = (
endpointIndex < endpointCandidates.length - 1
&& isEndpointDowngradeError(response.status, rawErrText)
&& (
isEndpointDowngradeError(response.status, rawErrText)
|| shouldDowngradeFromChatToMessagesForResponses(
endpointRequest.path,
response.status,
rawErrText,
)
)
);
if (shouldDowngradeEndpoint) {
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', response.status, Date.now() - startTime, errText, retryCount, downstreamPath);
continue;
}
@@ -1031,7 +1050,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
const status = finalStatus || 502;
const errText = finalErrText || 'unknown error';
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount);
logProxy(selected, requestedModel, 'failed', status, Date.now() - startTime, errText, retryCount, downstreamPath);
if (isTokenExpiredError({ status, message: errText })) {
await reportTokenExpired({
@@ -1196,7 +1215,7 @@ export async function responsesProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
successfulUpstreamPath,
);
@@ -1248,14 +1267,14 @@ export async function responsesProxyRoute(app: FastifyInstance) {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount,
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost,
successfulUpstreamPath,
);
return reply.send(downstreamData);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamPath);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -1280,6 +1299,7 @@ function logProxy(
latencyMs: number,
errorMessage: string | null,
retryCount: number,
downstreamPath: string,
promptTokens = 0,
completionTokens = 0,
totalTokens = 0,
@@ -1287,8 +1307,11 @@ function logProxy(
upstreamPath: string | null = null,
) {
try {
const normalizedErrorMessage = errorMessage
|| (upstreamPath ? `[upstream:${upstreamPath}]` : null);
const normalizedErrorMessage = composeProxyLogMessage({
downstreamPath,
upstreamPath,
errorMessage,
});
db.insert(schema.proxyLogs).values({
routeId: selected.channel.routeId,
channelId: selected.channel.id,