From e9c1bd898e6647b4a4acbd095d77d26896497f0f Mon Sep 17 00:00:00 2001 From: Bainily Date: Fri, 20 Mar 2026 16:18:28 +0800 Subject: [PATCH] [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 --- docker/docker-compose.yml | 2 +- src/server/proxy-core/surfaces/chatSurface.ts | 79 +- .../proxy-core/surfaces/geminiSurface.ts | 50 +- .../surfaces/openAiResponsesSurface.ts | 67 +- .../api/sites.subscription-summary.test.ts | 128 ++ src/server/routes/api/sites.ts | 78 +- src/server/routes/proxy/chat.stream.test.ts | 13 + src/server/routes/proxy/completions.ts | 92 +- src/server/routes/proxy/embeddings.ts | 50 +- src/server/routes/proxy/endpointFlow.test.ts | 34 + src/server/routes/proxy/endpointFlow.ts | 28 + src/server/routes/proxy/gemini.test.ts | 31 +- src/server/routes/proxy/images.ts | 84 +- src/server/routes/proxy/search.ts | 40 +- .../routes/proxy/upstreamEndpoint.test.ts | 46 + src/server/routes/proxy/upstreamEndpoint.ts | 267 ++++- src/server/routes/proxy/videos.ts | 6 +- .../services/accountExtraConfig.test.ts | 35 + src/server/services/accountExtraConfig.ts | 144 +++ src/server/services/balanceService.ts | 6 + src/server/services/platforms/base.ts | 21 + .../sub2api.subscription-summary.test.ts | 102 ++ src/server/services/platforms/sub2api.test.ts | 105 ++ src/server/services/platforms/sub2api.ts | 196 +++- src/server/services/proxyRetryPolicy.test.ts | 18 +- src/server/services/proxyRetryPolicy.ts | 55 +- src/server/services/routeRoutingStrategy.ts | 3 +- src/server/services/tokenRouter.cache.test.ts | 5 + .../services/tokenRouter.selection.test.ts | 404 +++++++ src/server/services/tokenRouter.ts | 1033 +++++++++++++++-- src/web/index.css | 46 + src/web/pages/Sites.tsx | 157 ++- src/web/pages/TokenRoutes.tsx | 24 +- src/web/pages/token-routes/RouteCard.tsx | 21 +- src/web/pages/token-routes/types.ts | 2 +- .../tokenRoutes.routing-strategy.test.tsx | 49 + update-and-restart.sh | 139 +++ 37 files changed, 3424 insertions(+), 236 deletions(-) create mode 100644 src/server/routes/api/sites.subscription-summary.test.ts create mode 100644 src/server/services/platforms/sub2api.subscription-summary.test.ts create mode 100755 update-and-restart.sh diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 360a8f4..99d4a6a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -2,7 +2,7 @@ services: metapi: image: 1467078763/metapi:latest ports: - - "4000:4000" + - "127.0.0.1:4000:4000" volumes: - ./data:/app/data environment: diff --git a/src/server/proxy-core/surfaces/chatSurface.ts b/src/server/proxy-core/surfaces/chatSurface.ts index 3e08dfa..d64ecaf 100644 --- a/src/server/proxy-core/surfaces/chatSurface.ts +++ b/src/server/proxy-core/surfaces/chatSurface.ts @@ -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, diff --git a/src/server/proxy-core/surfaces/geminiSurface.ts b/src/server/proxy-core/surfaces/geminiSurface.ts index 47dfd1d..462b981 100644 --- a/src/server/proxy-core/surfaces/geminiSurface.ts +++ b/src/server/proxy-core/surfaces/geminiSurface.ts @@ -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, diff --git a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts index 3fb783d..4ed49ae 100644 --- a/src/server/proxy-core/surfaces/openAiResponsesSurface.ts +++ b/src/server/proxy-core/surfaces/openAiResponsesSurface.ts @@ -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, diff --git a/src/server/routes/api/sites.subscription-summary.test.ts b/src/server/routes/api/sites.subscription-summary.test.ts new file mode 100644 index 0000000..6dc6c12 --- /dev/null +++ b/src/server/routes/api/sites.subscription-summary.test.ts @@ -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, + }); + }); +}); diff --git a/src/server/routes/api/sites.ts b/src/server/routes/api/sites.ts index e073e56..d433b8a 100644 --- a/src/server/routes/api/sites.ts +++ b/src/server/routes/api/sites.ts @@ -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`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 = {}; - for (const row of accountBalanceRows) { - totalBalanceBySiteId[row.siteId] = Number(row.totalBalance || 0); + const subscriptionBySiteId: Record = {}; + 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, })); }); diff --git a/src/server/routes/proxy/chat.stream.test.ts b/src/server/routes/proxy/chat.stream.test.ts index 6c61354..0c1a124 100644 --- a/src/server/routes/proxy/chat.stream.test.ts +++ b/src/server/routes/proxy/chat.stream.test.ts @@ -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 = []; }); diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index 9db3b4d..138410c 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -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; diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index b4e4765..a5f8236 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -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; diff --git a/src/server/routes/proxy/endpointFlow.test.ts b/src/server/routes/proxy/endpointFlow.test.ts index 8ae89a3..7513406 100644 --- a/src/server/routes/proxy/endpointFlow.test.ts +++ b/src/server/routes/proxy/endpointFlow.test.ts @@ -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' }, diff --git a/src/server/routes/proxy/endpointFlow.ts b/src/server/routes/proxy/endpointFlow.ts index 03e5e42..bd5b808 100644 --- a/src/server/routes/proxy/endpointFlow.ts +++ b/src/server/routes/proxy/endpointFlow.ts @@ -27,6 +27,14 @@ export type EndpointAttemptContext = { rawErrText: string; }; +export type EndpointAttemptSuccessContext = { + endpointIndex: number; + endpointCount: number; + request: BuiltEndpointRequest; + targetUrl: string; + response: Awaited>; +}; + export type EndpointRecoverResult = { upstream: Awaited>; upstreamPath: string; @@ -61,6 +69,8 @@ type ExecuteEndpointFlowInput = { tryRecover?: (ctx: EndpointAttemptContext) => Promise; shouldDowngrade?: (ctx: EndpointAttemptContext) => boolean; onDowngrade?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise; + onAttemptFailure?: (ctx: EndpointAttemptContext & { errText: string }) => void | Promise; + onAttemptSuccess?: (ctx: EndpointAttemptSuccessContext) => void | Promise; }; 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); diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index 87d4746..73e89c6 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -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'); }); diff --git a/src/server/routes/proxy/images.ts b/src/server/routes/proxy/images.ts index b6243a1..b0d3488 100644 --- a/src/server/routes/proxy/images.ts +++ b/src/server/routes/proxy/images.ts @@ -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; diff --git a/src/server/routes/proxy/search.ts b/src/server/routes/proxy/search.ts index e0fbf4a..23ee34c 100644 --- a/src/server/routes/proxy/search.ts +++ b/src/server/routes/proxy/search.ts @@ -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; diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index e80b526..0d4ce8e 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -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: [ diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index 7173144..87c5b90 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -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>; +}; + 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(); + function isRecord(value: unknown): value is Record { 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(); + 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 { 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(); @@ -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: { }, }; } - - diff --git a/src/server/routes/proxy/videos.ts b/src/server/routes/proxy/videos.ts index a5b1438..3ca359e 100644 --- a/src/server/routes/proxy/videos.ts +++ b/src/server/routes/proxy/videos.ts @@ -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; diff --git a/src/server/services/accountExtraConfig.test.ts b/src/server/services/accountExtraConfig.test.ts index a8a0c48..3189481 100644 --- a/src/server/services/accountExtraConfig.test.ts +++ b/src/server/services/accountExtraConfig.test.ts @@ -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, + }); + }); }); diff --git a/src/server/services/accountExtraConfig.ts b/src/server/services/accountExtraConfig.ts index 11b45d9..28e1ce7 100644 --- a/src/server/services/accountExtraConfig.ts +++ b/src/server/services/accountExtraConfig.ts @@ -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([ @@ -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; + 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; + 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; diff --git a/src/server/services/balanceService.ts b/src/server/services/balanceService.ts index f5806b2..1662580 100644 --- a/src/server/services/balanceService.ts +++ b/src/server/services/balanceService.ts @@ -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); diff --git a/src/server/services/platforms/base.ts b/src/server/services/platforms/base.ts index 1b22dd7..6b87351 100644 --- a/src/server/services/platforms/base.ts +++ b/src/server/services/platforms/base.ts @@ -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 { diff --git a/src/server/services/platforms/sub2api.subscription-summary.test.ts b/src/server/services/platforms/sub2api.subscription-summary.test.ts new file mode 100644 index 0000000..adff143 --- /dev/null +++ b/src/server/services/platforms/sub2api.subscription-summary.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import { Sub2ApiAdapter } from './sub2api.js'; + +class MockSub2ApiAdapter extends Sub2ApiAdapter { + constructor(private readonly responses: Record) { + super(); + } + + protected override async fetchJson(url: string): Promise { + 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, + }, + ], + }); + }); +}); diff --git a/src/server/services/platforms/sub2api.test.ts b/src/server/services/platforms/sub2api.test.ts index 38b16f6..94e07d0 100644 --- a/src/server/services/platforms/sub2api.test.ts +++ b/src/server/services/platforms/sub2api.test.ts @@ -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') { diff --git a/src/server/services/platforms/sub2api.ts b/src/server/services/platforms/sub2api.ts index 23106e7..01315bf 100644 --- a/src/server/services/platforms/sub2api.ts +++ b/src/server/services/platforms/sub2api.ts @@ -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; + const group = item.group && typeof item.group === 'object' && !Array.isArray(item.group) + ? item.group as Record + : 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; + 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 + : {}; + 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 { + const headers = this.buildAuthHeader(accessToken); + const summaryEndpoint = '/api/v1/subscriptions/summary'; + + try { + const res = await this.fetchJson(`${baseUrl}${summaryEndpoint}`, { headers }); + const data = this.parseSub2ApiEnvelope(res, summaryEndpoint); + return this.buildSubscriptionSummary(data); + } catch {} + + const fallbackEndpoints = ['/api/v1/subscriptions/active']; + for (const endpoint of fallbackEndpoints) { + try { + const res = await this.fetchJson(`${baseUrl}${endpoint}`, { headers }); + const data = this.parseSub2ApiEnvelope(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 { - 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, }; } diff --git a/src/server/services/proxyRetryPolicy.test.ts b/src/server/services/proxyRetryPolicy.test.ts index 23e8255..41e026a 100644 --- a/src/server/services/proxyRetryPolicy.test.ts +++ b/src/server/services/proxyRetryPolicy.test.ts @@ -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); }); }); diff --git a/src/server/services/proxyRetryPolicy.ts b/src/server/services/proxyRetryPolicy.ts index f9b0366..190bd99 100644 --- a/src/server/services/proxyRetryPolicy.ts +++ b/src/server/services/proxyRetryPolicy.ts @@ -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; } diff --git a/src/server/services/routeRoutingStrategy.ts b/src/server/services/routeRoutingStrategy.ts index 046674e..e1390dc 100644 --- a/src/server/services/routeRoutingStrategy.ts +++ b/src/server/services/routeRoutingStrategy.ts @@ -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; } diff --git a/src/server/services/tokenRouter.cache.test.ts b/src/server/services/tokenRouter.cache.test.ts index 7e04094..255555e 100644 --- a/src/server/services/tokenRouter.cache.test.ts +++ b/src/server/services/tokenRouter.cache.test.ts @@ -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; }); diff --git a/src/server/services/tokenRouter.selection.test.ts b/src/server/services/tokenRouter.selection.test.ts index 73764a6..dd18a6d 100644 --- a/src/server/services/tokenRouter.selection.test.ts +++ b/src/server/services/tokenRouter.selection.test.ts @@ -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); + }); }); diff --git a/src/server/services/tokenRouter.ts b/src/server/services/tokenRouter.ts index 4b3c5fc..b401f3d 100644 --- a/src/server/services/tokenRouter.ts +++ b/src/server/services/tokenRouter.ts @@ -1,10 +1,10 @@ import { eq, inArray } from 'drizzle-orm'; import { minimatch } from 'minimatch'; import { db, schema } from '../db/index.js'; +import { upsertSetting } from '../db/upsertSetting.js'; import { config } from '../config.js'; import { getCachedModelRoutingReferenceCost, refreshModelPricingCatalog } from './modelPricingService.js'; import { - DEFAULT_ROUTE_ROUTING_STRATEGY, normalizeRouteRoutingStrategy, type RouteRoutingStrategy, } from './routeRoutingStrategy.js'; @@ -39,11 +39,126 @@ type FailureAwareChannel = { lastFailAt?: string | null; }; +type SiteRuntimeFailureContext = { + status?: number | null; + errorText?: string | null; + modelName?: string | null; +}; + +type SiteRuntimeHealthState = { + penaltyScore: number; + latencyEmaMs: number | null; + transientFailureStreak: number; + lastTransientFailureAtMs: number | null; + breakerLevel: number; + breakerUntilMs: number | null; + lastUpdatedAtMs: number; + lastFailureAtMs: number | null; + lastSuccessAtMs: number | null; +}; + const FAILURE_BACKOFF_BASE_SEC = 15; const MIN_EFFECTIVE_UNIT_COST = 1e-6; const ROUND_ROBIN_FAILURE_THRESHOLD = 3; const ROUND_ROBIN_COOLDOWN_LEVELS_SEC = [0, 10 * 60, 60 * 60, 24 * 60 * 60] as const; const MAX_ROUTE_REGEX_BODY_LENGTH = 256; +const SITE_RUNTIME_HEALTH_DECAY_HALF_LIFE_MS = 10 * 60 * 1000; +const SITE_RUNTIME_MIN_MULTIPLIER = 0.08; +const SITE_RUNTIME_LATENCY_BASELINE_MS = 2_500; +const SITE_RUNTIME_LATENCY_WINDOW_MS = 30_000; +const SITE_RUNTIME_MAX_LATENCY_PENALTY = 0.35; +const SITE_RUNTIME_LATENCY_EMA_ALPHA = 0.3; +const SITE_RUNTIME_BREAKER_STREAK_THRESHOLD = 3; +const SITE_RUNTIME_BREAKER_LEVELS_MS = [0, 60_000, 5 * 60_000, 30 * 60 * 1000] as const; +const SITE_TRANSIENT_STREAK_WINDOW_MS = 5 * 60 * 1000; +const SITE_HISTORICAL_HEALTH_MIN_MULTIPLIER = 0.45; +const SITE_HISTORICAL_HEALTH_MAX_SAMPLE = 24; +const SITE_HISTORICAL_LATENCY_BASELINE_MS = 2_000; +const SITE_HISTORICAL_LATENCY_WINDOW_MS = 20_000; +const SITE_HISTORICAL_MAX_LATENCY_PENALTY = 0.18; +const SITE_RUNTIME_HEALTH_SETTING_KEY = 'token_router_site_runtime_health_v1'; +const SITE_RUNTIME_HEALTH_PERSIST_DEBOUNCE_MS = 500; +const SITE_RUNTIME_HEALTH_PERSIST_STALE_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const SITE_RUNTIME_HEALTH_PERSIST_IDLE_TTL_MS = 12 * 60 * 60 * 1000; +const SITE_RUNTIME_HEALTH_PERSIST_MIN_PENALTY = 0.02; + +const SITE_PROTOCOL_FAILURE_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, +]; + +const SITE_MODEL_FAILURE_PATTERNS: RegExp[] = [ + /unsupported\s+model/i, + /model\s+not\s+supported/i, + /does\s+not\s+support(?:\s+the)?\s+model/i, + /no\s+such\s+model/i, + /unknown\s+model/i, + /invalid\s+model/i, + /model.*does\s+not\s+exist/i, + /当前\s*api\s*不支持所选模型/i, + /不支持所选模型/i, +]; + +const SITE_VALIDATION_FAILURE_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, +]; + +const SITE_TRANSIENT_FAILURE_PATTERNS: RegExp[] = [ + /bad\s+gateway/i, + /gateway\s+time-?out/i, + /timed?\s*out/i, + /timeout/i, + /service\s+unavailable/i, + /temporar(?:y|ily)\s+unavailable/i, + /cpu\s+overloaded/i, + /overloaded/i, + /connection\s+reset/i, + /connection\s+refused/i, + /econnreset/i, + /econnrefused/i, +]; + +type SiteRuntimeHealthPersistencePayload = { + version: 1; + savedAtMs: number; + globalBySiteId: Record; + modelBySiteId: Record>; +}; + +type SiteRuntimeHealthDetails = { + globalMultiplier: number; + modelMultiplier: number; + combinedMultiplier: number; + globalBreakerOpen: boolean; + modelBreakerOpen: boolean; + modelKey: string; +}; + +type WeightedSelectionMode = 'weighted' | 'stable_first'; + +const siteRuntimeHealthStates = new Map(); +const siteModelRuntimeHealthStates = new Map>(); +let siteRuntimeHealthLoaded = false; +let siteRuntimeHealthLoadPromise: Promise | null = null; +let siteRuntimeHealthSaveTimer: ReturnType | null = null; +let siteRuntimeHealthPersistInFlight: Promise | null = null; function fibonacciNumber(index: number): number { if (index <= 2) return 1; @@ -66,6 +181,498 @@ function resolveRoundRobinCooldownSec(level: number): number { const normalizedLevel = Math.max(0, Math.min(ROUND_ROBIN_COOLDOWN_LEVELS_SEC.length - 1, Math.trunc(level))); return ROUND_ROBIN_COOLDOWN_LEVELS_SEC[normalizedLevel] ?? 0; } + +function resolveSiteRuntimeBreakerMs(level: number): number { + const normalizedLevel = Math.max(0, Math.min(SITE_RUNTIME_BREAKER_LEVELS_MS.length - 1, Math.trunc(level))); + return SITE_RUNTIME_BREAKER_LEVELS_MS[normalizedLevel] ?? 0; +} + +function matchesAnyPattern(patterns: RegExp[], input?: string | null): boolean { + const text = (input || '').trim(); + if (!text) return false; + return patterns.some((pattern) => pattern.test(text)); +} + +function clampNumber(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function readFiniteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function readFiniteInteger(value: unknown): number | null { + const normalized = readFiniteNumber(value); + return normalized == null ? null : Math.trunc(normalized); +} + +function readNullableTimestamp(value: unknown): number | null { + const normalized = readFiniteInteger(value); + if (normalized == null || normalized <= 0) return null; + return normalized; +} + +function resolveSiteRuntimeFailurePenalty(context: SiteRuntimeFailureContext = {}): number { + const status = typeof context.status === 'number' ? context.status : 0; + const errorText = (context.errorText || '').trim(); + + if (status >= 500 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText)) { + return 2.5; + } + + if (status === 429) { + return 2.2; + } + + if (status === 401 || status === 403) { + return 1.8; + } + + if (matchesAnyPattern(SITE_PROTOCOL_FAILURE_PATTERNS, errorText)) { + return 0.6; + } + + if (matchesAnyPattern(SITE_MODEL_FAILURE_PATTERNS, errorText)) { + return 0.9; + } + + if (matchesAnyPattern(SITE_VALIDATION_FAILURE_PATTERNS, errorText)) { + return 0.25; + } + + if (status >= 400 && status < 500) { + return 0.9; + } + + return 1.2; +} + +function isTransientSiteRuntimeFailure(context: SiteRuntimeFailureContext = {}): boolean { + const status = typeof context.status === 'number' ? context.status : 0; + const errorText = (context.errorText || '').trim(); + return status >= 500 || status === 429 || matchesAnyPattern(SITE_TRANSIENT_FAILURE_PATTERNS, errorText); +} + +function getDecayedSiteRuntimePenalty(state: SiteRuntimeHealthState, nowMs: number): number { + if (!Number.isFinite(state.penaltyScore) || state.penaltyScore <= 0) return 0; + const elapsedMs = Math.max(0, nowMs - state.lastUpdatedAtMs); + if (elapsedMs <= 0) return state.penaltyScore; + const decayFactor = Math.pow(0.5, elapsedMs / SITE_RUNTIME_HEALTH_DECAY_HALF_LIFE_MS); + return state.penaltyScore * decayFactor; +} + +function hydrateSiteRuntimeHealthState(raw: unknown): SiteRuntimeHealthState | null { + if (!isRecord(raw)) return null; + + const lastUpdatedAtMs = readFiniteInteger(raw.lastUpdatedAtMs) ?? Date.now(); + return { + penaltyScore: Math.max(0, readFiniteNumber(raw.penaltyScore) ?? 0), + latencyEmaMs: readFiniteNumber(raw.latencyEmaMs), + transientFailureStreak: Math.max(0, readFiniteInteger(raw.transientFailureStreak) ?? 0), + lastTransientFailureAtMs: readNullableTimestamp(raw.lastTransientFailureAtMs), + breakerLevel: Math.max(0, readFiniteInteger(raw.breakerLevel) ?? 0), + breakerUntilMs: readNullableTimestamp(raw.breakerUntilMs), + lastUpdatedAtMs: Math.max(0, lastUpdatedAtMs), + lastFailureAtMs: readNullableTimestamp(raw.lastFailureAtMs), + lastSuccessAtMs: readNullableTimestamp(raw.lastSuccessAtMs), + }; +} + +function cloneSiteRuntimeHealthState(state: SiteRuntimeHealthState): SiteRuntimeHealthState { + return { + penaltyScore: state.penaltyScore, + latencyEmaMs: state.latencyEmaMs, + transientFailureStreak: state.transientFailureStreak, + lastTransientFailureAtMs: state.lastTransientFailureAtMs, + breakerLevel: state.breakerLevel, + breakerUntilMs: state.breakerUntilMs, + lastUpdatedAtMs: state.lastUpdatedAtMs, + lastFailureAtMs: state.lastFailureAtMs, + lastSuccessAtMs: state.lastSuccessAtMs, + }; +} + +function getOrCreateRuntimeHealthState(states: Map, key: K, nowMs = Date.now()): SiteRuntimeHealthState { + const existing = states.get(key); + if (!existing) { + const initial: SiteRuntimeHealthState = { + penaltyScore: 0, + latencyEmaMs: null, + transientFailureStreak: 0, + lastTransientFailureAtMs: null, + breakerLevel: 0, + breakerUntilMs: null, + lastUpdatedAtMs: nowMs, + lastFailureAtMs: null, + lastSuccessAtMs: null, + }; + states.set(key, initial); + return initial; + } + + const nextPenalty = getDecayedSiteRuntimePenalty(existing, nowMs); + if (nextPenalty !== existing.penaltyScore || existing.lastUpdatedAtMs !== nowMs) { + existing.penaltyScore = nextPenalty; + existing.lastUpdatedAtMs = nowMs; + } + return existing; +} + +function getOrCreateSiteRuntimeHealthState(siteId: number, nowMs = Date.now()): SiteRuntimeHealthState { + return getOrCreateRuntimeHealthState(siteRuntimeHealthStates, siteId, nowMs); +} + +function getSiteModelRuntimeHealthState(siteId: number, modelName?: string | null): SiteRuntimeHealthState | null { + const modelKey = normalizeModelAlias(modelName || ''); + if (!modelKey) return null; + return siteModelRuntimeHealthStates.get(siteId)?.get(modelKey) ?? null; +} + +function getOrCreateSiteModelRuntimeHealthState( + siteId: number, + modelName?: string | null, + nowMs = Date.now(), +): SiteRuntimeHealthState | null { + const modelKey = normalizeModelAlias(modelName || ''); + if (!modelKey) return null; + let modelStates = siteModelRuntimeHealthStates.get(siteId); + if (!modelStates) { + modelStates = new Map(); + siteModelRuntimeHealthStates.set(siteId, modelStates); + } + return getOrCreateRuntimeHealthState(modelStates, modelKey, nowMs); +} + +function isRuntimeHealthBreakerOpen(state: SiteRuntimeHealthState | null | undefined, nowMs = Date.now()): boolean { + if (!state) return false; + return typeof state.breakerUntilMs === 'number' && state.breakerUntilMs > nowMs; +} + +function getRuntimeHealthMultiplier(state: SiteRuntimeHealthState | null | undefined, nowMs = Date.now()): number { + if (!state) return 1; + if (isRuntimeHealthBreakerOpen(state, nowMs)) { + return SITE_RUNTIME_MIN_MULTIPLIER; + } + const penaltyScore = getDecayedSiteRuntimePenalty(state, nowMs); + const failurePenaltyFactor = 1 / (1 + penaltyScore); + const latencyPenaltyRatio = state.latencyEmaMs == null + ? 0 + : clampNumber( + (state.latencyEmaMs - SITE_RUNTIME_LATENCY_BASELINE_MS) / SITE_RUNTIME_LATENCY_WINDOW_MS, + 0, + 1, + ); + const latencyFactor = 1 - (latencyPenaltyRatio * SITE_RUNTIME_MAX_LATENCY_PENALTY); + return clampNumber(failurePenaltyFactor * latencyFactor, SITE_RUNTIME_MIN_MULTIPLIER, 1); +} + +function getSiteRuntimeHealthDetails(siteId: number, modelName?: string | null, nowMs = Date.now()): SiteRuntimeHealthDetails { + const modelKey = normalizeModelAlias(modelName || ''); + const globalState = siteRuntimeHealthStates.get(siteId); + const modelState = modelKey ? getSiteModelRuntimeHealthState(siteId, modelKey) : null; + const globalMultiplier = getRuntimeHealthMultiplier(globalState, nowMs); + const modelMultiplier = modelState ? getRuntimeHealthMultiplier(modelState, nowMs) : 1; + return { + globalMultiplier, + modelMultiplier, + combinedMultiplier: clampNumber( + globalMultiplier * modelMultiplier, + SITE_RUNTIME_MIN_MULTIPLIER * SITE_RUNTIME_MIN_MULTIPLIER, + 1, + ), + globalBreakerOpen: isRuntimeHealthBreakerOpen(globalState, nowMs), + modelBreakerOpen: isRuntimeHealthBreakerOpen(modelState, nowMs), + modelKey, + }; +} + +function applyRuntimeHealthFailure(state: SiteRuntimeHealthState, context: SiteRuntimeFailureContext = {}, nowMs = Date.now()): void { + state.penaltyScore += resolveSiteRuntimeFailurePenalty(context); + if (isTransientSiteRuntimeFailure(context)) { + const lastTransientFailureAtMs = state.lastTransientFailureAtMs; + const shouldContinueStreak = ( + typeof lastTransientFailureAtMs === 'number' + && (nowMs - lastTransientFailureAtMs) <= SITE_TRANSIENT_STREAK_WINDOW_MS + ); + state.transientFailureStreak = shouldContinueStreak + ? state.transientFailureStreak + 1 + : 1; + state.lastTransientFailureAtMs = nowMs; + if (state.transientFailureStreak >= SITE_RUNTIME_BREAKER_STREAK_THRESHOLD) { + state.breakerLevel = Math.min(state.breakerLevel + 1, SITE_RUNTIME_BREAKER_LEVELS_MS.length - 1); + const breakerMs = resolveSiteRuntimeBreakerMs(state.breakerLevel); + state.breakerUntilMs = breakerMs > 0 ? nowMs + breakerMs : null; + state.transientFailureStreak = 0; + } + } else { + state.transientFailureStreak = 0; + state.lastTransientFailureAtMs = null; + } + state.lastFailureAtMs = nowMs; +} + +function applyRuntimeHealthSuccess(state: SiteRuntimeHealthState, latencyMs: number, nowMs = Date.now()): void { + state.penaltyScore = Math.max(0, state.penaltyScore * 0.2 - 0.3); + state.transientFailureStreak = 0; + state.lastTransientFailureAtMs = null; + state.breakerLevel = 0; + state.breakerUntilMs = null; + state.lastSuccessAtMs = nowMs; + const normalizedLatencyMs = Math.max(0, Math.trunc(latencyMs)); + state.latencyEmaMs = state.latencyEmaMs == null + ? normalizedLatencyMs + : (state.latencyEmaMs * (1 - SITE_RUNTIME_LATENCY_EMA_ALPHA)) + + (normalizedLatencyMs * SITE_RUNTIME_LATENCY_EMA_ALPHA); +} + +function shouldPersistSiteRuntimeHealthState(state: SiteRuntimeHealthState, nowMs = Date.now()): boolean { + const lastTouchedAtMs = Math.max( + state.lastUpdatedAtMs, + state.lastFailureAtMs ?? 0, + state.lastSuccessAtMs ?? 0, + state.lastTransientFailureAtMs ?? 0, + ); + if ((nowMs - lastTouchedAtMs) > SITE_RUNTIME_HEALTH_PERSIST_STALE_TTL_MS) { + return false; + } + + if (isRuntimeHealthBreakerOpen(state, nowMs)) return true; + if (getDecayedSiteRuntimePenalty(state, nowMs) >= SITE_RUNTIME_HEALTH_PERSIST_MIN_PENALTY) return true; + if ((state.latencyEmaMs ?? 0) > 0) return true; + return (nowMs - lastTouchedAtMs) <= SITE_RUNTIME_HEALTH_PERSIST_IDLE_TTL_MS; +} + +function buildSiteRuntimeHealthPersistencePayload(nowMs = Date.now()): SiteRuntimeHealthPersistencePayload { + const globalBySiteId: Record = {}; + const modelBySiteId: Record> = {}; + + for (const [siteId, state] of siteRuntimeHealthStates.entries()) { + if (!shouldPersistSiteRuntimeHealthState(state, nowMs)) continue; + globalBySiteId[String(siteId)] = cloneSiteRuntimeHealthState(state); + } + + for (const [siteId, modelStates] of siteModelRuntimeHealthStates.entries()) { + const persistedModels: Record = {}; + for (const [modelKey, state] of modelStates.entries()) { + if (!shouldPersistSiteRuntimeHealthState(state, nowMs)) continue; + persistedModels[modelKey] = cloneSiteRuntimeHealthState(state); + } + if (Object.keys(persistedModels).length > 0) { + modelBySiteId[String(siteId)] = persistedModels; + } + } + + return { + version: 1, + savedAtMs: nowMs, + globalBySiteId, + modelBySiteId, + }; +} + +async function persistSiteRuntimeHealthState(): Promise { + if (siteRuntimeHealthPersistInFlight) { + await siteRuntimeHealthPersistInFlight; + return; + } + const persistTask = (async () => { + const payload = buildSiteRuntimeHealthPersistencePayload(); + await upsertSetting(SITE_RUNTIME_HEALTH_SETTING_KEY, payload); + })(); + siteRuntimeHealthPersistInFlight = persistTask.finally(() => { + if (siteRuntimeHealthPersistInFlight === persistTask) { + siteRuntimeHealthPersistInFlight = null; + } + }); + await siteRuntimeHealthPersistInFlight; +} + +function scheduleSiteRuntimeHealthPersistence(): void { + if (siteRuntimeHealthSaveTimer) return; + siteRuntimeHealthSaveTimer = setTimeout(() => { + siteRuntimeHealthSaveTimer = null; + void persistSiteRuntimeHealthState(); + }, SITE_RUNTIME_HEALTH_PERSIST_DEBOUNCE_MS); +} + +async function loadSiteRuntimeHealthStateFromSettings(): Promise { + siteRuntimeHealthStates.clear(); + siteModelRuntimeHealthStates.clear(); + + const row = await db.select({ value: schema.settings.value }) + .from(schema.settings) + .where(eq(schema.settings.key, SITE_RUNTIME_HEALTH_SETTING_KEY)) + .get(); + if (!row?.value) return; + + let parsed: unknown; + try { + parsed = JSON.parse(row.value); + } catch { + return; + } + if (!isRecord(parsed)) return; + + const globalBySiteId = isRecord(parsed.globalBySiteId) ? parsed.globalBySiteId : {}; + for (const [siteIdKey, stateRaw] of Object.entries(globalBySiteId)) { + const siteId = Number(siteIdKey); + if (!Number.isFinite(siteId) || siteId <= 0) continue; + const state = hydrateSiteRuntimeHealthState(stateRaw); + if (!state) continue; + siteRuntimeHealthStates.set(siteId, state); + } + + const modelBySiteId = isRecord(parsed.modelBySiteId) ? parsed.modelBySiteId : {}; + for (const [siteIdKey, modelStatesRaw] of Object.entries(modelBySiteId)) { + const siteId = Number(siteIdKey); + if (!Number.isFinite(siteId) || siteId <= 0 || !isRecord(modelStatesRaw)) continue; + const hydratedModelStates = new Map(); + for (const [rawModelKey, stateRaw] of Object.entries(modelStatesRaw)) { + const modelKey = normalizeModelAlias(rawModelKey); + if (!modelKey) continue; + const state = hydrateSiteRuntimeHealthState(stateRaw); + if (!state) continue; + hydratedModelStates.set(modelKey, state); + } + if (hydratedModelStates.size > 0) { + siteModelRuntimeHealthStates.set(siteId, hydratedModelStates); + } + } +} + +async function ensureSiteRuntimeHealthStateLoaded(): Promise { + if (siteRuntimeHealthLoaded) return; + if (!siteRuntimeHealthLoadPromise) { + siteRuntimeHealthLoadPromise = (async () => { + try { + await loadSiteRuntimeHealthStateFromSettings(); + } finally { + siteRuntimeHealthLoaded = true; + } + })(); + } + await siteRuntimeHealthLoadPromise; +} + +function recordSiteRuntimeFailure(siteId: number, context: SiteRuntimeFailureContext = {}, nowMs = Date.now()): void { + applyRuntimeHealthFailure(getOrCreateSiteRuntimeHealthState(siteId, nowMs), context, nowMs); + const modelState = getOrCreateSiteModelRuntimeHealthState(siteId, context.modelName, nowMs); + if (modelState) { + applyRuntimeHealthFailure(modelState, context, nowMs); + } + scheduleSiteRuntimeHealthPersistence(); +} + +function recordSiteRuntimeSuccess(siteId: number, latencyMs: number, modelName?: string | null, nowMs = Date.now()): void { + applyRuntimeHealthSuccess(getOrCreateSiteRuntimeHealthState(siteId, nowMs), latencyMs, nowMs); + const modelState = getOrCreateSiteModelRuntimeHealthState(siteId, modelName, nowMs); + if (modelState) { + applyRuntimeHealthSuccess(modelState, latencyMs, nowMs); + } + scheduleSiteRuntimeHealthPersistence(); +} + +export function resetSiteRuntimeHealthState(): void { + siteRuntimeHealthStates.clear(); + siteModelRuntimeHealthStates.clear(); + siteRuntimeHealthLoaded = false; + siteRuntimeHealthLoadPromise = null; + if (siteRuntimeHealthSaveTimer) { + clearTimeout(siteRuntimeHealthSaveTimer); + siteRuntimeHealthSaveTimer = null; + } + siteRuntimeHealthPersistInFlight = null; +} + +export async function flushSiteRuntimeHealthPersistence(): Promise { + if (siteRuntimeHealthSaveTimer) { + clearTimeout(siteRuntimeHealthSaveTimer); + siteRuntimeHealthSaveTimer = null; + await persistSiteRuntimeHealthState(); + return; + } + if (siteRuntimeHealthPersistInFlight) { + await siteRuntimeHealthPersistInFlight; + } +} + +export function getSiteRuntimeHealthMultiplier(siteId: number, nowMs = Date.now()): number { + const state = siteRuntimeHealthStates.get(siteId); + return getRuntimeHealthMultiplier(state, nowMs); +} + +export function isSiteRuntimeBreakerOpen(siteId: number, nowMs = Date.now()): boolean { + const state = siteRuntimeHealthStates.get(siteId); + return isRuntimeHealthBreakerOpen(state, nowMs); +} + +export function filterSiteRuntimeBrokenCandidates( + candidates: T[], + nowMs = Date.now(), +): T[] { + if (candidates.length <= 1) return candidates; + const healthy = candidates.filter((candidate) => !isSiteRuntimeBreakerOpen(candidate.site.id, nowMs)); + return healthy.length > 0 ? healthy : candidates; +} + +function buildRuntimeBreakerReason(details: SiteRuntimeHealthDetails): string { + if (details.globalBreakerOpen && details.modelBreakerOpen) { + return '站点熔断中,模型熔断中,优先避让'; + } + if (details.globalBreakerOpen) { + return '站点熔断中,优先避让'; + } + if (details.modelBreakerOpen) { + return '模型熔断中,优先避让'; + } + return '运行时熔断中,优先避让'; +} + +function filterSiteRuntimeBrokenCandidatesByModel( + candidates: RouteChannelCandidate[], + modelName: string | ((candidate: RouteChannelCandidate) => string), + nowMs = Date.now(), +): { + candidates: RouteChannelCandidate[]; + avoided: Array<{ candidate: RouteChannelCandidate; reason: string }>; +} { + if (candidates.length <= 1) { + return { + candidates, + avoided: [], + }; + } + + const resolveModelName = typeof modelName === 'function' + ? modelName + : (() => modelName); + const avoided: Array<{ candidate: RouteChannelCandidate; reason: string }> = []; + const healthy = candidates.filter((candidate) => { + const details = getSiteRuntimeHealthDetails(candidate.site.id, resolveModelName(candidate), nowMs); + const blocked = details.globalBreakerOpen || details.modelBreakerOpen; + if (blocked) { + avoided.push({ + candidate, + reason: buildRuntimeBreakerReason(details), + }); + } + return !blocked; + }); + + return healthy.length > 0 + ? { + candidates: healthy, + avoided, + } + : { + candidates, + avoided: [], + }; +} type RouteMode = 'pattern' | 'explicit_group'; type RouteRow = typeof schema.tokenRoutes.$inferSelect & { @@ -609,7 +1216,7 @@ function compareNullableTimeAsc(left?: string | null, right?: string | null): nu return leftMs - rightMs; } -function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal { +function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: string): CostSignal { const successCount = Math.max(0, candidate.channel.successCount ?? 0); const totalCost = Math.max(0, candidate.channel.totalCost ?? 0); const configured = candidate.account.unitCost ?? null; @@ -646,6 +1253,86 @@ function resolveEffectiveUnitCost(candidate: RouteChannelCandidate, modelName: s }; } +type SiteHistoricalHealthMetrics = { + multiplier: number; + totalCalls: number; + successRate: number | null; + avgLatencyMs: number | null; +}; + +function buildSiteHistoricalHealthMetrics(candidates: RouteChannelCandidate[]): Map { + const totals = new Map(); + + for (const candidate of candidates) { + const siteId = candidate.site.id; + if (!totals.has(siteId)) { + totals.set(siteId, { + totalCalls: 0, + successCount: 0, + failCount: 0, + totalLatencyMs: 0, + latencySamples: 0, + }); + } + const target = totals.get(siteId)!; + const successCount = Math.max(0, candidate.channel.successCount ?? 0); + const failCount = Math.max(0, candidate.channel.failCount ?? 0); + target.successCount += successCount; + target.failCount += failCount; + target.totalCalls += successCount + failCount; + if (successCount > 0) { + target.totalLatencyMs += Math.max(0, candidate.channel.totalLatencyMs ?? 0); + target.latencySamples += successCount; + } + } + + const metrics = new Map(); + for (const [siteId, total] of totals.entries()) { + if (total.totalCalls <= 0) { + metrics.set(siteId, { + multiplier: 1, + totalCalls: 0, + successRate: null, + avgLatencyMs: null, + }); + continue; + } + + const sampleFactor = clampNumber(total.totalCalls / SITE_HISTORICAL_HEALTH_MAX_SAMPLE, 0, 1); + const successRate = total.successCount / total.totalCalls; + const successPenaltyFactor = 1 - ((1 - successRate) * 0.55 * sampleFactor); + const avgLatencyMs = total.latencySamples > 0 + ? Math.round(total.totalLatencyMs / total.latencySamples) + : null; + const latencyPenaltyRatio = avgLatencyMs == null + ? 0 + : clampNumber( + (avgLatencyMs - SITE_HISTORICAL_LATENCY_BASELINE_MS) / SITE_HISTORICAL_LATENCY_WINDOW_MS, + 0, + 1, + ) * sampleFactor; + const latencyFactor = 1 - (latencyPenaltyRatio * SITE_HISTORICAL_MAX_LATENCY_PENALTY); + metrics.set(siteId, { + multiplier: clampNumber( + successPenaltyFactor * latencyFactor, + SITE_HISTORICAL_HEALTH_MIN_MULTIPLIER, + 1, + ), + totalCalls: total.totalCalls, + successRate, + avgLatencyMs, + }); + } + + return metrics; +} + function isExplicitTokenChannel(candidate: RouteChannelCandidate): boolean { return typeof candidate.channel.tokenId === 'number' && candidate.channel.tokenId > 0; } @@ -657,6 +1344,7 @@ export class TokenRouter { */ async selectChannel(requestedModel: string, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise { if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRoute(requestedModel, downstreamPolicy); if (!match) return null; @@ -668,6 +1356,7 @@ export class TokenRouter { downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRoute(requestedModel, downstreamPolicy); if (!match) return null; @@ -683,6 +1372,7 @@ export class TokenRouter { downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { if (!isModelAllowedByDownstreamPolicy(requestedModel, downstreamPolicy)) return null; + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRoute(requestedModel, downstreamPolicy); if (!match) return null; @@ -694,6 +1384,7 @@ export class TokenRouter { excludeChannelIds: number[] = [], downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRoute(requestedModel, downstreamPolicy); return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy }); } @@ -704,11 +1395,13 @@ export class TokenRouter { excludeChannelIds: number[] = [], downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY, ): Promise { + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRouteById(routeId, downstreamPolicy); return this.explainSelectionFromMatch(match, requestedModel, { excludeChannelIds, downstreamPolicy }); } async explainSelectionRouteWide(routeId: number, downstreamPolicy: DownstreamRoutingPolicy = DEFAULT_DOWNSTREAM_POLICY): Promise { + await ensureSiteRuntimeHealthStateLoaded(); const match = await this.findRouteById(routeId, downstreamPolicy); const fallbackRequestedModel = match?.route.modelPattern || `route:${routeId}`; return this.explainSelectionFromMatch(match, fallbackRequestedModel, { @@ -773,12 +1466,17 @@ export class TokenRouter { const useChannelSourceModelForCost = (options.useChannelSourceModelForCost ?? false) || requestedByDisplayName; const mappedModel = resolveMappedModel(requestedModel, match.route.modelMapping); const routeStrategy = resolveRouteStrategy(match.route); + const runtimeModelResolver = requestedByDisplayName + ? ((candidate: RouteChannelCandidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) + : mappedModel; const nowIso = new Date().toISOString(); const nowMs = Date.now(); const summary: string[] = [ `命中路由:${match.route.modelPattern}`, - routeStrategy === 'round_robin' ? '路由策略:轮询' : '路由策略:按权重随机', + routeStrategy === 'round_robin' + ? '路由策略:轮询' + : (routeStrategy === 'stable_first' ? '路由策略:稳定优先' : '路由策略:按权重随机'), ]; if (requestedByDisplayName) { summary.push(`按显示名命中:${normalizeRouteDisplayName(match.route.displayName)}`); @@ -796,7 +1494,7 @@ export class TokenRouter { nowIso, }); - const recentlyFailed = routeStrategy === DEFAULT_ROUTE_ROUTING_STRATEGY + const recentlyFailed = routeStrategy !== 'round_robin' ? isChannelRecentlyFailed(row.channel, nowMs) : false; const eligible = reasonParts.length === 0; @@ -838,10 +1536,23 @@ export class TokenRouter { } if (routeStrategy === 'round_robin') { - const ordered = this.getRoundRobinCandidates(match.channels.filter((row) => { + const rawOrdered = this.getRoundRobinCandidates(match.channels.filter((row) => { const target = candidateMap.get(row.channel.id); return !!target?.eligible; })); + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(rawOrdered, runtimeModelResolver, nowMs); + if (breakerFiltered.avoided.length > 0) { + for (const item of breakerFiltered.avoided) { + const target = candidateMap.get(item.candidate.channel.id); + if (!target) continue; + target.reason = item.reason; + } + const breakerSummaryLabel = breakerFiltered.avoided.some((item) => item.reason.includes('模型熔断')) + ? '运行时熔断避让' + : '站点熔断避让'; + summary.push(`${breakerSummaryLabel} ${breakerFiltered.avoided.length}`); + } + const ordered = breakerFiltered.candidates; let selected: RouteChannelCandidate | null = null; for (let index = 0; index < ordered.length; index += 1) { @@ -907,8 +1618,17 @@ export class TokenRouter { const rawLayer = availableByPriority.get(priority) ?? []; if (rawLayer.length === 0) continue; - const filteredLayer = filterRecentlyFailedCandidates(rawLayer, nowMs); - const avoided = rawLayer.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(rawLayer, runtimeModelResolver, nowMs); + if (breakerFiltered.avoided.length > 0) { + for (const item of breakerFiltered.avoided) { + const target = candidateMap.get(item.candidate.channel.id); + if (!target) continue; + target.reason = item.reason; + } + } + + const filteredLayer = filterRecentlyFailedCandidates(breakerFiltered.candidates, nowMs); + const avoided = breakerFiltered.candidates.filter((row) => !filteredLayer.some((item) => item.channel.id === row.channel.id)); if (avoided.length > 0) { for (const row of avoided) { const target = candidateMap.get(row.channel.id); @@ -920,10 +1640,10 @@ export class TokenRouter { const weighted = this.calculateWeightedSelection( filteredLayer, - useChannelSourceModelForCost - ? (candidate) => (normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) - : mappedModel, + useChannelSourceModelForCost ? runtimeModelResolver : mappedModel, downstreamPolicy, + nowMs, + routeStrategy === 'stable_first' ? 'stable_first' : 'weighted', ); for (const detail of weighted.details) { const target = candidateMap.get(detail.candidate.channel.id); @@ -937,11 +1657,17 @@ export class TokenRouter { if (!weighted.selected) continue; selected = weighted.selected; selectedPriority = priority; - summary.push( - avoided.length > 0 - ? `优先级 P${priority}:可用 ${rawLayer.length},因最近失败避让 ${avoided.length}` - : `优先级 P${priority}:可用 ${rawLayer.length}`, - ); + const layerSummaryParts = [`优先级 P${priority}:可用 ${rawLayer.length}`]; + if (breakerFiltered.avoided.length > 0) { + const breakerSummaryLabel = breakerFiltered.avoided.some((item) => item.reason.includes('模型熔断')) + ? '运行时熔断避让' + : '站点熔断避让'; + layerSummaryParts.push(`${breakerSummaryLabel} ${breakerFiltered.avoided.length}`); + } + if (avoided.length > 0) { + layerSummaryParts.push(`最近失败避让 ${avoided.length}`); + } + summary.push(layerSummaryParts.join(',')); break; } @@ -1029,13 +1755,20 @@ export class TokenRouter { /** * Record success for a channel. */ - async recordSuccess(channelId: number, latencyMs: number, cost: number) { - const ch = await db.select().from(schema.routeChannels).where(eq(schema.routeChannels.id, channelId)).get(); - if (!ch) return; + async recordSuccess(channelId: number, latencyMs: number, cost: number, modelName?: string | null) { + await ensureSiteRuntimeHealthStateLoaded(); + const row = await db.select() + .from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) + .where(eq(schema.routeChannels.id, channelId)) + .get(); + if (!row) return; + const ch = row.route_channels; + const account = row.accounts; const nowIso = new Date().toISOString(); const nextSuccessCount = (ch.successCount ?? 0) + 1; - const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs; - const nextTotalCost = (ch.totalCost ?? 0) + cost; + const nextTotalLatencyMs = (ch.totalLatencyMs ?? 0) + latencyMs; + const nextTotalCost = (ch.totalCost ?? 0) + cost; await db.update(schema.routeChannels).set({ successCount: nextSuccessCount, totalLatencyMs: nextTotalLatencyMs, @@ -1057,24 +1790,32 @@ export class TokenRouter { channel.consecutiveFailCount = 0; channel.cooldownLevel = 0; }); + + recordSiteRuntimeSuccess(account.siteId, latencyMs, modelName); } /** * Record failure and set cooldown. */ - async recordFailure(channelId: number) { + async recordFailure(channelId: number, context: SiteRuntimeFailureContext | string | null = {}) { + await ensureSiteRuntimeHealthStateLoaded(); const row = await db.select() .from(schema.routeChannels) + .innerJoin(schema.accounts, eq(schema.routeChannels.accountId, schema.accounts.id)) .innerJoin(schema.tokenRoutes, eq(schema.routeChannels.routeId, schema.tokenRoutes.id)) .where(eq(schema.routeChannels.id, channelId)) .get(); if (!row) return; const ch = row.route_channels; + const account = row.accounts; const route = row.token_routes; const nowMs = Date.now(); const nowIso = new Date(nowMs).toISOString(); const failCount = (ch.failCount ?? 0) + 1; + const normalizedContext: SiteRuntimeFailureContext = typeof context === 'string' + ? { modelName: context } + : (context ?? {}); const routeStrategy = resolveRouteStrategy(route); let cooldownUntil: string | null = null; let consecutiveFailCount = Math.max(0, ch.consecutiveFailCount ?? 0) + 1; @@ -1109,6 +1850,8 @@ export class TokenRouter { channel.consecutiveFailCount = consecutiveFailCount; channel.cooldownLevel = cooldownLevel; }); + + recordSiteRuntimeFailure(account.siteId, normalizedContext, nowMs); } /** @@ -1135,6 +1878,9 @@ export class TokenRouter { const requestedByDisplayName = isRouteDisplayNameMatch(requestedModel, match.route.displayName); const bypassSourceModelCheck = requestedByDisplayName; const routeStrategy = resolveRouteStrategy(match.route); + const runtimeModelResolver = requestedByDisplayName + ? ((candidate: RouteChannelCandidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel) + : mappedModel; const nowIso = new Date().toISOString(); const nowMs = Date.now(); @@ -1150,7 +1896,8 @@ export class TokenRouter { if (available.length === 0) return null; if (routeStrategy === 'round_robin') { - const selected = this.selectRoundRobinCandidate(available); + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(available, runtimeModelResolver, nowMs); + const selected = this.selectRoundRobinCandidate(breakerFiltered.candidates); if (!selected) return null; const tokenValue = this.resolveChannelTokenValue(selected); @@ -1184,18 +1931,28 @@ export class TokenRouter { const sortedPriorities = Array.from(layers.keys()).sort((a, b) => a - b); for (const priority of sortedPriorities) { const rawLayer = layers.get(priority) ?? []; - const candidates = filterRecentlyFailedCandidates(rawLayer, nowMs); - const selected = this.weightedRandomSelect( + const breakerFiltered = filterSiteRuntimeBrokenCandidatesByModel(rawLayer, runtimeModelResolver, nowMs); + const candidates = filterRecentlyFailedCandidates(breakerFiltered.candidates, nowMs); + const selected = routeStrategy === 'stable_first' + ? this.stableFirstSelect( + candidates, + requestedByDisplayName ? runtimeModelResolver : mappedModel, + downstreamPolicy, + nowMs, + ) + : this.weightedRandomSelect( candidates, - requestedByDisplayName - ? (candidate) => normalizeChannelSourceModel(candidate.channel.sourceModel) || mappedModel - : mappedModel, + requestedByDisplayName ? runtimeModelResolver : mappedModel, downstreamPolicy, + nowMs, ); if (!selected) continue; const tokenValue = this.resolveChannelTokenValue(selected); if (!tokenValue) continue; + if (routeStrategy === 'stable_first' && recordSelection) { + await this.recordChannelSelection(selected.channel.id); + } const actualModel = resolveActualModelForSelectedChannel( requestedModel, @@ -1341,6 +2098,19 @@ export class TokenRouter { return this.getRoundRobinCandidates(candidates)[0] ?? null; } + private compareStableFirstCandidates(left: RouteChannelCandidate, right: RouteChannelCandidate): number { + const selectionOrder = compareNullableTimeAsc( + left.channel.lastSelectedAt || left.channel.lastUsedAt, + right.channel.lastSelectedAt || right.channel.lastUsedAt, + ); + if (selectionOrder !== 0) return selectionOrder; + + const usedOrder = compareNullableTimeAsc(left.channel.lastUsedAt, right.channel.lastUsedAt); + if (usedOrder !== 0) return usedOrder; + + return (left.channel.id ?? 0) - (right.channel.id ?? 0); + } + private async recordChannelSelection(channelId: number): Promise { const nowIso = new Date().toISOString(); await db.update(schema.routeChannels).set({ @@ -1356,43 +2126,58 @@ export class TokenRouter { candidates: RouteChannelCandidate[], modelName: string | ((candidate: RouteChannelCandidate) => string), downstreamPolicy: DownstreamRoutingPolicy, - ) { - return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy).selected; - } - - private calculateWeightedSelection( - candidates: RouteChannelCandidate[], - modelName: string | ((candidate: RouteChannelCandidate) => string), - downstreamPolicy: DownstreamRoutingPolicy, - ) { - if (candidates.length === 0) { - return { - selected: null as RouteChannelCandidate | null, - details: [] as Array<{ candidate: RouteChannelCandidate; probability: number; reason: string }>, + nowMs = Date.now(), + ) { + return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy, nowMs, 'weighted').selected; + } + + private stableFirstSelect( + candidates: RouteChannelCandidate[], + modelName: string | ((candidate: RouteChannelCandidate) => string), + downstreamPolicy: DownstreamRoutingPolicy, + nowMs = Date.now(), + ) { + return this.calculateWeightedSelection(candidates, modelName, downstreamPolicy, nowMs, 'stable_first').selected; + } + + private calculateWeightedSelection( + candidates: RouteChannelCandidate[], + modelName: string | ((candidate: RouteChannelCandidate) => string), + downstreamPolicy: DownstreamRoutingPolicy, + nowMs = Date.now(), + selectionMode: WeightedSelectionMode = 'weighted', + ) { + if (candidates.length === 0) { + return { + selected: null as RouteChannelCandidate | null, + details: [] as Array<{ candidate: RouteChannelCandidate; probability: number; reason: string }>, }; } - if (candidates.length === 1) { - return { - selected: candidates[0], - details: [{ - candidate: candidates[0], - probability: 1, - reason: '唯一可用候选', - }], - }; - } + if (candidates.length === 1) { + return { + selected: candidates[0], + details: [{ + candidate: candidates[0], + probability: 1, + reason: selectionMode === 'stable_first' ? '稳定优先(唯一可用候选)' : '唯一可用候选', + }], + }; + } const { baseWeightFactor, valueScoreFactor, costWeight, balanceWeight, usageWeight } = config.routingWeights; - const resolveModelName = typeof modelName === 'function' - ? modelName - : (() => modelName); - const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, resolveModelName(candidate))); - - const valueScores = candidates.map((c, i) => { - const unitCost = effectiveCosts[i]?.unitCost || 1; - const balance = c.account.balance || 0; - const totalUsed = (c.channel.successCount ?? 0) + (c.channel.failCount ?? 0); + const resolveModelName = typeof modelName === 'function' + ? modelName + : (() => modelName); + const effectiveCosts = candidates.map((candidate) => resolveEffectiveUnitCost(candidate, resolveModelName(candidate))); + const runtimeHealthDetails = candidates.map((candidate) => ( + getSiteRuntimeHealthDetails(candidate.site.id, resolveModelName(candidate), nowMs) + )); + + const valueScores = candidates.map((c, i) => { + const unitCost = effectiveCosts[i]?.unitCost || 1; + const balance = c.account.balance || 0; + const totalUsed = (c.channel.successCount ?? 0) + (c.channel.failCount ?? 0); const recentUsage = Math.max(totalUsed, 1); return costWeight * (1 / unitCost) + balanceWeight * balance + usageWeight * (1 / recentUsage); }); @@ -1409,14 +2194,15 @@ export class TokenRouter { // Avoid over-favoring a site that has many tokens/channels for the same route. // Site-level total contribution remains comparable, then split across its channels. - const siteChannelCounts = new Map(); - for (const candidate of candidates) { - siteChannelCounts.set(candidate.site.id, (siteChannelCounts.get(candidate.site.id) || 0) + 1); - } - - const contributions = candidates.map((candidate, i) => { - const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1); - let contribution = baseContributions[i] / siteChannels; + const siteChannelCounts = new Map(); + for (const candidate of candidates) { + siteChannelCounts.set(candidate.site.id, (siteChannelCounts.get(candidate.site.id) || 0) + 1); + } + const siteHistoricalHealthMetrics = buildSiteHistoricalHealthMetrics(candidates); + + const contributions = candidates.map((candidate, i) => { + const siteChannels = Math.max(1, siteChannelCounts.get(candidate.site.id) || 1); + let contribution = baseContributions[i] / siteChannels; const downstreamSiteMultiplier = downstreamPolicy.siteWeightMultipliers[candidate.site.id] ?? 1; const normalizedDownstreamSiteMultiplier = (Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0) @@ -1426,25 +2212,40 @@ export class TokenRouter { (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) ? (candidate.site.globalWeight as number) : 1; - const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; - if (combinedSiteWeight > 0 && Number.isFinite(combinedSiteWeight)) { - contribution *= combinedSiteWeight; - } - - // If upstream price is unknown and we are using fallback unit cost, - // apply an explicit penalty so raising fallback cost meaningfully lowers probability. - if (effectiveCosts[i]?.source === 'fallback') { - contribution *= 1 / Math.max(1, effectiveCosts[i]?.unitCost || 1); + const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; + if (combinedSiteWeight > 0 && Number.isFinite(combinedSiteWeight)) { + contribution *= combinedSiteWeight; + } + + contribution *= runtimeHealthDetails[i]?.combinedMultiplier ?? 1; + contribution *= siteHistoricalHealthMetrics.get(candidate.site.id)?.multiplier ?? 1; + + // If upstream price is unknown and we are using fallback unit cost, + // apply an explicit penalty so raising fallback cost meaningfully lowers probability. + if (effectiveCosts[i]?.source === 'fallback') { + contribution *= 1 / Math.max(1, effectiveCosts[i]?.unitCost || 1); } return contribution; }); - - const totalContribution = contributions.reduce((a, b) => a + b, 0); - const details = candidates.map((candidate, i) => { - const probability = totalContribution > 0 ? contributions[i] / totalContribution : 0; - const weight = candidate.channel.weight ?? 10; - const cost = effectiveCosts[i]; + + const totalContribution = contributions.reduce((a, b) => a + b, 0); + const rankedIndices = candidates.map((_, index) => index) + .sort((leftIndex, rightIndex) => { + const contributionDiff = contributions[rightIndex] - contributions[leftIndex]; + if (Math.abs(contributionDiff) > 1e-9) { + return contributionDiff > 0 ? 1 : -1; + } + return this.compareStableFirstCandidates(candidates[leftIndex], candidates[rightIndex]); + }); + const rankByIndex = new Map(); + rankedIndices.forEach((candidateIndex, rank) => { + rankByIndex.set(candidateIndex, rank + 1); + }); + const details = candidates.map((candidate, i) => { + const probability = totalContribution > 0 ? contributions[i] / totalContribution : 0; + const weight = candidate.channel.weight ?? 10; + const cost = effectiveCosts[i]; const costSourceText = cost?.source === 'observed' ? '实测' : (cost?.source === 'configured' ? '配置' : (cost?.source === 'catalog' ? '目录' : '默认')); @@ -1454,30 +2255,50 @@ export class TokenRouter { (Number.isFinite(downstreamSiteMultiplier) && downstreamSiteMultiplier > 0) ? downstreamSiteMultiplier : 1; - const siteGlobalWeight = - (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) - ? (candidate.site.globalWeight as number) - : 1; - const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; - return { - candidate, - probability, - reason: `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`, - }; - }); - - let rand = Math.random() * totalContribution; - let selected = candidates[candidates.length - 1]; - for (let i = 0; i < candidates.length; i++) { - rand -= contributions[i]; - if (rand <= 0) { - selected = candidates[i]; - break; - } - } - - return { selected, details }; - } + const siteGlobalWeight = + (Number.isFinite(candidate.site.globalWeight) && (candidate.site.globalWeight || 0) > 0) + ? (candidate.site.globalWeight as number) + : 1; + const combinedSiteWeight = siteGlobalWeight * normalizedDownstreamSiteMultiplier; + const siteRuntimeDetail = runtimeHealthDetails[i]; + const siteHistoricalHealth = siteHistoricalHealthMetrics.get(candidate.site.id); + const siteHistoricalMultiplier = siteHistoricalHealth?.multiplier ?? 1; + const historicalSuccessRateText = siteHistoricalHealth?.successRate == null + ? '—' + : `${(siteHistoricalHealth.successRate * 100).toFixed(1)}%`; + const historicalLatencyText = siteHistoricalHealth?.avgLatencyMs == null + ? '—' + : `${siteHistoricalHealth.avgLatencyMs}ms`; + const runtimeHealthText = siteRuntimeDetail.modelKey + ? `${siteRuntimeDetail.combinedMultiplier.toFixed(2)}(站点=${siteRuntimeDetail.globalMultiplier.toFixed(2)},模型=${siteRuntimeDetail.modelMultiplier.toFixed(2)})` + : `${siteRuntimeDetail.globalMultiplier.toFixed(2)}`; + const reasonPrefix = selectionMode === 'stable_first' + ? `稳定优先(综合评分第 ${rankByIndex.get(i) ?? 1} / ${candidates.length}` + : '按权重随机'; + return { + candidate, + probability, + reason: selectionMode === 'stable_first' + ? `${reasonPrefix},W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},运行时健康=${runtimeHealthText},历史健康=${siteHistoricalMultiplier.toFixed(2)}(成功率=${historicalSuccessRateText},均延迟=${historicalLatencyText},样本=${siteHistoricalHealth?.totalCalls ?? 0}),同站点通道=${siteChannels},评分占比≈${(probability * 100).toFixed(1)}%)` + : `按权重随机(W=${weight},成本=${costSourceText}:${(cost?.unitCost || 1).toFixed(6)},站点权重=${siteGlobalWeight.toFixed(2)}x下游倍率=${normalizedDownstreamSiteMultiplier.toFixed(2)}=${combinedSiteWeight.toFixed(2)},运行时健康=${runtimeHealthText},历史健康=${siteHistoricalMultiplier.toFixed(2)}(成功率=${historicalSuccessRateText},均延迟=${historicalLatencyText},样本=${siteHistoricalHealth?.totalCalls ?? 0}),同站点通道=${siteChannels},概率≈${(probability * 100).toFixed(1)}%)`, + }; + }); + + let selected = candidates[rankedIndices[0] ?? 0]; + if (selectionMode === 'weighted') { + let rand = Math.random() * totalContribution; + selected = candidates[candidates.length - 1]; + for (let i = 0; i < candidates.length; i++) { + rand -= contributions[i]; + if (rand <= 0) { + selected = candidates[i]; + break; + } + } + } + + return { selected, details }; + } } export const tokenRouter = new TokenRouter(); diff --git a/src/web/index.css b/src/web/index.css index 37a5dc5..483a601 100644 --- a/src/web/index.css +++ b/src/web/index.css @@ -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; diff --git a/src/web/pages/Sites.tsx b/src/web/pages/Sites.tsx index 03820dc..125d789 100644 --- a/src/web/pages/Sites.tsx +++ b/src/web/pages/Sites.tsx @@ -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 ( +
+ {walletBalanceText} + {subscriptionValue ? ( + <> + / + + {subscriptionValue} + + + ) : null} +
+ ); +} + const platformColors: Record = { 'new-api': 'badge-info', 'one-api': 'badge-success', @@ -873,7 +983,16 @@ export default function Sites() { )} /> - + + )} + /> {isExpanded ? (
@@ -1017,17 +1136,24 @@ export default function Sites() { /> - - {site.name} - +
+ + {site.name} + + {hasConfiguredCustomHeaders(site.customHeaders) ? ( + + 自定义头 + + ) : null} +
{site.externalCheckinUrl ? ( @@ -1048,8 +1174,11 @@ export default function Sites() { ) : null} - - ${(site.totalBalance || 0).toFixed(2)} + + diff --git a/src/web/pages/TokenRoutes.tsx b/src/web/pages/TokenRoutes.tsx index c009335..8949d85 100644 --- a/src/web/pages/TokenRoutes.tsx +++ b/src/web/pages/TokenRoutes.tsx @@ -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([]); @@ -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() { > - +