1
0
forked from boos/BoosAPI

[codex] improve proxy stability, routing, and site subscription visibility (#194)

* wip: preserve local changes before upstream merge

* Improve proxy routing stability and add stable-first strategy
This commit is contained in:
Bainily
2026-03-20 16:18:28 +08:00
committed by GitHub
parent e2595bcab3
commit e9c1bd898e
37 changed files with 3424 additions and 236 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ services:
metapi:
image: 1467078763/metapi:latest
ports:
- "4000:4000"
- "127.0.0.1:4000:4000"
volumes:
- ./data:/app/data
environment:
+67 -12
View File
@@ -12,6 +12,8 @@ import { type DownstreamFormat } from '../../transformers/shared/normalized.js';
import {
buildClaudeCountTokensUpstreamRequest,
buildUpstreamEndpointRequest,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
import {
@@ -155,6 +157,15 @@ export async function handleChatSurfaceRequest(
},
),
];
const endpointRuntimeContext = {
siteId: selected.site.id,
modelName,
downstreamFormat,
requestedModelHint: requestedModel,
requestCapabilities: {
hasNonImageFileInput,
},
};
const buildProviderHeaders = () => (
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
@@ -257,6 +268,20 @@ export async function handleChatSurfaceRequest(
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover,
onAttemptFailure: (ctx) => {
recordUpstreamEndpointFailure({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
status: ctx.response.status,
errorText: ctx.rawErrText,
});
},
onAttemptSuccess: (ctx) => {
recordUpstreamEndpointSuccess({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
});
},
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
@@ -284,7 +309,11 @@ export async function handleChatSurfaceRequest(
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
const rawErrText = endpointResult.rawErrText || errText;
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status,
errorText: rawErrText,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -387,7 +416,7 @@ export async function handleChatSurfaceRequest(
);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, modelName);
logProxy(
selected,
requestedModel,
@@ -423,7 +452,11 @@ export async function handleChatSurfaceRequest(
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -461,7 +494,11 @@ export async function handleChatSurfaceRequest(
startSseResponse();
const streamResult = streamSession.consumeUpstreamFinalPayload(fallbackData, fallbackText, reply.raw);
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -511,7 +548,11 @@ export async function handleChatSurfaceRequest(
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -564,7 +605,7 @@ export async function handleChatSurfaceRequest(
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
@@ -615,7 +656,11 @@ export async function handleChatSurfaceRequest(
const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -676,7 +721,7 @@ export async function handleChatSurfaceRequest(
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected,
@@ -699,7 +744,10 @@ export async function handleChatSurfaceRequest(
return reply.send(downstreamResponse);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
errorText: err?.message,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -901,7 +949,11 @@ export async function handleClaudeCountTokensSurfaceRequest(
}
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: typeof payload === 'string' ? payload : text,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -927,7 +979,7 @@ export async function handleClaudeCountTokensSurfaceRequest(
return reply.code(upstream.status).type(contentType).send(payload);
}
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
tokenRouter.recordSuccess(selected.channel.id, latency, 0, modelName);
recordDownstreamCostUsage(request, 0);
logProxy(
selected,
@@ -949,7 +1001,10 @@ export async function handleClaudeCountTokensSurfaceRequest(
);
return reply.code(upstream.status).type(contentType).send(payload);
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
errorText: error?.message,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -16,6 +16,8 @@ import { executeEndpointFlow, type BuiltEndpointRequest } from '../../routes/pro
import { composeProxyLogMessage } from '../../routes/proxy/logPathMeta.js';
import {
buildUpstreamEndpointRequest,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
import {
@@ -296,7 +298,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
lastStatus = upstream.status;
lastText = text;
lastContentType = upstream.headers.get('content-type') || 'application/json';
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: upstream.status,
errorText: text,
});
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -311,7 +316,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
return reply.code(upstream.status).type(upstream.headers.get('content-type') || 'application/json').send(text);
}
} catch (error) {
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
errorText: error instanceof Error ? error.message : 'Gemini upstream request failed',
});
lastStatus = 502;
lastContentType = 'application/json';
lastText = JSON.stringify({
@@ -410,7 +417,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
type: 'server_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: 500,
errorText: 'Gemini CLI OAuth project is missing',
});
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -494,7 +504,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
lastStatus = upstream.status;
lastContentType = contentType;
lastText = await upstream.text();
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: upstream.status,
errorText: lastText,
});
await logProxy(
selected,
requestedModel,
@@ -681,6 +694,12 @@ export async function geminiProxyRoute(app: FastifyInstance) {
'openai',
requestedModel,
);
const endpointRuntimeContext = {
siteId: selected.site.id,
modelName: actualModel,
downstreamFormat: 'openai' as const,
requestedModelHint: requestedModel,
};
const buildEndpointRequest = (
endpoint: 'chat' | 'messages' | 'responses',
requestOptions: { forceNormalizeClaudeBody?: boolean } = {},
@@ -742,6 +761,20 @@ export async function geminiProxyRoute(app: FastifyInstance) {
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover: endpointStrategy.tryRecover,
onAttemptFailure: (ctx) => {
recordUpstreamEndpointFailure({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
status: ctx.response.status,
errorText: ctx.rawErrText,
});
},
onAttemptSuccess: (ctx) => {
recordUpstreamEndpointSuccess({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
});
},
shouldDowngrade: endpointStrategy.shouldDowngrade,
});
if (!endpointResult.ok) {
@@ -753,7 +786,10 @@ export async function geminiProxyRoute(app: FastifyInstance) {
type: 'upstream_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
status: endpointResult.status,
errorText: endpointResult.rawErrText || endpointResult.errText,
});
await logProxy(
selected,
requestedModel,
@@ -821,7 +857,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
type: 'upstream_error',
},
});
await tokenRouter.recordFailure?.(selected.channel.id);
await tokenRouter.recordFailure?.(selected.channel.id, {
errorText: error instanceof Error ? error.message : 'Gemini upstream request failed',
});
await logProxy(
selected,
requestedModel,
@@ -11,6 +11,8 @@ import { resolveProxyUrlForSite, withSiteRecordProxyRequestInit } from '../../se
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
import {
buildUpstreamEndpointRequest,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resolveUpstreamEndpointCandidates,
} from '../../routes/proxy/upstreamEndpoint.js';
import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordDownstreamCostUsage } from '../../routes/proxy/downstreamPolicy.js';
@@ -263,6 +265,16 @@ export async function handleOpenAiResponsesSurfaceRequest(
if (endpointCandidates.length === 0) {
endpointCandidates.push('responses', 'chat', 'messages');
}
const endpointRuntimeContext = {
siteId: selected.site.id,
modelName,
downstreamFormat: 'responses' as const,
requestedModelHint: requestedModel,
requestCapabilities: {
hasNonImageFileInput,
wantsNativeResponsesReasoning: prefersNativeResponsesReasoning,
},
};
const buildProviderHeaders = () => (
buildOauthProviderHeaders({
extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null,
@@ -357,6 +369,20 @@ export async function handleOpenAiResponsesSurfaceRequest(
buildRequest: (endpoint) => buildEndpointRequest(endpoint),
dispatchRequest,
tryRecover,
onAttemptFailure: (ctx) => {
recordUpstreamEndpointFailure({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
status: ctx.response.status,
errorText: ctx.rawErrText,
});
},
onAttemptSuccess: (ctx) => {
recordUpstreamEndpointSuccess({
...endpointRuntimeContext,
endpoint: ctx.request.endpoint,
});
},
shouldDowngrade: endpointStrategy.shouldDowngrade,
onDowngrade: (ctx) => {
logProxy(
@@ -384,7 +410,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const status = endpointResult.status || 502;
const errText = endpointResult.errText || 'unknown error';
const rawErrText = endpointResult.rawErrText || errText;
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status,
errorText: rawErrText,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -476,7 +506,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
}
try {
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
@@ -539,7 +569,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
);
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, modelName);
logProxy(
selected,
requestedModel,
@@ -578,7 +608,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const latency = Date.now() - startTime;
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -613,7 +647,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
startSseResponse();
const streamResult = streamSession.consumeUpstreamFinalPayload(upstreamData, rawText, reply.raw);
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -669,7 +707,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const latency = Date.now() - startTime;
if (streamResult.status === 'failed') {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: 502,
errorText: streamResult.errorMessage,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -733,7 +775,11 @@ export async function handleOpenAiResponsesSurfaceRequest(
const parsedUsage = parseProxyUsage(upstreamData);
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: failure.reason,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -797,7 +843,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, modelName);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamPath,
@@ -808,7 +854,10 @@ export async function handleOpenAiResponsesSurfaceRequest(
);
return reply.send(downstreamData);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
errorText: err?.message,
modelName,
});
logProxy(
selected,
requestedModel,
@@ -0,0 +1,128 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { mkdtempSync } from 'node:fs';
import {
buildStoredSub2ApiSubscriptionSummary,
mergeAccountExtraConfig,
} from '../../services/accountExtraConfig.js';
type DbModule = typeof import('../../db/index.js');
describe('sites route subscription summary aggregation', () => {
let app: FastifyInstance;
let db: DbModule['db'];
let schema: DbModule['schema'];
let dataDir = '';
beforeAll(async () => {
dataDir = mkdtempSync(join(tmpdir(), 'metapi-sites-subscription-'));
process.env.DATA_DIR = dataDir;
await import('../../db/migrate.js');
const dbModule = await import('../../db/index.js');
const routesModule = await import('./sites.js');
db = dbModule.db;
schema = dbModule.schema;
app = Fastify();
await app.register(routesModule.sitesRoutes);
});
beforeEach(async () => {
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
});
afterAll(async () => {
await app.close();
delete process.env.DATA_DIR;
});
it('aggregates stored sub2api subscription summaries into /api/sites', async () => {
const site = await db.insert(schema.sites).values({
name: 'sub2api-site',
url: 'https://sub2api.example.com',
platform: 'sub2api',
}).returning().get();
await db.insert(schema.accounts).values([
{
siteId: site.id,
username: 'user-a',
accessToken: 'token-a',
balance: 10,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 3,
subscriptions: [
{
id: 11,
groupName: 'Pro',
expiresAt: '2026-04-10T00:00:00.000Z',
monthlyUsedUsd: 3,
monthlyLimitUsd: 20,
},
],
}, 1760000000000),
}),
},
{
siteId: site.id,
username: 'user-b',
accessToken: 'token-b',
balance: 5,
status: 'active',
extraConfig: mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 2,
subscriptions: [
{
id: 12,
groupName: 'Lite',
expiresAt: '2026-03-25T00:00:00.000Z',
monthlyUsedUsd: 2,
monthlyLimitUsd: 10,
},
],
}, 1760000002000),
}),
},
]).run();
const response = await app.inject({
method: 'GET',
url: '/api/sites',
});
expect(response.statusCode).toBe(200);
const body = response.json() as Array<{
totalBalance: number;
subscriptionSummary: {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd: number | null;
totalRemainingUsd: number | null;
nextExpiresAt: string | null;
planNames: string[];
updatedAt: number | null;
} | null;
}>;
expect(body).toHaveLength(1);
expect(body[0]?.totalBalance).toBe(15);
expect(body[0]?.subscriptionSummary).toEqual({
activeCount: 2,
totalUsedUsd: 5,
totalMonthlyLimitUsd: 30,
totalRemainingUsd: 25,
nextExpiresAt: '2026-03-25T00:00:00.000Z',
planNames: ['Pro', 'Lite'],
updatedAt: 1760000002000,
});
});
});
+70 -8
View File
@@ -1,11 +1,12 @@
import { FastifyInstance } from 'fastify';
import { db, schema } from '../../db/index.js';
import { and, eq, sql } from 'drizzle-orm';
import { and, eq } from 'drizzle-orm';
import { detectSite } from '../../services/siteDetector.js';
import { invalidateSiteProxyCache } from '../../services/siteProxy.js';
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
import { invalidateTokenRouterCache } from '../../services/tokenRouter.js';
import { parseSiteCustomHeadersInput } from '../../services/siteCustomHeaders.js';
import { getSub2ApiSubscriptionFromExtraConfig } from '../../services/accountExtraConfig.js';
function normalizeSiteStatus(input: unknown): 'active' | 'disabled' | null {
if (input === undefined || input === null) return null;
@@ -75,6 +76,65 @@ function normalizeOptionalExternalCheckinUrl(input: unknown): {
return { valid: true, present: true, url: parsed.toString().replace(/\/+$/, '') };
}
type SiteSubscriptionAggregate = {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd: number | null;
totalRemainingUsd: number | null;
nextExpiresAt: string | null;
planNames: string[];
updatedAt: number | null;
};
function roundMetric(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
function pickEarlierIsoDate(current?: string | null, next?: string | null): string | null {
if (!current) return next || null;
if (!next) return current;
const currentMs = Date.parse(current);
const nextMs = Date.parse(next);
if (!Number.isFinite(currentMs)) return next;
if (!Number.isFinite(nextMs)) return current;
return nextMs < currentMs ? next : current;
}
function aggregateSiteSubscription(
current: SiteSubscriptionAggregate | undefined,
extraConfig?: string | null,
): SiteSubscriptionAggregate | undefined {
const stored = getSub2ApiSubscriptionFromExtraConfig(extraConfig);
if (!stored) return current;
const planNames = new Set(current?.planNames || []);
let totalMonthlyLimitUsd = current?.totalMonthlyLimitUsd ?? null;
let nextExpiresAt = current?.nextExpiresAt ?? null;
for (const item of stored.subscriptions) {
if (item.groupName) planNames.add(item.groupName);
if (typeof item.monthlyLimitUsd === 'number' && Number.isFinite(item.monthlyLimitUsd)) {
totalMonthlyLimitUsd = roundMetric((totalMonthlyLimitUsd ?? 0) + item.monthlyLimitUsd);
}
nextExpiresAt = pickEarlierIsoDate(nextExpiresAt, item.expiresAt);
}
const totalUsedUsd = roundMetric((current?.totalUsedUsd || 0) + stored.totalUsedUsd);
const totalRemainingUsd = totalMonthlyLimitUsd == null
? null
: roundMetric(Math.max(0, totalMonthlyLimitUsd - totalUsedUsd));
return {
activeCount: (current?.activeCount || 0) + stored.activeCount,
totalUsedUsd,
totalMonthlyLimitUsd,
totalRemainingUsd,
nextExpiresAt,
planNames: Array.from(planNames),
updatedAt: Math.max(current?.updatedAt || 0, stored.updatedAt || 0) || null,
};
}
export async function sitesRoutes(app: FastifyInstance) {
function invalidateSiteCaches() {
invalidateSiteProxyCache();
@@ -137,21 +197,23 @@ export async function sitesRoutes(app: FastifyInstance) {
// List all sites
app.get('/api/sites', async () => {
const siteRows = await db.select().from(schema.sites).all();
const accountBalanceRows = await db.select({
const accountRows = await db.select({
siteId: schema.accounts.siteId,
totalBalance: sql<number>`coalesce(sum(${schema.accounts.balance}), 0)`,
}).from(schema.accounts)
.groupBy(schema.accounts.siteId)
.all();
balance: schema.accounts.balance,
extraConfig: schema.accounts.extraConfig,
}).from(schema.accounts).all();
const totalBalanceBySiteId: Record<number, number> = {};
for (const row of accountBalanceRows) {
totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0);
const subscriptionBySiteId: Record<number, SiteSubscriptionAggregate | undefined> = {};
for (const row of accountRows) {
totalBalanceBySiteId[row.siteId] = roundMetric((totalBalanceBySiteId[row.siteId] || 0) + Number(row.balance || 0));
subscriptionBySiteId[row.siteId] = aggregateSiteSubscription(subscriptionBySiteId[row.siteId], row.extraConfig);
}
return siteRows.map((site) => ({
...site,
totalBalance: Math.round((totalBalanceBySiteId[site.id] || 0) * 1_000_000) / 1_000_000,
subscriptionSummary: subscriptionBySiteId[site.id] || null,
}));
});
@@ -1,6 +1,7 @@
import Fastify, { type FastifyInstance } from 'fastify';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { config } from '../../config.js';
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
const fetchMock = vi.fn();
const selectChannelMock = vi.fn();
@@ -102,6 +103,7 @@ describe('chat proxy stream behavior', () => {
fetchModelPricingCatalogMock.mockReset();
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
dbInsertMock.mockClear();
resetUpstreamEndpointRuntimeState();
selectChannelMock.mockReturnValue({
channel: { id: 11, routeId: 22 },
@@ -113,6 +115,17 @@ describe('chat proxy stream behavior', () => {
});
selectNextChannelMock.mockReturnValue(null);
fetchModelPricingCatalogMock.mockResolvedValue(null);
(config as any).codexHeaderDefaults = {
userAgent: '',
betaFeatures: '',
};
(config as any).payloadRules = {
default: [],
defaultRaw: [],
override: [],
overrideRaw: [],
filter: [],
};
config.proxyEmptyContentFailEnabled = false;
config.proxyErrorKeywords = [];
});
+81 -11
View File
@@ -79,8 +79,28 @@ export async function completionsProxyRoute(app: FastifyInstance) {
if (!upstream.ok) {
const errText = await upstream.text().catch(() => 'unknown error');
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, errText, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: errText,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
errText,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: errText })) {
await reportTokenExpired({
@@ -178,11 +198,24 @@ export async function completionsProxyRoute(app: FastifyInstance) {
parsedUsage,
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamApiKeyId,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
clientContext,
downstreamPath,
);
return;
}
@@ -199,7 +232,11 @@ export async function completionsProxyRoute(app: FastifyInstance) {
const failure = detectProxyFailure({ rawText, usage: parsedUsage });
if (failure) {
const errText = failure.reason;
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, {
status: failure.status,
errorText: errText,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
@@ -256,16 +293,49 @@ export async function completionsProxyRoute(app: FastifyInstance) {
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', 200, latency, null, retryCount, downstreamApiKeyId,
resolvedUsage.promptTokens, resolvedUsage.completionTokens, resolvedUsage.totalTokens, estimatedCost, billingDetails, clientContext, downstreamPath,
selected,
requestedModel,
'success',
200,
latency,
null,
retryCount,
downstreamApiKeyId,
resolvedUsage.promptTokens,
resolvedUsage.completionTokens,
resolvedUsage.totalTokens,
estimatedCost,
billingDetails,
clientContext,
downstreamPath,
);
return reply.send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
+45 -5
View File
@@ -75,8 +75,28 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
@@ -126,7 +146,7 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
resolvedUsage,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(
selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId,
@@ -134,8 +154,28 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, 0, 0, 0, null, clientContext, downstreamPath);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
0,
0,
0,
null,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -155,6 +155,40 @@ describe('executeEndpointFlow', () => {
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('emits attempt callbacks for failed and successful endpoint probes', async () => {
fetchMock
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'unsupported endpoint', type: 'invalid_request_error' },
}), {
status: 404,
headers: { 'content-type': 'application/json' },
})))
.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})));
const onAttemptFailure = vi.fn();
const onAttemptSuccess = vi.fn();
const result = await executeEndpointFlow({
siteUrl: 'https://example.com',
endpointCandidates: ['responses', 'chat'],
buildRequest: (endpoint) => endpoint === 'responses'
? requestFor('/v1/responses')
: { ...requestFor('/v1/chat/completions'), endpoint },
shouldDowngrade: () => true,
onAttemptFailure,
onAttemptSuccess,
});
expect(result.ok).toBe(true);
expect(onAttemptFailure).toHaveBeenCalledTimes(1);
expect(onAttemptFailure.mock.calls[0]?.[0]?.request?.path).toBe('/v1/responses');
expect(onAttemptSuccess).toHaveBeenCalledTimes(1);
expect(onAttemptSuccess.mock.calls[0]?.[0]?.request?.path).toBe('/v1/chat/completions');
});
it('accepts recovered response from tryRecover hook', async () => {
fetchMock.mockResolvedValueOnce(toUndiciResponse(new Response(JSON.stringify({
error: { message: 'upstream_error', type: 'upstream_error' },
+28
View File
@@ -27,6 +27,14 @@ export type EndpointAttemptContext = {
rawErrText: string;
};
export type EndpointAttemptSuccessContext = {
endpointIndex: number;
endpointCount: number;
request: BuiltEndpointRequest;
targetUrl: string;
response: Awaited<ReturnType<typeof fetch>>;
};
export type EndpointRecoverResult = {
upstream: Awaited<ReturnType<typeof fetch>>;
upstreamPath: string;
@@ -61,6 +69,8 @@ type ExecuteEndpointFlowInput = {
tryRecover?: (ctx: EndpointAttemptContext) => Promise<EndpointRecoverResult>;
shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean;
onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise<void>;
onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise<void>;
};
function buildAbsoluteUrl(base: string, path: string): string {
@@ -100,6 +110,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
}));
if (response.ok) {
await input.onAttemptSuccess?.({
endpointIndex,
endpointCount,
request,
targetUrl,
response,
});
return {
ok: true,
upstream: response,
@@ -120,6 +137,13 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
if (input.tryRecover) {
const recovered = await input.tryRecover(baseContext);
if (recovered?.upstream?.ok) {
await input.onAttemptSuccess?.({
endpointIndex,
endpointCount,
request: baseContext.request,
targetUrl: baseContext.targetUrl,
response: recovered.upstream,
});
return {
ok: true,
upstream: recovered.upstream,
@@ -135,6 +159,10 @@ export async function executeEndpointFlow(input: ExecuteEndpointFlowInput): Prom
baseContext.request.path,
summarizeUpstreamError(response.status, rawErrText),
);
await input.onAttemptFailure?.({
...baseContext,
errText,
});
const isLastEndpoint = endpointIndex >= endpointCount - 1;
const shouldDowngrade = !isLastEndpoint && !!input.shouldDowngrade?.(baseContext);
+25 -6
View File
@@ -2,6 +2,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
const fetchMock = vi.fn();
const fetchModelPricingCatalogMock = vi.fn();
@@ -188,6 +189,7 @@ describe('gemini native proxy routes', () => {
selectNextChannelMock.mockReturnValue(null);
recordSuccessMock.mockResolvedValue(undefined);
recordFailureMock.mockResolvedValue(undefined);
resetUpstreamEndpointRuntimeState();
explainSelectionMock.mockResolvedValue({ selectedChannelId: 11 });
isModelAllowedByPolicyOrAllowedRoutesMock.mockResolvedValue(true);
});
@@ -261,7 +263,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: JSON.stringify({ error: { message: 'first channel failed' } }),
}));
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(firstUrl).toContain('key=gemini-key');
@@ -1417,7 +1422,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 400,
errorText: JSON.stringify({ error: { message: 'bad request on first channel' } }),
}));
const [firstUrl] = fetchMock.mock.calls[0] as [string, RequestInit];
const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(firstUrl).toContain('key=gemini-key');
@@ -1467,7 +1475,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 403,
errorText: JSON.stringify({ error: { message: 'forbidden on first channel' } }),
}));
});
it('falls back to the next channel when first Gemini channel returns 500 before any bytes are written', async () => {
@@ -1510,7 +1521,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: 'upstream crash',
}));
});
it('falls back to the next channel when first Gemini channel throws before any bytes are written', async () => {
@@ -1550,7 +1564,9 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
errorText: 'socket hang up',
}));
});
it('falls back to the next channel for SSE requests before any bytes are written', async () => {
@@ -1596,7 +1612,10 @@ describe('gemini native proxy routes', () => {
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(recordFailureMock).toHaveBeenCalledWith(11);
expect(recordFailureMock).toHaveBeenCalledWith(11, expect.objectContaining({
status: 500,
errorText: JSON.stringify({ error: { message: 'upstream unavailable' } }),
}));
expect(response.body).toContain('hello from fallback sse');
});
+74 -10
View File
@@ -74,8 +74,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -107,13 +123,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
@@ -200,8 +232,24 @@ export async function imagesProxyRoute(app: FastifyInstance) {
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -233,13 +281,29 @@ export async function imagesProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, estimatedCost, downstreamPath, clientContext);
return reply.code(upstream.status).send(data);
} catch (err: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, err.message, retryCount, downstreamApiKeyId, 0, downstreamPath, clientContext);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: err.message,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
err.message,
retryCount,
downstreamApiKeyId,
0,
downstreamPath,
clientContext,
);
if (retryCount < MAX_RETRIES) {
retryCount++;
continue;
+35 -5
View File
@@ -105,8 +105,23 @@ export async function searchProxyRoute(app: FastifyInstance) {
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', upstream.status, Date.now() - startTime, text, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
tokenRouter.recordFailure(selected.channel.id, {
status: upstream.status,
errorText: text,
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
upstream.status,
Date.now() - startTime,
text,
retryCount,
downstreamApiKeyId,
clientContext,
downstreamPath,
);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -130,13 +145,28 @@ export async function searchProxyRoute(app: FastifyInstance) {
try { data = JSON.parse(text); } catch { data = { data: [] }; }
const latency = Date.now() - startTime;
tokenRouter.recordSuccess(selected.channel.id, latency, 0);
tokenRouter.recordSuccess(selected.channel.id, latency, 0, selected.actualModel);
recordDownstreamCostUsage(request, 0);
logProxy(selected, requestedModel, 'success', upstream.status, latency, null, retryCount, downstreamApiKeyId, clientContext, downstreamPath);
return reply.code(upstream.status).send(data);
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
logProxy(selected, requestedModel, 'failed', 0, Date.now() - startTime, error?.message || 'network error', retryCount, downstreamApiKeyId, clientContext, downstreamPath);
tokenRouter.recordFailure(selected.channel.id, {
status: 0,
errorText: error?.message || 'network error',
modelName: selected.actualModel,
});
logProxy(
selected,
requestedModel,
'failed',
0,
Date.now() - startTime,
error?.message || 'network error',
retryCount,
downstreamApiKeyId,
clientContext,
downstreamPath,
);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -12,6 +12,9 @@ import {
buildUpstreamEndpointRequest,
isUnsupportedMediaTypeError,
isEndpointDowngradeError,
recordUpstreamEndpointFailure,
recordUpstreamEndpointSuccess,
resetUpstreamEndpointRuntimeState,
resolveUpstreamEndpointCandidates,
} from './upstreamEndpoint.js';
@@ -33,6 +36,7 @@ describe('resolveUpstreamEndpointCandidates', () => {
beforeEach(() => {
fetchModelPricingCatalogMock.mockReset();
fetchModelPricingCatalogMock.mockResolvedValue(null);
resetUpstreamEndpointRuntimeState();
(config as any).codexHeaderDefaults = {
userAgent: '',
betaFeatures: '',
@@ -192,6 +196,48 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(order).toEqual(['responses', 'messages', 'chat']);
});
it('remembers the last successful endpoint per site capability profile', async () => {
recordUpstreamEndpointSuccess({
siteId: baseContext.site.id,
endpoint: 'responses',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['responses', 'chat', 'messages']);
});
it('learns a better endpoint from explicit upstream protocol errors', async () => {
recordUpstreamEndpointFailure({
siteId: baseContext.site.id,
endpoint: 'chat',
downstreamFormat: 'openai',
modelName: 'gpt-5.3',
status: 400,
errorText: 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.',
});
const order = await resolveUpstreamEndpointCandidates(
{
...baseContext,
site: { ...baseContext.site, platform: 'new-api' },
},
'gpt-5.3',
'openai',
);
expect(order).toEqual(['responses', 'messages']);
});
it('keeps claude models messages-first even when openai platform catalog prefers chat', async () => {
fetchModelPricingCatalogMock.mockResolvedValue({
models: [
+245 -22
View File
@@ -19,7 +19,7 @@ import {
import {
buildGeminiGenerateContentRequestFromOpenAi,
} from './geminiCliCompat.js';
export {
import {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
@@ -27,10 +27,30 @@ export {
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
} from '../../transformers/shared/endpointCompatibility.js';
export {
buildMinimalJsonHeadersForCompatibility,
isEndpointDispatchDeniedError,
isEndpointDowngradeError,
isUnsupportedMediaTypeError,
promoteResponsesCandidateAfterLegacyChatError,
shouldPreferResponsesAfterLegacyChatError,
};
export type UpstreamEndpoint = 'chat' | 'messages' | 'responses';
export type EndpointPreference = DownstreamFormat | 'responses';
type EndpointCapabilityProfile = {
preferMessagesForClaudeModel: boolean;
hasNonImageFileInput: boolean;
wantsNativeResponsesReasoning: boolean;
};
type EndpointRuntimeState = {
preferredEndpoint: UpstreamEndpoint | null;
preferredUpdatedAtMs: number;
blockedUntilMsByEndpoint: Partial<Record<UpstreamEndpoint, number>>;
};
type ChannelContext = {
site: {
id: number;
@@ -45,6 +65,10 @@ type ChannelContext = {
};
};
const ENDPOINT_RUNTIME_PREFERRED_TTL_MS = 24 * 60 * 60 * 1000;
const ENDPOINT_RUNTIME_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
const endpointRuntimeStates = new Map<string, EndpointRuntimeState>();
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object';
}
@@ -577,6 +601,197 @@ function normalizeEndpointTypes(value: unknown): UpstreamEndpoint[] {
return Array.from(normalized);
}
function buildEndpointCapabilityProfile(input?: {
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): EndpointCapabilityProfile {
return {
preferMessagesForClaudeModel: (
isClaudeFamilyModel(asTrimmedString(input?.modelName))
|| isClaudeFamilyModel(asTrimmedString(input?.requestedModelHint))
),
hasNonImageFileInput: (
input?.requestCapabilities?.conversationFileSummary?.hasDocument === true
|| input?.requestCapabilities?.hasNonImageFileInput === true
),
wantsNativeResponsesReasoning: input?.requestCapabilities?.wantsNativeResponsesReasoning === true,
};
}
function buildEndpointRuntimeStateKey(input: {
siteId: number;
downstreamFormat: EndpointPreference;
capabilityProfile: EndpointCapabilityProfile;
}): string {
const capabilityProfile = input.capabilityProfile;
return [
String(input.siteId),
input.downstreamFormat,
capabilityProfile.preferMessagesForClaudeModel ? 'claude' : 'generic',
capabilityProfile.hasNonImageFileInput ? 'files' : 'nofiles',
capabilityProfile.wantsNativeResponsesReasoning ? 'reasoning' : 'noreasoning',
].join(':');
}
function getOrCreateEndpointRuntimeState(key: string, nowMs = Date.now()): EndpointRuntimeState {
const existing = endpointRuntimeStates.get(key);
if (existing) return existing;
const initial: EndpointRuntimeState = {
preferredEndpoint: null,
preferredUpdatedAtMs: nowMs,
blockedUntilMsByEndpoint: {},
};
endpointRuntimeStates.set(key, initial);
return initial;
}
function maybeDeleteEndpointRuntimeState(key: string, nowMs = Date.now()): void {
const state = endpointRuntimeStates.get(key);
if (!state) return;
const hasActiveBlock = Object.values(state.blockedUntilMsByEndpoint).some((untilMs) => (
typeof untilMs === 'number' && untilMs > nowMs
));
const preferredFresh = (
!!state.preferredEndpoint
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
);
if (!hasActiveBlock && !preferredFresh) {
endpointRuntimeStates.delete(key);
}
}
function applyEndpointRuntimePreference(
candidates: UpstreamEndpoint[],
key: string,
nowMs = Date.now(),
): UpstreamEndpoint[] {
const state = endpointRuntimeStates.get(key);
if (!state || candidates.length <= 1) return candidates;
const blocked = new Set<UpstreamEndpoint>();
for (const endpoint of candidates) {
const untilMs = state.blockedUntilMsByEndpoint[endpoint];
if (typeof untilMs === 'number' && untilMs > nowMs) {
blocked.add(endpoint);
}
}
let next = candidates.filter((endpoint) => !blocked.has(endpoint));
if (next.length === 0) {
next = [...candidates];
}
const preferredFresh = (
!!state.preferredEndpoint
&& (state.preferredUpdatedAtMs + ENDPOINT_RUNTIME_PREFERRED_TTL_MS) > nowMs
);
if (preferredFresh && state.preferredEndpoint && next.includes(state.preferredEndpoint)) {
next = [
state.preferredEndpoint,
...next.filter((endpoint) => endpoint !== state.preferredEndpoint),
];
}
maybeDeleteEndpointRuntimeState(key, nowMs);
return next;
}
function inferSuggestedEndpointFromError(errorText?: string | null): UpstreamEndpoint | null {
const text = (errorText || '').toLowerCase();
if (!text) return null;
if (text.includes('/v1/responses')) return 'responses';
if (text.includes('/v1/messages')) return 'messages';
if (text.includes('/v1/chat/completions')) return 'chat';
return null;
}
function shouldBlockEndpointByError(status: number, errorText?: string | null): boolean {
if (isEndpointDispatchDeniedError(status, errorText)) return true;
if (isEndpointDowngradeError(status, errorText)) return true;
const text = (errorText || '').toLowerCase();
return (
text.includes('unsupported legacy protocol')
|| text.includes('please use /v1/')
|| text.includes('does not allow /v1/')
);
}
export function resetUpstreamEndpointRuntimeState(): void {
endpointRuntimeStates.clear();
}
export function recordUpstreamEndpointSuccess(input: {
siteId: number;
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): void {
const nowMs = Date.now();
const key = buildEndpointRuntimeStateKey({
siteId: input.siteId,
downstreamFormat: input.downstreamFormat,
capabilityProfile: buildEndpointCapabilityProfile({
modelName: input.modelName,
requestedModelHint: input.requestedModelHint,
requestCapabilities: input.requestCapabilities,
}),
});
const state = getOrCreateEndpointRuntimeState(key, nowMs);
state.preferredEndpoint = input.endpoint;
state.preferredUpdatedAtMs = nowMs;
delete state.blockedUntilMsByEndpoint[input.endpoint];
}
export function recordUpstreamEndpointFailure(input: {
siteId: number;
endpoint: UpstreamEndpoint;
downstreamFormat: EndpointPreference;
status: number;
errorText?: string | null;
modelName?: string;
requestedModelHint?: string;
requestCapabilities?: {
hasNonImageFileInput?: boolean;
conversationFileSummary?: ConversationFileInputSummary;
wantsNativeResponsesReasoning?: boolean;
};
}): void {
if (!shouldBlockEndpointByError(input.status, input.errorText)) return;
const nowMs = Date.now();
const key = buildEndpointRuntimeStateKey({
siteId: input.siteId,
downstreamFormat: input.downstreamFormat,
capabilityProfile: buildEndpointCapabilityProfile({
modelName: input.modelName,
requestedModelHint: input.requestedModelHint,
requestCapabilities: input.requestCapabilities,
}),
});
const state = getOrCreateEndpointRuntimeState(key, nowMs);
state.blockedUntilMsByEndpoint[input.endpoint] = nowMs + ENDPOINT_RUNTIME_BLOCK_TTL_MS;
const suggestedEndpoint = inferSuggestedEndpointFromError(input.errorText);
if (suggestedEndpoint && suggestedEndpoint !== input.endpoint) {
state.preferredEndpoint = suggestedEndpoint;
state.preferredUpdatedAtMs = nowMs;
delete state.blockedUntilMsByEndpoint[suggestedEndpoint];
}
}
function preferredEndpointOrder(
downstreamFormat: EndpointPreference,
sitePlatform?: string,
@@ -651,29 +866,39 @@ export async function resolveUpstreamEndpointCandidates(
},
): Promise<UpstreamEndpoint[]> {
const sitePlatform = normalizePlatformName(context.site.platform);
const preferMessagesForClaudeModel = (
isClaudeFamilyModel(modelName)
|| isClaudeFamilyModel(asTrimmedString(requestedModelHint))
const capabilityProfile = buildEndpointCapabilityProfile({
modelName,
requestedModelHint,
requestCapabilities,
});
const preferMessagesForClaudeModel = capabilityProfile.preferMessagesForClaudeModel;
const hasNonImageFileInput = capabilityProfile.hasNonImageFileInput;
const wantsNativeResponsesReasoning = capabilityProfile.wantsNativeResponsesReasoning;
const runtimeStateKey = buildEndpointRuntimeStateKey({
siteId: context.site.id,
downstreamFormat,
capabilityProfile,
});
const applyRuntimePreference = (candidates: UpstreamEndpoint[]) => (
applyEndpointRuntimePreference(candidates, runtimeStateKey)
);
const conversationFileSummary = requestCapabilities?.conversationFileSummary ?? {
hasImage: false,
hasAudio: false,
hasDocument: requestCapabilities?.hasNonImageFileInput === true,
hasDocument: hasNonImageFileInput,
hasRemoteDocumentUrl: false,
};
const hasNonImageFileInput = conversationFileSummary.hasDocument;
const wantsNativeResponsesReasoning = requestCapabilities?.wantsNativeResponsesReasoning === true;
if (sitePlatform === 'anyrouter') {
// anyrouter deployments are effectively anthropic-protocol first.
if (hasNonImageFileInput) {
return downstreamFormat === 'responses'
return applyRuntimePreference(downstreamFormat === 'responses'
? ['responses', 'messages', 'chat']
: ['messages', 'responses', 'chat'];
: ['messages', 'responses', 'chat']);
}
if (downstreamFormat === 'responses') {
return ['responses', 'messages', 'chat'];
return applyRuntimePreference(['responses', 'messages', 'chat']);
}
return ['messages', 'chat', 'responses'];
return applyRuntimePreference(['messages', 'chat', 'responses']);
}
const preferred = preferredEndpointOrder(
@@ -732,20 +957,20 @@ export async function resolveUpstreamEndpointCandidates(
});
if (!catalog || !Array.isArray(catalog.models) || catalog.models.length === 0) {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const matched = catalog.models.find((item) =>
asTrimmedString(item?.modelName).toLowerCase() === modelName.toLowerCase(),
);
if (!matched) return prioritizedPreferredEndpoints;
if (!matched) return applyRuntimePreference(prioritizedPreferredEndpoints);
const shouldIgnoreCatalogOrderingForClaudeMessages = (
preferMessagesForClaudeModel
&& (downstreamFormat !== 'responses' || sitePlatform !== 'openai')
);
if (shouldIgnoreCatalogOrderingForClaudeMessages) {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const supportedRaw = Array.isArray(matched.supportedEndpointTypes) ? matched.supportedEndpointTypes : [];
@@ -765,7 +990,7 @@ export async function resolveUpstreamEndpointCandidates(
if (forceMessagesFirstForClaudeModel && !hasConcreteEndpointHint) {
// Generic labels like openai/anthropic are too coarse for Claude models;
// keep messages-first order in this case.
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
const supported = new Set<UpstreamEndpoint>();
@@ -776,19 +1001,19 @@ export async function resolveUpstreamEndpointCandidates(
}
}
if (supported.size === 0) return prioritizedPreferredEndpoints;
if (supported.size === 0) return applyRuntimePreference(prioritizedPreferredEndpoints);
const firstSupported = prioritizedPreferredEndpoints.find((endpoint) => supported.has(endpoint));
if (!firstSupported) return prioritizedPreferredEndpoints;
if (!firstSupported) return applyRuntimePreference(prioritizedPreferredEndpoints);
// Catalog metadata can be incomplete/inaccurate, so only use it to pick
// the first attempt. Keep downstream-driven fallback order unchanged.
return [
return applyRuntimePreference([
firstSupported,
...prioritizedPreferredEndpoints.filter((endpoint) => endpoint !== firstSupported),
];
]);
} catch {
return prioritizedPreferredEndpoints;
return applyRuntimePreference(prioritizedPreferredEndpoints);
}
}
@@ -1195,5 +1420,3 @@ export function buildClaudeCountTokensUpstreamRequest(input: {
},
};
}
+3 -3
View File
@@ -100,7 +100,7 @@ export async function videosProxyRoute(app: FastifyInstance) {
const upstream = await fetch(targetUrl, requestInit);
const text = await upstream.text();
if (!upstream.ok) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
if (isTokenExpiredError({ status: upstream.status, message: text })) {
await reportTokenExpired({
accountId: selected.account.id,
@@ -153,11 +153,11 @@ export async function videosProxyRoute(app: FastifyInstance) {
completionTokens: 0,
totalTokens: 0,
});
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost, selected.actualModel);
recordDownstreamCostUsage(request, estimatedCost);
return reply.code(upstream.status).send(rewriteVideoResponsePublicId(data, mapping.publicId));
} catch (error: any) {
tokenRouter.recordFailure(selected.channel.id);
tokenRouter.recordFailure(selected.channel.id, selected.actualModel);
if (retryCount < MAX_RETRIES) {
retryCount += 1;
continue;
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'vitest';
import {
buildStoredSub2ApiSubscriptionSummary,
getCredentialModeFromExtraConfig,
getPlatformUserIdFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
getSub2ApiSubscriptionFromExtraConfig,
guessPlatformUserIdFromUsername,
mergeAccountExtraConfig,
normalizeCredentialMode,
@@ -105,4 +107,37 @@ describe('accountExtraConfig', () => {
extraConfig: JSON.stringify({ credentialMode: 'session' }),
})).toBe(true);
});
it('parses stored sub2api subscription summary from extra config', () => {
const extraConfig = mergeAccountExtraConfig(null, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
}, 1760000000000),
});
expect(getSub2ApiSubscriptionFromExtraConfig(extraConfig)).toEqual({
activeCount: 1,
totalUsedUsd: 3.5,
subscriptions: [
{
id: 7,
groupName: 'Pro',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.5,
monthlyLimitUsd: 20,
},
],
updatedAt: 1760000000000,
});
});
});
+144
View File
@@ -1,3 +1,5 @@
import type { SubscriptionPlanSummary, SubscriptionSummary } from './platforms/base.js';
type AutoReloginConfig = {
username?: unknown;
passwordCipher?: unknown;
@@ -9,6 +11,13 @@ type Sub2ApiAuthConfig = {
tokenExpiresAt?: unknown;
};
type Sub2ApiSubscriptionConfig = {
updatedAt?: unknown;
activeCount?: unknown;
totalUsedUsd?: unknown;
subscriptions?: unknown;
};
export type AccountCredentialMode = 'auto' | 'session' | 'apikey';
const VALID_CREDENTIAL_MODES = new Set<AccountCredentialMode>([
@@ -26,6 +35,7 @@ type AccountExtraConfig = {
};
autoRelogin?: AutoReloginConfig;
sub2apiAuth?: Sub2ApiAuthConfig;
sub2apiSubscription?: Sub2ApiSubscriptionConfig;
[key: string]: unknown;
};
@@ -64,6 +74,39 @@ function normalizeTimestampMs(raw: unknown): number | undefined {
return undefined;
}
function normalizeNonNegativeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return Math.round(raw * 1_000_000) / 1_000_000;
}
if (typeof raw === 'string') {
const parsed = Number(raw.trim());
if (Number.isFinite(parsed) && parsed >= 0) {
return Math.round(parsed * 1_000_000) / 1_000_000;
}
}
return undefined;
}
function normalizeIsoDateTime(raw: unknown): string | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
return new Date(ms).toISOString();
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
return undefined;
}
export function normalizeCredentialMode(raw: unknown): AccountCredentialMode | undefined {
if (typeof raw !== 'string') return undefined;
const normalized = raw.trim().toLowerCase();
@@ -129,6 +172,10 @@ export type ManagedSub2ApiAuth = {
tokenExpiresAt?: number;
};
export type StoredSub2ApiSubscriptionSummary = SubscriptionSummary & {
updatedAt: number;
};
export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): ManagedSub2ApiAuth | null {
const parsed = parseExtraConfig(extraConfig);
const raw = parsed.sub2apiAuth;
@@ -141,6 +188,103 @@ export function getSub2ApiAuthFromExtraConfig(extraConfig?: string | null): Mana
: { refreshToken };
}
function normalizeSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const item = raw as Record<string, unknown>;
const normalized: SubscriptionPlanSummary = {};
const id = normalizeUserId(item.id);
if (id) normalized.id = id;
const groupId = normalizeUserId(item.groupId ?? item.group_id);
if (groupId) normalized.groupId = groupId;
const groupName = normalizeNonEmptyString(item.groupName ?? item.group_name);
if (groupName) normalized.groupName = groupName;
const status = normalizeNonEmptyString(item.status);
if (status) normalized.status = status;
const expiresAt = normalizeIsoDateTime(
item.expiresAt
?? item.expires_at
?? item.expiredAt
?? item.expired_at
?? item.endAt
?? item.end_at,
);
if (expiresAt) normalized.expiresAt = expiresAt;
const dailyUsedUsd = normalizeNonNegativeNumber(item.dailyUsedUsd ?? item.daily_used_usd);
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
const dailyLimitUsd = normalizeNonNegativeNumber(item.dailyLimitUsd ?? item.daily_limit_usd);
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
const weeklyUsedUsd = normalizeNonNegativeNumber(item.weeklyUsedUsd ?? item.weekly_used_usd);
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
const weeklyLimitUsd = normalizeNonNegativeNumber(item.weeklyLimitUsd ?? item.weekly_limit_usd);
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
const monthlyUsedUsd = normalizeNonNegativeNumber(item.monthlyUsedUsd ?? item.monthly_used_usd);
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
const monthlyLimitUsd = normalizeNonNegativeNumber(item.monthlyLimitUsd ?? item.monthly_limit_usd);
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
return Object.keys(normalized).length > 0 ? normalized : null;
}
function normalizeSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => normalizeSubscriptionItem(item))
.filter((item): item is SubscriptionPlanSummary => !!item);
}
export function normalizeSub2ApiSubscriptionSummary(
raw: unknown,
): StoredSub2ApiSubscriptionSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const body = raw as Record<string, unknown>;
const subscriptions = normalizeSubscriptionItems(body.subscriptions);
const activeCount = normalizeNonNegativeNumber(body.activeCount ?? body.active_count);
const totalUsedUsd = normalizeNonNegativeNumber(body.totalUsedUsd ?? body.total_used_usd);
const updatedAt = normalizeTimestampMs(body.updatedAt ?? body.updated_at);
return {
activeCount: Math.trunc(activeCount ?? subscriptions.length),
totalUsedUsd: totalUsedUsd ?? 0,
subscriptions,
updatedAt: updatedAt ?? Date.now(),
};
}
export function buildStoredSub2ApiSubscriptionSummary(
summary: SubscriptionSummary,
updatedAt = Date.now(),
): StoredSub2ApiSubscriptionSummary {
return normalizeSub2ApiSubscriptionSummary({
...summary,
updatedAt,
}) || {
activeCount: Math.max(0, Math.trunc(summary.activeCount || 0)),
totalUsedUsd: normalizeNonNegativeNumber(summary.totalUsedUsd) ?? 0,
subscriptions: normalizeSubscriptionItems(summary.subscriptions),
updatedAt,
};
}
export function getSub2ApiSubscriptionFromExtraConfig(
extraConfig?: string | null,
): StoredSub2ApiSubscriptionSummary | null {
const parsed = parseExtraConfig(extraConfig);
return normalizeSub2ApiSubscriptionSummary(parsed.sub2apiSubscription);
}
export function guessPlatformUserIdFromUsername(username?: string | null): number | undefined {
const text = (username || '').trim();
if (!text) return undefined;
+6
View File
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm';
import { appendSessionTokenRebindHint, isTokenExpiredError } from './alertRules.js';
import { reportTokenExpired } from './alertService.js';
import {
buildStoredSub2ApiSubscriptionSummary,
getAutoReloginConfig,
getCredentialModeFromExtraConfig,
getSub2ApiAuthFromExtraConfig,
@@ -477,6 +478,11 @@ export async function refreshBalance(accountId: number) {
if (typeof balanceInfo.todayIncome === 'number' && Number.isFinite(balanceInfo.todayIncome)) {
nextExtraConfig = updateTodayIncomeSnapshot(nextExtraConfig, balanceInfo.todayIncome);
}
if (balanceInfo.subscriptionSummary && isSub2ApiPlatform(site.platform)) {
nextExtraConfig = mergeAccountExtraConfig(nextExtraConfig, {
sub2apiSubscription: buildStoredSub2ApiSubscriptionSummary(balanceInfo.subscriptionSummary),
});
}
const existingRuntimeHealth = extractRuntimeHealth(nextExtraConfig);
const keepUnsupportedCheckinDegraded = isUnsupportedCheckinRuntimeHealth(existingRuntimeHealth);
+21
View File
@@ -8,12 +8,33 @@ export interface CheckinResult {
reward?: string;
}
export interface SubscriptionPlanSummary {
id?: number;
groupId?: number;
groupName?: string;
status?: string;
expiresAt?: string;
dailyUsedUsd?: number;
dailyLimitUsd?: number;
weeklyUsedUsd?: number;
weeklyLimitUsd?: number;
monthlyUsedUsd?: number;
monthlyLimitUsd?: number;
}
export interface SubscriptionSummary {
activeCount: number;
totalUsedUsd: number;
subscriptions: SubscriptionPlanSummary[];
}
export interface BalanceInfo {
balance: number;
used: number;
quota: number;
todayIncome?: number;
todayQuotaConsumption?: number;
subscriptionSummary?: SubscriptionSummary;
}
interface LoginResult {
@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import { Sub2ApiAdapter } from './sub2api.js';
class MockSub2ApiAdapter extends Sub2ApiAdapter {
constructor(private readonly responses: Record<string, unknown>) {
super();
}
protected override async fetchJson<T>(url: string): Promise<T> {
const parsed = new URL(url);
const key = `${parsed.pathname}${parsed.search}`;
const value = this.responses[key];
if (value instanceof Error) throw value;
if (value === undefined) throw new Error(`Unexpected request: ${key}`);
return value as T;
}
}
describe('Sub2ApiAdapter subscription summary parsing', () => {
it('returns summary data from /api/v1/subscriptions/summary', async () => {
const adapter = new MockSub2ApiAdapter({
'/api/v1/auth/me': {
code: 0,
message: 'success',
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
},
'/api/v1/subscriptions/summary': {
code: 0,
message: 'success',
data: {
active_count: 1,
total_used_usd: 3.2,
subscriptions: [
{
id: 9,
group_name: 'Pro',
status: 'active',
expires_at: '2026-04-01T00:00:00Z',
monthly_used_usd: 3.2,
monthly_limit_usd: 20,
},
],
},
},
});
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 3.2,
subscriptions: [
{
id: 9,
groupName: 'Pro',
status: 'active',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.2,
monthlyLimitUsd: 20,
},
],
});
});
it('falls back to /api/v1/subscriptions/active when summary endpoint fails', async () => {
const adapter = new MockSub2ApiAdapter({
'/api/v1/auth/me': {
code: 0,
message: 'success',
data: { id: 1, username: 'demo', email: 'demo@example.com', balance: 8.5 },
},
'/api/v1/subscriptions/summary': new Error('HTTP 404'),
'/api/v1/subscriptions/active': {
code: 0,
message: 'success',
data: [
{
id: 10,
group_name: 'Fallback',
expires_at: '2026-05-01T00:00:00Z',
monthly_used_usd: 1.5,
monthly_limit_usd: 10,
},
],
},
});
const balance = await adapter.getBalance('https://sub2api.example.com', 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 1.5,
subscriptions: [
{
id: 10,
groupName: 'Fallback',
expiresAt: '2026-05-01T00:00:00.000Z',
monthlyUsedUsd: 1.5,
monthlyLimitUsd: 10,
},
],
});
});
});
@@ -101,6 +101,111 @@ describe('Sub2ApiAdapter', () => {
expect(balance.used).toBe(0);
});
it('includes subscription summary from /api/v1/subscriptions/summary when available', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
}));
return;
}
if (req.url === '/api/v1/subscriptions/summary') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: {
active_count: 1,
total_used_usd: 3.75,
subscriptions: [
{
id: 3,
group_name: 'Pro',
status: 'active',
expires_at: '2026-04-01T00:00:00Z',
monthly_used_usd: 3.75,
monthly_limit_usd: 20,
},
],
},
}));
return;
}
res.writeHead(404).end();
});
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 3.75,
subscriptions: [
{
id: 3,
groupName: 'Pro',
status: 'active',
expiresAt: '2026-04-01T00:00:00.000Z',
monthlyUsedUsd: 3.75,
monthlyLimitUsd: 20,
},
],
});
});
it('falls back to active subscriptions when summary endpoint is unavailable', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: { id: 1, username: 'testuser', email: 'test@example.com', balance: 12.5 },
}));
return;
}
if (req.url === '/api/v1/subscriptions/summary') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: 404, message: 'not found' }));
return;
}
if (req.url === '/api/v1/subscriptions/active') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
code: 0,
message: 'success',
data: [
{
id: 9,
group_name: 'Fallback',
expires_at: '2026-05-01T00:00:00Z',
monthly_used_usd: 2.5,
monthly_limit_usd: 15,
},
],
}));
return;
}
res.writeHead(404).end();
});
const balance = await adapter.getBalance(baseUrl, 'jwt-token');
expect(balance.subscriptionSummary).toEqual({
activeCount: 1,
totalUsedUsd: 2.5,
subscriptions: [
{
id: 9,
groupName: 'Fallback',
expiresAt: '2026-05-01T00:00:00.000Z',
monthlyUsedUsd: 2.5,
monthlyLimitUsd: 15,
},
],
});
});
it('fetches user info from /api/v1/auth/me', async () => {
await startServer((req, res) => {
if (req.url === '/api/v1/auth/me') {
+195 -1
View File
@@ -4,6 +4,8 @@ import {
CheckinResult,
BalanceInfo,
CreateApiTokenOptions,
SubscriptionPlanSummary,
SubscriptionSummary,
type SiteAnnouncement,
UserInfo,
} from './base.js';
@@ -22,6 +24,193 @@ function normalizeBaseUrl(baseUrl: string): string {
export class Sub2ApiAdapter extends BasePlatformAdapter {
readonly platformName = 'sub2api';
private roundCurrency(value: number): number {
return Math.round(value * 1_000_000) / 1_000_000;
}
private parsePositiveInteger(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return Math.trunc(raw);
if (typeof raw === 'string') {
const parsed = Number.parseInt(raw.trim(), 10);
if (!Number.isNaN(parsed) && parsed > 0) return parsed;
}
return undefined;
}
private parseNonNegativeNumber(raw: unknown): number | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) {
return this.roundCurrency(raw);
}
if (typeof raw === 'string') {
const parsed = Number(raw.trim());
if (Number.isFinite(parsed) && parsed >= 0) {
return this.roundCurrency(parsed);
}
}
return undefined;
}
private parseDateTime(raw: unknown): string | undefined {
if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
const ms = raw > 10_000_000_000 ? raw : raw * 1000;
return new Date(ms).toISOString();
}
if (typeof raw !== 'string') return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
const numeric = Number(trimmed);
if (Number.isFinite(numeric) && numeric > 0) {
const ms = numeric > 10_000_000_000 ? numeric : numeric * 1000;
return new Date(ms).toISOString();
}
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
return undefined;
}
private parseSubscriptionItem(raw: unknown): SubscriptionPlanSummary | null {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
const item = raw as Record<string, unknown>;
const group = item.group && typeof item.group === 'object' && !Array.isArray(item.group)
? item.group as Record<string, unknown>
: null;
const normalized: SubscriptionPlanSummary = {};
const id = this.parsePositiveInteger(item.id);
if (id) normalized.id = id;
const groupId = this.parsePositiveInteger(item.group_id ?? item.groupId ?? group?.id);
if (groupId) normalized.groupId = groupId;
const groupNameCandidates = [
item.group_name,
item.groupName,
item.name,
item.title,
group?.name,
group?.title,
];
for (const candidate of groupNameCandidates) {
if (typeof candidate !== 'string') continue;
const trimmed = candidate.trim();
if (!trimmed) continue;
normalized.groupName = trimmed;
break;
}
if (typeof item.status === 'string' && item.status.trim()) {
normalized.status = item.status.trim();
}
const expiresAt = this.parseDateTime(
item.expires_at
?? item.expiresAt
?? item.expired_at
?? item.expiredAt
?? item.end_at
?? item.endAt
?? item.end_time
?? item.endTime
?? item.current_period_end
?? item.currentPeriodEnd,
);
if (expiresAt) normalized.expiresAt = expiresAt;
const dailyUsedUsd = this.parseNonNegativeNumber(item.daily_used_usd ?? item.dailyUsedUsd);
if (dailyUsedUsd !== undefined) normalized.dailyUsedUsd = dailyUsedUsd;
const dailyLimitUsd = this.parseNonNegativeNumber(item.daily_limit_usd ?? item.dailyLimitUsd);
if (dailyLimitUsd !== undefined) normalized.dailyLimitUsd = dailyLimitUsd;
const weeklyUsedUsd = this.parseNonNegativeNumber(item.weekly_used_usd ?? item.weeklyUsedUsd);
if (weeklyUsedUsd !== undefined) normalized.weeklyUsedUsd = weeklyUsedUsd;
const weeklyLimitUsd = this.parseNonNegativeNumber(item.weekly_limit_usd ?? item.weeklyLimitUsd);
if (weeklyLimitUsd !== undefined) normalized.weeklyLimitUsd = weeklyLimitUsd;
const monthlyUsedUsd = this.parseNonNegativeNumber(
item.monthly_used_usd
?? item.monthlyUsedUsd
?? item.used_usd
?? item.usedUsd
?? item.total_used_usd
?? item.totalUsedUsd,
);
if (monthlyUsedUsd !== undefined) normalized.monthlyUsedUsd = monthlyUsedUsd;
const monthlyLimitUsd = this.parseNonNegativeNumber(
item.monthly_limit_usd
?? item.monthlyLimitUsd
?? item.limit_usd
?? item.limitUsd
?? item.total_limit_usd
?? item.totalLimitUsd,
);
if (monthlyLimitUsd !== undefined) normalized.monthlyLimitUsd = monthlyLimitUsd;
return Object.keys(normalized).length > 0 ? normalized : null;
}
private parseSubscriptionItems(raw: unknown): SubscriptionPlanSummary[] {
const rawItems = (() => {
if (Array.isArray(raw)) return raw;
if (raw && typeof raw === 'object') {
const body = raw as Record<string, unknown>;
if (Array.isArray(body.subscriptions)) return body.subscriptions;
if (Array.isArray(body.items)) return body.items;
if (Array.isArray(body.list)) return body.list;
if (Array.isArray(body.data)) return body.data;
}
return [];
})();
return rawItems
.map((item) => this.parseSubscriptionItem(item))
.filter((item): item is SubscriptionPlanSummary => !!item);
}
private buildSubscriptionSummary(payload: unknown): SubscriptionSummary {
const body = payload && typeof payload === 'object' && !Array.isArray(payload)
? payload as Record<string, unknown>
: {};
const subscriptions = this.parseSubscriptionItems(payload);
const activeCount = this.parsePositiveInteger(body.active_count ?? body.activeCount);
const totalUsedUsd = this.parseNonNegativeNumber(body.total_used_usd ?? body.totalUsedUsd);
const inferredUsedUsd = subscriptions.reduce((sum, item) => sum + (item.monthlyUsedUsd || 0), 0);
return {
activeCount: activeCount ?? subscriptions.length,
totalUsedUsd: totalUsedUsd ?? this.roundCurrency(inferredUsedUsd),
subscriptions,
};
}
private async fetchSubscriptionSummary(baseUrl: string, accessToken: string): Promise<SubscriptionSummary | undefined> {
const headers = this.buildAuthHeader(accessToken);
const summaryEndpoint = '/api/v1/subscriptions/summary';
try {
const res = await this.fetchJson<any>(`${baseUrl}${summaryEndpoint}`, { headers });
const data = this.parseSub2ApiEnvelope<any>(res, summaryEndpoint);
return this.buildSubscriptionSummary(data);
} catch {}
const fallbackEndpoints = ['/api/v1/subscriptions/active'];
for (const endpoint of fallbackEndpoints) {
try {
const res = await this.fetchJson<any>(`${baseUrl}${endpoint}`, { headers });
const data = this.parseSub2ApiEnvelope<any>(res, endpoint);
return this.buildSubscriptionSummary(data);
} catch {}
}
return undefined;
}
private stripBearerPrefix(value?: string | null): string {
const trimmed = (value || '').trim();
if (!trimmed) return '';
@@ -462,13 +651,18 @@ export class Sub2ApiAdapter extends BasePlatformAdapter {
// --- Balance ---
async getBalance(baseUrl: string, accessToken: string): Promise<BalanceInfo> {
const user = await this.fetchAuthMe(baseUrl, accessToken);
const normalizedBase = normalizeBaseUrl(baseUrl);
const [user, subscriptionSummary] = await Promise.all([
this.fetchAuthMe(normalizedBase, accessToken),
this.fetchSubscriptionSummary(normalizedBase, accessToken),
]);
const quotaValue = this.usdToQuota(user.balance);
// Sub2API only provides current balance, no usage breakdown
return {
balance: quotaValue / 500000,
used: 0,
quota: quotaValue / 500000,
subscriptionSummary,
};
}
+12 -6
View File
@@ -20,10 +20,19 @@ describe('proxyRetryPolicy', () => {
).toBe(true);
});
it('retries on generic client request errors for cross-channel fallback', () => {
it('does not retry obvious request-shape errors that will fail on every channel', () => {
expect(
shouldRetryProxyRequest(400, '{"error":{"message":"invalid request body"}}'),
).toBe(true);
).toBe(false);
expect(
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
).toBe(false);
expect(
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
).toBe(false);
});
it('keeps retrying channel-local compatibility and auth failures', () => {
expect(
shouldRetryProxyRequest(401, '{"error":{"message":"invalid access token"}}'),
).toBe(true);
@@ -31,10 +40,7 @@ describe('proxyRetryPolicy', () => {
shouldRetryProxyRequest(403, '{"error":{"message":"forbidden"}}'),
).toBe(true);
expect(
shouldRetryProxyRequest(404, '{"error":{"message":"not found"}}'),
).toBe(true);
expect(
shouldRetryProxyRequest(422, '{"error":{"message":"unprocessable"}}'),
shouldRetryProxyRequest(400, 'Unsupported legacy protocol: /v1/chat/completions is not supported. Please use /v1/responses.'),
).toBe(true);
});
});
+51 -4
View File
@@ -14,14 +14,61 @@ const MODEL_UNSUPPORTED_PATTERNS: RegExp[] = [
/you\s+do\s+not\s+have\s+access\s+to\s+the\s+model/i,
];
const RETRYABLE_CHANNEL_LOCAL_PATTERNS: RegExp[] = [
/unsupported\s+legacy\s+protocol/i,
/please\s+use\s+\/v1\/responses/i,
/please\s+use\s+\/v1\/messages/i,
/please\s+use\s+\/v1\/chat\/completions/i,
/does\s+not\s+allow\s+\/v1\/[a-z0-9/_:-]+\s+dispatch/i,
/unsupported\s+endpoint/i,
/unsupported\s+path/i,
/unknown\s+endpoint/i,
/unrecognized\s+request\s+url/i,
/no\s+route\s+matched/i,
/invalid\s+api\s+key/i,
/invalid\s+access\s+token/i,
/forbidden/i,
/rate\s+limit/i,
/quota/i,
/bad\s+gateway/i,
/gateway\s+time-?out/i,
/service\s+unavailable/i,
/cpu\s+overloaded/i,
/timeout/i,
];
const NON_RETRYABLE_REQUEST_PATTERNS: RegExp[] = [
/invalid\s+request\s+body/i,
/validation/i,
/missing\s+required/i,
/required\s+parameter/i,
/unknown\s+parameter/i,
/unrecognized\s+(field|key|parameter)/i,
/malformed/i,
/invalid\s+json/i,
/cannot\s+parse/i,
/unsupported\s+media\s+type/i,
];
function isModelUnsupportedErrorMessage(rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
return MODEL_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(text));
}
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
if (status >= 400) return true;
if (!upstreamErrorText) return false;
return isModelUnsupportedErrorMessage(upstreamErrorText);
function matchesAnyPattern(patterns: RegExp[], rawMessage?: string | null): boolean {
const text = (rawMessage || '').trim();
if (!text) return false;
return patterns.some((pattern) => pattern.test(text));
}
export function shouldRetryProxyRequest(status: number, upstreamErrorText?: string | null): boolean {
if (status >= 500) return true;
if (status === 408 || status === 409 || status === 425 || status === 429) return true;
if (status === 401 || status === 403) return true;
if (isModelUnsupportedErrorMessage(upstreamErrorText)) return true;
if (matchesAnyPattern(NON_RETRYABLE_REQUEST_PATTERNS, upstreamErrorText)) return false;
if (matchesAnyPattern(RETRYABLE_CHANNEL_LOCAL_PATTERNS, upstreamErrorText)) return true;
if (status === 400 || status === 404 || status === 422) return false;
return false;
}
+2 -1
View File
@@ -1,10 +1,11 @@
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
export const DEFAULT_ROUTE_ROUTING_STRATEGY: RouteRoutingStrategy = 'weighted';
export function normalizeRouteRoutingStrategy(value: unknown): RouteRoutingStrategy {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'round_robin') return 'round_robin';
if (normalized === 'stable_first') return 'stable_first';
return DEFAULT_ROUTE_ROUTING_STRATEGY;
}
@@ -13,6 +13,7 @@ describe('TokenRouter runtime cache', () => {
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let config: ConfigModule['config'];
let dataDir = '';
let originalCacheTtlMs = 0;
@@ -29,6 +30,7 @@ describe('TokenRouter runtime cache', () => {
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
config = configModule.config;
originalCacheTtlMs = config.tokenRouterCacheTtlMs;
});
@@ -36,16 +38,19 @@ describe('TokenRouter runtime cache', () => {
beforeEach(async () => {
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
config.tokenRouterCacheTtlMs = 60_000;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
});
afterAll(() => {
config.tokenRouterCacheTtlMs = originalCacheTtlMs;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
delete process.env.DATA_DIR;
});
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
type DbModule = typeof import('../db/index.js');
type TokenRouterModule = typeof import('./tokenRouter.js');
@@ -24,6 +25,8 @@ describe('TokenRouter selection scoring', () => {
let schema: DbModule['schema'];
let TokenRouter: TokenRouterModule['TokenRouter'];
let invalidateTokenRouterCache: TokenRouterModule['invalidateTokenRouterCache'];
let resetSiteRuntimeHealthState: TokenRouterModule['resetSiteRuntimeHealthState'];
let flushSiteRuntimeHealthPersistence: TokenRouterModule['flushSiteRuntimeHealthPersistence'];
let config: ConfigModule['config'];
let dataDir = '';
let idSeed = 0;
@@ -47,6 +50,8 @@ describe('TokenRouter selection scoring', () => {
schema = dbModule.schema;
TokenRouter = tokenRouterModule.TokenRouter;
invalidateTokenRouterCache = tokenRouterModule.invalidateTokenRouterCache;
resetSiteRuntimeHealthState = tokenRouterModule.resetSiteRuntimeHealthState;
flushSiteRuntimeHealthPersistence = tokenRouterModule.flushSiteRuntimeHealthPersistence;
config = configModule.config;
originalRoutingWeights = { ...config.routingWeights };
originalRoutingFallbackUnitCost = config.routingFallbackUnitCost;
@@ -58,16 +63,19 @@ describe('TokenRouter selection scoring', () => {
mockedCatalogRoutingCost.mockReturnValue(null);
await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run();
await db.delete(schema.settings).run();
await db.delete(schema.accountTokens).run();
await db.delete(schema.accounts).run();
await db.delete(schema.sites).run();
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
});
afterAll(() => {
config.routingWeights = { ...originalRoutingWeights };
config.routingFallbackUnitCost = originalRoutingFallbackUnitCost;
invalidateTokenRouterCache();
resetSiteRuntimeHealthState();
delete process.env.DATA_DIR;
});
@@ -398,4 +406,400 @@ describe('TokenRouter selection scoring', () => {
expect(catalogCandidate?.reason || '').toContain('成本=目录:0.200000');
expect(fallbackCandidate?.reason || '').toContain('成本=默认:100.000000');
});
it('downweights a site after transient failures and restores it quickly after success', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.4');
const siteA = await createSite('runtime-a');
const accountA = await createAccount(siteA.id, 'runtime-user-a');
const tokenA = await createToken(accountA.id, 'runtime-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('runtime-b');
const accountB = await createAccount(siteB.id, 'runtime-user-b');
const tokenB = await createToken(accountB.id, 'runtime-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
let decision = await router.explainSelection('gpt-5.4');
let candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
let candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA?.probability).toBeCloseTo(50, 1);
expect(candidateB?.probability).toBeCloseTo(50, 1);
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Bad gateway',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan(30);
expect(candidateA?.reason || '').toContain('运行时健康=');
expect((candidateB?.probability || 0)).toBeGreaterThan(70);
await router.recordSuccess(channelA.id, 800, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.4');
candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((candidateA?.probability || 0)).toBeGreaterThan(40);
expect((candidateB?.probability || 0)).toBeLessThan(60);
});
it('opens a site breaker after repeated transient failures and closes it after recovery', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-5.3');
const siteA = await createSite('breaker-a');
const accountA = await createAccount(siteA.id, 'breaker-user-a');
const tokenA = await createToken(accountA.id, 'breaker-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('breaker-b');
const accountB = await createAccount(siteB.id, 'breaker-user-b');
const tokenB = await createToken(accountB.id, 'breaker-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
for (let index = 0; index < 3; index += 1) {
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
});
}
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
let decision = await router.explainSelection('gpt-5.3');
const breakerCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const breakerCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(breakerCandidateA?.reason || '').toContain('站点熔断');
expect((breakerCandidateA?.probability || 0)).toBe(0);
expect((breakerCandidateB?.probability || 0)).toBe(100);
expect(decision.summary.join(' ')).toContain('站点熔断避让');
await router.recordSuccess(channelA.id, 600, 0);
invalidateTokenRouterCache();
decision = await router.explainSelection('gpt-5.3');
const recoveredCandidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const recoveredCandidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect((recoveredCandidateA?.probability || 0)).toBeGreaterThan(30);
expect((recoveredCandidateB?.probability || 0)).toBeLessThan(70);
});
it('uses persisted site success and latency history to prefer historically healthier sites', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('claude-4-sonnet');
const siteStable = await createSite('history-stable');
const accountStable = await createAccount(siteStable.id, 'history-user-stable');
const tokenStable = await createToken(accountStable.id, 'history-token-stable');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountStable.id,
tokenId: tokenStable.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 90,
failCount: 10,
totalLatencyMs: 90 * 240,
}).run();
const siteWeak = await createSite('history-weak');
const accountWeak = await createAccount(siteWeak.id, 'history-user-weak');
const tokenWeak = await createToken(accountWeak.id, 'history-token-weak');
await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountWeak.id,
tokenId: tokenWeak.id,
priority: 0,
weight: 10,
enabled: true,
successCount: 20,
failCount: 30,
totalLatencyMs: 20 * 5200,
}).run();
const decision = await new TokenRouter().explainSelection('claude-4-sonnet');
const stableCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-stable'));
const weakCandidate = decision.candidates.find((candidate) => candidate.siteName.startsWith('history-weak'));
expect(stableCandidate).toBeTruthy();
expect(weakCandidate).toBeTruthy();
expect((stableCandidate?.probability || 0)).toBeGreaterThan(weakCandidate?.probability || 0);
expect(stableCandidate?.reason || '').toContain('历史健康=');
expect(stableCandidate?.reason || '').toContain('成功率=90.0%');
expect(weakCandidate?.reason || '').toContain('成功率=40.0%');
});
it('reloads persisted runtime health after in-memory reset', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await createRoute('gpt-4o-mini');
const siteA = await createSite('persist-a');
const accountA = await createAccount(siteA.id, 'persist-user-a');
const tokenA = await createToken(accountA.id, 'persist-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('persist-b');
const accountB = await createAccount(siteB.id, 'persist-user-b');
const tokenB = await createToken(accountB.id, 'persist-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-4o-mini',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
await flushSiteRuntimeHealthPersistence();
const persisted = await db.select().from(schema.settings)
.where(eq(schema.settings.key, 'token_router_site_runtime_health_v1'))
.get();
expect(persisted?.value).toBeTruthy();
resetSiteRuntimeHealthState();
invalidateTokenRouterCache();
const decision = await new TokenRouter().explainSelection('gpt-4o-mini');
const candidateA = decision.candidates.find((candidate) => candidate.channelId === channelA.id);
const candidateB = decision.candidates.find((candidate) => candidate.channelId === channelB.id);
expect(candidateA).toBeTruthy();
expect(candidateB).toBeTruthy();
expect((candidateA?.probability || 0)).toBeLessThan((candidateB?.probability || 0));
expect(candidateA?.reason || '').toContain('运行时健康=');
});
it('penalizes the failed model more than unrelated models on the same site', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const gptRoute = await createRoute('gpt-5.4');
const claudeRoute = await createRoute('claude-sonnet-4-6');
const siteA = await createSite('model-aware-a');
const accountA = await createAccount(siteA.id, 'model-aware-user-a');
const tokenA = await createToken(accountA.id, 'model-aware-token-a');
const gptChannelA = await db.insert(schema.routeChannels).values({
routeId: gptRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
await db.insert(schema.routeChannels).values({
routeId: claudeRoute.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).run();
const siteB = await createSite('model-aware-b');
const accountB = await createAccount(siteB.id, 'model-aware-user-b');
const tokenB = await createToken(accountB.id, 'model-aware-token-b');
await db.insert(schema.routeChannels).values([
{
routeId: gptRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
{
routeId: claudeRoute.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
},
]).run();
const router = new TokenRouter();
await router.recordFailure(gptChannelA.id, {
status: 502,
errorText: 'Bad gateway',
modelName: 'gpt-5.4',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, gptChannelA.id)).run();
invalidateTokenRouterCache();
const gptDecision = await router.explainSelection('gpt-5.4');
const claudeDecision = await router.explainSelection('claude-sonnet-4-6');
const gptCandidateA = gptDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
const claudeCandidateA = claudeDecision.candidates.find((candidate) => candidate.siteName.startsWith('model-aware-a'));
expect(gptCandidateA).toBeTruthy();
expect(claudeCandidateA).toBeTruthy();
expect((gptCandidateA?.probability || 0)).toBeLessThan((claudeCandidateA?.probability || 0));
expect(gptCandidateA?.reason || '').toContain('模型=');
});
it('stable_first deterministically chooses the healthiest candidate', async () => {
config.routingWeights = {
baseWeightFactor: 1,
valueScoreFactor: 0,
costWeight: 0,
balanceWeight: 0,
usageWeight: 0,
};
const route = await db.insert(schema.tokenRoutes).values({
modelPattern: 'gpt-5.1',
routingStrategy: 'stable_first',
enabled: true,
}).returning().get();
const siteA = await createSite('stable-first-a');
const accountA = await createAccount(siteA.id, 'stable-first-user-a');
const tokenA = await createToken(accountA.id, 'stable-first-token-a');
const channelA = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountA.id,
tokenId: tokenA.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const siteB = await createSite('stable-first-b');
const accountB = await createAccount(siteB.id, 'stable-first-user-b');
const tokenB = await createToken(accountB.id, 'stable-first-token-b');
const channelB = await db.insert(schema.routeChannels).values({
routeId: route.id,
accountId: accountB.id,
tokenId: tokenB.id,
priority: 0,
weight: 10,
enabled: true,
}).returning().get();
const router = new TokenRouter();
await router.recordFailure(channelA.id, {
status: 502,
errorText: 'Gateway timeout',
modelName: 'gpt-5.1',
});
await db.update(schema.routeChannels).set({
cooldownUntil: null,
lastFailAt: null,
failCount: 0,
}).where(eq(schema.routeChannels.id, channelA.id)).run();
invalidateTokenRouterCache();
const preview = await router.previewSelectedChannel('gpt-5.1');
const decision = await router.explainSelection('gpt-5.1');
expect(preview?.channel.id).toBe(channelB.id);
expect(decision.summary.join(' ')).toContain('稳定优先');
expect(decision.selectedChannelId).toBe(channelB.id);
});
});
File diff suppressed because it is too large Load Diff
+46
View File
@@ -1917,6 +1917,52 @@ body {
white-space: normal !important;
}
.site-balance-cell {
white-space: normal !important;
}
.site-balance-inline {
display: inline-flex;
flex-wrap: wrap;
align-items: baseline;
gap: 6px;
max-width: 100%;
line-height: 1.35;
}
.site-balance-inline.align-end {
justify-content: flex-end;
}
.site-balance-primary {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
font-variant-numeric: tabular-nums;
line-height: 1.15;
}
.site-balance-divider {
color: var(--color-text-muted);
font-size: 12px;
}
.site-balance-subscription {
font-size: 11px;
color: var(--color-text-muted);
line-height: 1.35;
font-variant-numeric: tabular-nums;
white-space: normal;
text-wrap: pretty;
cursor: help;
transition: color 0.15s ease;
}
.site-balance-subscription:hover,
.site-balance-subscription:focus-visible {
color: var(--color-text-secondary);
}
.sites-row-actions {
display: flex;
flex-wrap: wrap;
+132 -3
View File
@@ -23,6 +23,16 @@ import {
type SiteForm,
} from './helpers/sitesEditor.js';
type SiteSubscriptionSummary = {
activeCount: number;
totalUsedUsd: number;
totalMonthlyLimitUsd?: number | null;
totalRemainingUsd?: number | null;
nextExpiresAt?: string | null;
planNames?: string[];
updatedAt?: number | null;
};
type SiteRow = {
id: number;
name: string;
@@ -36,6 +46,7 @@ type SiteRow = {
isPinned?: boolean;
sortOrder?: number;
totalBalance?: number;
subscriptionSummary?: SiteSubscriptionSummary | null;
createdAt?: string;
};
@@ -43,6 +54,105 @@ function hasConfiguredCustomHeaders(customHeaders?: string | null): boolean {
return typeof customHeaders === 'string' && customHeaders.trim().length > 0;
}
function formatUsd(value?: number | null): string {
return `$${(value || 0).toFixed(2)}`;
}
function formatSubscriptionDate(value?: string | null): string {
if (!value) return '';
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return value;
return new Date(parsed).toISOString().slice(0, 10);
}
function formatRemainingDuration(value?: string | null): string | null {
if (!value) return null;
const targetMs = Date.parse(value);
if (!Number.isFinite(targetMs)) return null;
const deltaMs = targetMs - Date.now();
if (deltaMs <= 0) return '已到期';
const minuteMs = 60 * 1000;
const hourMs = 60 * minuteMs;
const dayMs = 24 * hourMs;
if (deltaMs >= dayMs) return `剩余${Math.ceil(deltaMs / dayMs)}天`;
if (deltaMs >= hourMs) return `剩余${Math.ceil(deltaMs / hourMs)}小时`;
if (deltaMs >= minuteMs) return `剩余${Math.ceil(deltaMs / minuteMs)}分钟`;
return `剩余${Math.max(1, Math.ceil(deltaMs / 1000))}秒`;
}
function buildSubscriptionInlineValue(summary?: SiteSubscriptionSummary | null): string | null {
if (!summary) return null;
const remainingValue = typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)
? formatUsd(summary.totalRemainingUsd)
: '--';
const usedValue = formatUsd(summary.totalUsedUsd);
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
const remainingSuffix = remainingDuration ? `(${remainingDuration})` : '';
if (usedValue === '$0.00' && remainingValue === '--' && !remainingSuffix) return null;
return `${remainingValue}${remainingSuffix}`;
}
function buildSubscriptionTooltip(summary?: SiteSubscriptionSummary | null): string | null {
if (!summary) return null;
const parts: string[] = [];
if (summary.activeCount > 0) parts.push(`生效订阅 ${summary.activeCount} 个`);
const planNames = Array.isArray(summary.planNames)
? summary.planNames.filter((item) => typeof item === 'string' && item.trim())
: [];
if (planNames.length > 0) parts.push(`套餐 ${planNames.join(' / ')}`);
if (typeof summary.totalRemainingUsd === 'number' && Number.isFinite(summary.totalRemainingUsd)) {
parts.push(`订阅余额 ${formatUsd(summary.totalRemainingUsd)}`);
}
parts.push(`已用 ${formatUsd(summary.totalUsedUsd)}`);
if (typeof summary.totalMonthlyLimitUsd === 'number' && Number.isFinite(summary.totalMonthlyLimitUsd)) {
parts.push(`总额度 ${formatUsd(summary.totalMonthlyLimitUsd)}`);
}
const remainingDuration = formatRemainingDuration(summary.nextExpiresAt);
if (remainingDuration) parts.push(remainingDuration);
if (summary.nextExpiresAt) parts.push(`到期 ${formatSubscriptionDate(summary.nextExpiresAt)}`);
return parts.join(' | ');
}
function SiteBalanceDisplay(props: {
balance?: number | null;
summary?: SiteSubscriptionSummary | null;
align?: 'start' | 'end';
}) {
const { balance, summary, align = 'start' } = props;
const walletBalanceText = formatUsd(balance);
const subscriptionValue = buildSubscriptionInlineValue(summary);
const tooltip = buildSubscriptionTooltip(summary);
return (
<div
className={`site-balance-inline ${align === 'end' ? 'align-end' : ''}`.trim()}
>
<span className="site-balance-primary">{walletBalanceText}</span>
{subscriptionValue ? (
<>
<span className="site-balance-divider">/</span>
<span
className="site-balance-subscription"
data-tooltip={tooltip || undefined}
data-tooltip-align={align === 'end' ? 'end' : 'start'}
data-tooltip-side="top"
tabIndex={tooltip ? 0 : undefined}
>
{subscriptionValue}
</span>
</>
) : null}
</div>
);
}
const platformColors: Record<string, string> = {
'new-api': 'badge-info',
'one-api': 'badge-success',
@@ -873,7 +983,16 @@ export default function Sites() {
</span>
)}
/>
<MobileField label="余额" value={`$${(site.totalBalance || 0).toFixed(2)}`} />
<MobileField
label="余额"
value={(
<SiteBalanceDisplay
balance={site.totalBalance}
summary={site.subscriptionSummary}
align="end"
/>
)}
/>
<MobileField label="权重" value={(site.globalWeight || 1).toFixed(2)} />
{isExpanded ? (
<div className="mobile-card-extra">
@@ -1017,6 +1136,7 @@ export default function Sites() {
/>
</td>
<td style={{ fontWeight: 600 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-start' }}>
<a
href={site.url}
target="_blank"
@@ -1028,6 +1148,12 @@ export default function Sites() {
>
{site.name}
</a>
{hasConfiguredCustomHeaders(site.customHeaders) ? (
<span className="badge badge-info" style={{ fontSize: 11 }}>
自定义头
</span>
) : null}
</div>
</td>
<td className="sites-url-cell" style={{ maxWidth: 300 }}>
{site.externalCheckinUrl ? (
@@ -1048,8 +1174,11 @@ export default function Sites() {
</a>
) : null}
</td>
<td style={{ fontVariantNumeric: 'tabular-nums', fontWeight: 600 }}>
${(site.totalBalance || 0).toFixed(2)}
<td className="site-balance-cell">
<SiteBalanceDisplay
balance={site.totalBalance}
summary={site.subscriptionSummary}
/>
</td>
<td>
<span className={`badge ${site.status === 'disabled' ? 'badge-muted' : 'badge-success'}`} style={{ fontSize: 11 }}>
+21 -3
View File
@@ -88,6 +88,24 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
advancedOpen: false,
};
function normalizeRouteRoutingStrategyValue(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
if (value === 'round_robin' || value === 'stable_first') return value;
return 'weighted';
}
function getRouteRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRouteRoutingStrategyValue(value);
if (strategy === 'round_robin') return tr('轮询');
if (strategy === 'stable_first') return tr('稳定优先');
return tr('权重随机');
}
function getRouteRoutingStrategySuccessMessage(value: RouteRoutingStrategy): string {
if (value === 'round_robin') return '已切换为轮询策略';
if (value === 'stable_first') return '已切换为稳定优先策略';
return '已切换为权重随机策略';
}
export default function TokenRoutes() {
const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -421,7 +439,7 @@ export default function TokenRoutes() {
};
const handleRoutingStrategyChange = async (route: RouteSummaryRow, routingStrategy: RouteRoutingStrategy) => {
const currentStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const currentStrategy = normalizeRouteRoutingStrategyValue(route.routingStrategy);
if (routingStrategy === currentStrategy) return;
setUpdatingRoutingStrategyByRoute((prev) => ({ ...prev, [route.id]: true }));
@@ -432,7 +450,7 @@ export default function TokenRoutes() {
)));
try {
await api.updateRoute(route.id, { routingStrategy });
toast.success(routingStrategy === 'round_robin' ? '已切换为轮询策略' : '已切换为权重随机策略');
toast.success(getRouteRoutingStrategySuccessMessage(routingStrategy));
} catch (e: any) {
setRouteSummaries((prev) => prev.map((item) => (
item.id === route.id
@@ -1186,7 +1204,7 @@ export default function TokenRoutes() {
>
<MobileField label="模型" value={route.modelPattern} />
<MobileField label="通道" value={route.channelCount} />
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : (route.routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机'))} />
<MobileField label="策略" value={isReadOnlyRoute ? tr('未生成') : getRouteRoutingStrategyLabel(route.routingStrategy)} />
<MobileField label="状态" value={isReadOnlyRoute ? tr('未生成') : (route.enabled ? tr('启用') : tr('禁用'))} />
<div className="mobile-card-actions">
<button
+19 -2
View File
@@ -72,6 +72,18 @@ type RouteCardProps = {
onToggleSourceGroup: (groupKey: string) => void;
};
function normalizeRoutingStrategy(value?: RouteRoutingStrategy | null): RouteRoutingStrategy {
if (value === 'round_robin' || value === 'stable_first') return value;
return 'weighted';
}
function getRoutingStrategyLabel(value?: RouteRoutingStrategy | null): string {
const strategy = normalizeRoutingStrategy(value);
if (strategy === 'round_robin') return tr('轮询');
if (strategy === 'stable_first') return tr('稳定优先');
return tr('权重随机');
}
function AnimatedCollapseSection({ open, children }: { open: boolean; children: ReactNode }) {
const presence = useAnimatedVisibility(open, 220);
if (!presence.shouldRender) return null;
@@ -120,7 +132,7 @@ function RouteCardInner({
const readOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
const channelManagementDisabled = explicitGroupRoute;
const title = resolveRouteTitle(route);
const routingStrategy = route.routingStrategy === 'round_robin' ? 'round_robin' : 'weighted';
const routingStrategy = normalizeRoutingStrategy(route.routingStrategy);
const routingStrategyOptions = [
{
value: 'weighted',
@@ -132,6 +144,11 @@ function RouteCardInner({
label: tr('轮询'),
description: tr('按全局顺序轮流调用,忽略优先级,连续失败 3 次后进入分级冷却'),
},
{
value: 'stable_first',
label: tr('稳定优先'),
description: tr('按优先级优先选择当前最稳、最快、成功率更高的通道,不做随机分流'),
},
] as const;
const sensors = useSensors(
@@ -228,7 +245,7 @@ function RouteCardInner({
</span>
) : (
<span className="badge badge-muted" style={{ fontSize: 10, flexShrink: 0 }}>
{routingStrategy === 'round_robin' ? tr('轮询') : tr('权重随机')}
{getRoutingStrategyLabel(routingStrategy)}
</span>
)}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { BrandInfo } from '../../components/BrandIcon.js';
export type RouteSortBy = 'modelPattern' | 'channelCount';
export type RouteSortDir = 'asc' | 'desc';
export type GroupFilter = null | '__all__' | number;
export type RouteRoutingStrategy = 'weighted' | 'round_robin';
export type RouteRoutingStrategy = 'weighted' | 'round_robin' | 'stable_first';
export type RouteRowKind = 'persisted' | 'zero_channel';
export type RouteMode = 'pattern' | 'explicit_group';
@@ -127,4 +127,53 @@ describe('TokenRoutes routing strategy updates', () => {
root?.unmount();
}
});
it('supports switching to stable_first and keeps the optimistic label when refresh fails', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter initialEntries={['/routes']}>
<ToastProvider>
<TokenRoutes />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const expandButton = root.root.find((node) => (
node.type === 'div'
&& String(node.props.className || '').includes('route-card-collapsed')
));
await act(async () => {
expandButton.props.onClick();
});
await flushMicrotasks();
const stableFirstOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('稳定优先')
));
await act(async () => {
stableFirstOption.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRoute).toHaveBeenCalledWith(1, { routingStrategy: 'stable_first' });
const strategyTrigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
&& collectText(node).includes('稳定优先')
));
expect(collectText(strategyTrigger)).toContain('稳定优先');
} finally {
root?.unmount();
}
});
});
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker/docker-compose.yml"
COMPOSE_OVERRIDE_FILE="$ROOT_DIR/docker/docker-compose.override.yml"
ENV_FILE="$ROOT_DIR/docker/.env"
DATA_DIR="$ROOT_DIR/docker/data"
DO_PULL=0
SKIP_BACKUP=0
usage() {
cat <<'EOF'
Usage: ./update-and-restart.sh [--pull] [--skip-backup]
默认行为:
1. 保留现有 docker/data 数据
2. 先备份数据目录
3. 基于当前本地代码重新构建并重启容器
可选参数:
--pull 先拉取最新 Git 代码后再构建。若工作区有未提交修改会直接退出,避免覆盖本地改动
--skip-backup 跳过启动前数据备份
-h, --help 显示帮助
EOF
}
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing required command: $1" >&2
exit 1
fi
}
require_file() {
if [ ! -f "$1" ]; then
echo "Missing required file: $1" >&2
exit 1
fi
}
while [ $# -gt 0 ]; do
case "$1" in
--pull)
DO_PULL=1
;;
--skip-backup)
SKIP_BACKUP=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown argument: $1" >&2
usage >&2
exit 1
;;
esac
shift
done
require_cmd docker
require_cmd tar
require_cmd git
require_file "$COMPOSE_FILE"
require_file "$COMPOSE_OVERRIDE_FILE"
require_file "$ENV_FILE"
COMPOSE_ARGS=(
-f "$COMPOSE_FILE"
-f "$COMPOSE_OVERRIDE_FILE"
--env-file "$ENV_FILE"
)
if [ "$DO_PULL" -eq 1 ]; then
echo "[0/5] Checking git worktree..."
if [ -n "$(git -C "$ROOT_DIR" status --porcelain)" ]; then
echo "Git worktree has uncommitted changes. Commit/stash them first, or rerun without --pull to rebuild the current local code." >&2
exit 1
fi
echo "[1/5] Pulling latest git changes..."
git -C "$ROOT_DIR" pull --rebase
else
echo "[1/4] Using current local source code..."
fi
BACKUP_FILE=""
if [ "$SKIP_BACKUP" -eq 0 ]; then
mkdir -p "$DATA_DIR"
BACKUP_FILE="$ROOT_DIR/docker/data-backup-$(date +%Y%m%d-%H%M%S).tar.gz"
if [ "$DO_PULL" -eq 1 ]; then
echo "[2/5] Backing up data directory..."
else
echo "[2/4] Backing up data directory..."
fi
tar -czf "$BACKUP_FILE" -C "$DATA_DIR" .
else
if [ "$DO_PULL" -eq 1 ]; then
echo "[2/5] Skipping data backup by request..."
else
echo "[2/4] Skipping data backup by request..."
fi
fi
if [ "$DO_PULL" -eq 1 ]; then
echo "[3/5] Stopping current containers..."
else
echo "[3/4] Stopping current containers..."
fi
docker compose "${COMPOSE_ARGS[@]}" down --remove-orphans
if [ "$DO_PULL" -eq 1 ]; then
echo "[4/5] Rebuilding and starting containers..."
else
echo "[4/4] Rebuilding and starting containers..."
fi
docker compose "${COMPOSE_ARGS[@]}" up -d --build --force-recreate
echo
echo "Container status:"
docker compose "${COMPOSE_ARGS[@]}" ps
echo
echo "Recent logs:"
docker compose "${COMPOSE_ARGS[@]}" logs --tail 20
if [ -n "$BACKUP_FILE" ]; then
echo
echo "Data backup saved to: $BACKUP_FILE"
fi
if PORT_MAPPING="$(docker compose "${COMPOSE_ARGS[@]}" port metapi 4000 2>/dev/null)"; then
echo
echo "Service is available at: $PORT_MAPPING"
fi