diff --git a/src/server/routes/api/stats.ts b/src/server/routes/api/stats.ts index 38bb70f..fc3ac24 100644 --- a/src/server/routes/api/stats.ts +++ b/src/server/routes/api/stats.ts @@ -200,7 +200,11 @@ export async function statsRoutes(app: FastifyInstance) { .leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .orderBy(desc(schema.proxyLogs.createdAt)) .limit(limit).offset(offset).all() - ), { includeBillingDetails: true }); + ), { includeBillingDetails: true }) as Array<{ + proxy_logs: Record & { billingDetails?: string | null }; + accounts: { username?: string | null } | null; + sites: { name?: string | null; url?: string | null } | null; + }>; return rows.map((row) => ({ ...row.proxy_logs, diff --git a/src/server/routes/proxy/chat.ts b/src/server/routes/proxy/chat.ts index 298cc45..9f7ec41 100644 --- a/src/server/routes/proxy/chat.ts +++ b/src/server/routes/proxy/chat.ts @@ -5,7 +5,6 @@ import { fetch } from 'undici'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; -import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; @@ -38,6 +37,7 @@ import { import { composeProxyLogMessage } from './logPathMeta.js'; import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from './proxyBilling.js'; const MAX_RETRIES = 2; const CLAUDE_SSE_EVENT_NAMES = new Set([ @@ -429,33 +429,13 @@ async function handleChatProxyRequest( }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); @@ -609,33 +589,13 @@ async function handleChatProxyRequest( }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); @@ -687,33 +647,13 @@ async function handleChatProxyRequest( }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); diff --git a/src/server/routes/proxy/completions.ts b/src/server/routes/proxy/completions.ts index f2e584d..f13a578 100644 --- a/src/server/routes/proxy/completions.ts +++ b/src/server/routes/proxy/completions.ts @@ -1,11 +1,10 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; -import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js'; @@ -13,6 +12,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from './proxyBilling.js'; const MAX_RETRIES = 2; @@ -160,32 +160,13 @@ export async function completionsProxyRoute(app: FastifyInstance) { totalTokens: parsedUsage.totalTokens, }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( @@ -213,32 +194,13 @@ export async function completionsProxyRoute(app: FastifyInstance) { totalTokens: parsedUsage.totalTokens, }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); diff --git a/src/server/routes/proxy/embeddings.ts b/src/server/routes/proxy/embeddings.ts index 6e75f68..59596aa 100644 --- a/src/server/routes/proxy/embeddings.ts +++ b/src/server/routes/proxy/embeddings.ts @@ -1,11 +1,10 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { fetch } from 'undici'; import { db, schema } from '../../db/index.js'; -import { tokenRouter } from '../../services/tokenRouter.js'; +import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; -import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { parseProxyUsage } from '../../services/proxyUsageParser.js'; @@ -13,6 +12,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { withExplicitProxyRequestInit } from '../../services/siteProxy.js'; import { composeProxyLogMessage } from './logPathMeta.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from './proxyBilling.js'; const MAX_RETRIES = 2; @@ -108,32 +108,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) { totalTokens: parsedUsage.totalTokens, }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); diff --git a/src/server/routes/proxy/proxyBilling.test.ts b/src/server/routes/proxy/proxyBilling.test.ts new file mode 100644 index 0000000..5a8759f --- /dev/null +++ b/src/server/routes/proxy/proxyBilling.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { resolveProxyLogBilling } from './proxyBilling.js'; + +describe('resolveProxyLogBilling', () => { + it('uses self-log billing metadata for detail breakdown while preserving quota-derived total cost', async () => { + const result = await resolveProxyLogBilling({ + site: { + id: 1, + url: 'https://anyrouter.top', + platform: 'anyrouter', + }, + account: { + id: 2, + }, + modelName: 'claude-haiku-4-5-20251001', + parsedUsage: { + promptTokens: 146638, + completionTokens: 172, + totalTokens: 146810, + cacheReadTokens: 0, + cacheCreationTokens: 0, + promptTokensIncludeCache: null, + }, + resolvedUsage: { + promptTokens: 146638, + completionTokens: 172, + totalTokens: 146810, + recoveredFromSelfLog: true, + estimatedCostFromQuota: 0.083056, + selfLogBillingMeta: { + modelRatio: 2.5, + completionRatio: 5, + cacheRatio: 0.1, + cacheCreationRatio: 1.25, + groupRatio: 1, + cacheReadTokens: 145692, + cacheCreationTokens: 945, + promptTokensIncludeCache: true, + }, + }, + }); + + expect(result.estimatedCost).toBe(0.083056); + expect(result.billingDetails).toMatchObject({ + usage: { + cacheReadTokens: 145692, + cacheCreationTokens: 945, + billablePromptTokens: 1, + }, + pricing: { + modelRatio: 2.5, + completionRatio: 5, + cacheRatio: 0.1, + cacheCreationRatio: 1.25, + groupRatio: 1, + }, + breakdown: { + inputPerMillion: 5, + outputPerMillion: 25, + cacheReadPerMillion: 0.5, + cacheCreationPerMillion: 6.25, + totalCost: 0.083057, + }, + }); + }); +}); diff --git a/src/server/routes/proxy/proxyBilling.ts b/src/server/routes/proxy/proxyBilling.ts new file mode 100644 index 0000000..b7a02c4 --- /dev/null +++ b/src/server/routes/proxy/proxyBilling.ts @@ -0,0 +1,92 @@ +import { + buildProxyBillingDetails, + estimateProxyCost, + type ProxyBillingDetails, + type ProxyBillingPricingOverride, +} from '../../services/modelPricingService.js'; +import type { SelfLogBillingMeta } from '../../services/proxyUsageFallbackService.js'; + +interface ProxyBillingUsageSummary { + promptTokens: number; + completionTokens: number; + totalTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + promptTokensIncludeCache: boolean | null; +} + +interface ResolvedProxyUsageSummary { + promptTokens: number; + completionTokens: number; + totalTokens: number; + recoveredFromSelfLog: boolean; + estimatedCostFromQuota: number; + selfLogBillingMeta: SelfLogBillingMeta | null; +} + +interface ResolveProxyLogBillingInput { + site: { + id: number; + url: string; + platform: string; + apiKey?: string | null; + }; + account: { + id: number; + accessToken?: string | null; + apiToken?: string | null; + }; + modelName: string; + parsedUsage: ProxyBillingUsageSummary; + resolvedUsage: ResolvedProxyUsageSummary; +} + +function toPricingOverride(meta: SelfLogBillingMeta | null): ProxyBillingPricingOverride | null { + if (!meta) return null; + return { + modelRatio: meta.modelRatio, + completionRatio: meta.completionRatio, + cacheRatio: meta.cacheRatio, + cacheCreationRatio: meta.cacheCreationRatio, + groupRatio: meta.groupRatio, + }; +} + +export async function resolveProxyLogBilling( + input: ResolveProxyLogBillingInput, +): Promise<{ estimatedCost: number; billingDetails: ProxyBillingDetails | null }> { + const selfLogMeta = input.resolvedUsage.selfLogBillingMeta; + const billingPricingOverride = toPricingOverride(selfLogMeta); + const cacheReadTokens = selfLogMeta?.cacheReadTokens ?? input.parsedUsage.cacheReadTokens; + const cacheCreationTokens = selfLogMeta?.cacheCreationTokens ?? input.parsedUsage.cacheCreationTokens; + const promptTokensIncludeCache = selfLogMeta?.promptTokensIncludeCache + ?? input.parsedUsage.promptTokensIncludeCache; + + const billingInput = { + site: input.site, + account: input.account, + modelName: input.modelName, + promptTokens: input.resolvedUsage.promptTokens, + completionTokens: input.resolvedUsage.completionTokens, + totalTokens: input.resolvedUsage.totalTokens, + cacheReadTokens, + cacheCreationTokens, + promptTokensIncludeCache, + billingPricingOverride, + }; + + let estimatedCost = await estimateProxyCost(billingInput); + const billingDetails = await buildProxyBillingDetails(billingInput); + + if ( + input.resolvedUsage.estimatedCostFromQuota > 0 + && (input.resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0) + ) { + estimatedCost = input.resolvedUsage.estimatedCostFromQuota; + } + + return { + estimatedCost, + billingDetails, + }; +} diff --git a/src/server/routes/proxy/responses.ts b/src/server/routes/proxy/responses.ts index 3055e3c..27e6c5d 100644 --- a/src/server/routes/proxy/responses.ts +++ b/src/server/routes/proxy/responses.ts @@ -5,7 +5,6 @@ import { tokenRouter } from '../../services/tokenRouter.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js'; import { isTokenExpiredError } from '../../services/alertRules.js'; -import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js'; import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js'; import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js'; import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js'; @@ -29,6 +28,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD import { composeProxyLogMessage } from './logPathMeta.js'; import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; +import { resolveProxyLogBilling } from './proxyBilling.js'; const MAX_RETRIES = 2; @@ -1475,32 +1475,13 @@ export async function responsesProxyRoute(app: FastifyInstance) { totalTokens: parsedUsage.totalTokens, }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); logProxy( @@ -1541,32 +1522,13 @@ export async function responsesProxyRoute(app: FastifyInstance) { totalTokens: parsedUsage.totalTokens, }, }); - let estimatedCost = await estimateProxyCost({ + const { estimatedCost, billingDetails } = await resolveProxyLogBilling({ site: selected.site, account: selected.account, modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, + parsedUsage, + resolvedUsage, }); - let billingDetails = await buildProxyBillingDetails({ - site: selected.site, - account: selected.account, - modelName: selected.actualModel || requestedModel, - promptTokens: resolvedUsage.promptTokens, - completionTokens: resolvedUsage.completionTokens, - totalTokens: resolvedUsage.totalTokens, - cacheReadTokens: parsedUsage.cacheReadTokens, - cacheCreationTokens: parsedUsage.cacheCreationTokens, - promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache, - }); - if (resolvedUsage.estimatedCostFromQuota > 0 && (resolvedUsage.recoveredFromSelfLog || estimatedCost <= 0)) { - estimatedCost = resolvedUsage.estimatedCostFromQuota; - billingDetails = null; - } tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost); recordDownstreamCostUsage(request, estimatedCost); diff --git a/src/server/services/modelPricingService.ts b/src/server/services/modelPricingService.ts index 9dcc506..a9a83b4 100644 --- a/src/server/services/modelPricingService.ts +++ b/src/server/services/modelPricingService.ts @@ -37,6 +37,14 @@ interface PricingData { groupRatio: Record; } +export interface ProxyBillingPricingOverride { + modelRatio: number; + completionRatio: number; + cacheRatio?: number; + cacheCreationRatio?: number; + groupRatio?: number; +} + interface PricingCacheEntry { fetchedAt: number; ttlMs: number; @@ -68,6 +76,7 @@ interface EstimateProxyCostInput { cacheReadTokens?: number; cacheCreationTokens?: number; promptTokensIncludeCache?: boolean | null; + billingPricingOverride?: ProxyBillingPricingOverride | null; } interface ModelGroupPricing { @@ -573,6 +582,26 @@ function calculatePerCallPricing( return { total: 0 }; } +function buildPricingOverrideModel( + modelName: string, + pricingOverride: ProxyBillingPricingOverride, +): { model: PricingModel; groupRatio: Record } { + const groupRatio = normalizeRatio(pricingOverride.groupRatio, 1); + return { + model: { + modelName, + quotaType: 0, + modelRatio: normalizeRatio(pricingOverride.modelRatio, 1), + completionRatio: normalizeRatio(pricingOverride.completionRatio, 1), + cacheRatio: normalizeRatio(pricingOverride.cacheRatio, 1), + cacheCreationRatio: normalizeRatio(pricingOverride.cacheCreationRatio, 1), + modelPrice: null, + enableGroups: [DEFAULT_GROUP], + }, + groupRatio: { [DEFAULT_GROUP]: groupRatio }, + }; +} + function normalizeUsageBreakdownInput(usage: { promptTokens: number; completionTokens: number; @@ -744,8 +773,21 @@ export async function estimateProxyCost(input: EstimateProxyCostInput): Promise< const promptTokens = toPositiveInt(input.promptTokens); const completionTokens = toPositiveInt(input.completionTokens); const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens)); + const usage = { + promptTokens, + completionTokens, + totalTokens, + cacheReadTokens: input.cacheReadTokens, + cacheCreationTokens: input.cacheCreationTokens, + promptTokensIncludeCache: input.promptTokensIncludeCache, + }; try { + if (input.billingPricingOverride) { + const pricingOverride = buildPricingOverrideModel(input.modelName, input.billingPricingOverride); + return calculateModelUsageCost(pricingOverride.model, usage, pricingOverride.groupRatio); + } + const pricingData = await getPricingDataCached(input); if (!pricingData) { return fallbackTokenCost(totalTokens, input.site.platform); @@ -756,14 +798,7 @@ export async function estimateProxyCost(input: EstimateProxyCostInput): Promise< return fallbackTokenCost(totalTokens, input.site.platform); } - return calculateModelUsageCost(model, { - promptTokens, - completionTokens, - totalTokens, - cacheReadTokens: input.cacheReadTokens, - cacheCreationTokens: input.cacheCreationTokens, - promptTokensIncludeCache: input.promptTokensIncludeCache, - }, pricingData.groupRatio); + return calculateModelUsageCost(model, usage, pricingData.groupRatio); } catch { return fallbackTokenCost(totalTokens, input.site.platform); } @@ -784,22 +819,28 @@ export async function buildProxyBillingDetails(input: EstimateProxyCostInput): P const promptTokens = toPositiveInt(input.promptTokens); const completionTokens = toPositiveInt(input.completionTokens); const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens)); + const usage = { + promptTokens, + completionTokens, + totalTokens, + cacheReadTokens: input.cacheReadTokens, + cacheCreationTokens: input.cacheCreationTokens, + promptTokensIncludeCache: input.promptTokensIncludeCache, + }; try { + if (input.billingPricingOverride) { + const pricingOverride = buildPricingOverrideModel(input.modelName, input.billingPricingOverride); + return calculateModelUsageBreakdown(pricingOverride.model, usage, pricingOverride.groupRatio); + } + const pricingData = await getPricingDataCached(input); if (!pricingData) return null; const model = resolveModel(input.modelName, pricingData); if (!model || model.quotaType === 1) return null; - return calculateModelUsageBreakdown(model, { - promptTokens, - completionTokens, - totalTokens, - cacheReadTokens: input.cacheReadTokens, - cacheCreationTokens: input.cacheCreationTokens, - promptTokensIncludeCache: input.promptTokensIncludeCache, - }, pricingData.groupRatio); + return calculateModelUsageBreakdown(model, usage, pricingData.groupRatio); } catch { return null; } diff --git a/src/server/services/proxyUsageFallbackService.test.ts b/src/server/services/proxyUsageFallbackService.test.ts index 9bce979..524ddab 100644 --- a/src/server/services/proxyUsageFallbackService.test.ts +++ b/src/server/services/proxyUsageFallbackService.test.ts @@ -35,6 +35,7 @@ describe('proxyUsageFallbackService', () => { quota: 3300, createdAtMs: 1_700_000_123_000, requestTimeMs: 1450, + billingMeta: null, }, ]); }); diff --git a/src/server/services/proxyUsageFallbackService.ts b/src/server/services/proxyUsageFallbackService.ts index 356b23b..c180964 100644 --- a/src/server/services/proxyUsageFallbackService.ts +++ b/src/server/services/proxyUsageFallbackService.ts @@ -1,5 +1,9 @@ import { fetch } from 'undici'; import { resolvePlatformUserId } from './accountExtraConfig.js'; +import { + buildNewApiCookieCandidates, + fetchJsonWithShieldCookieRetry, +} from './platforms/newApiShield.js'; import { withExplicitProxyRequestInit } from './siteProxy.js'; const SELF_LOG_FETCH_TIMEOUT_MS = 8_000; @@ -10,7 +14,7 @@ const MATCH_MAX_CREATED_DELTA_MS = 90_000; const MATCH_MAX_LATENCY_DELTA_MS = 12_000; const QUOTA_PER_UNIT = 500_000; const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', 'anyrouter']); -const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub']); +const ALWAYS_LOOKUP_SELF_LOG_PLATFORMS = new Set(['done-hub', 'one-hub', 'anyrouter']); const PLATFORM_REQUIRES_USER_HEADER = new Set(['new-api', 'anyrouter']); interface ProxyUsage { @@ -45,6 +49,18 @@ interface ProxyUsageFallbackInput { interface ProxyUsageFallbackResult extends ProxyUsage { recoveredFromSelfLog: boolean; estimatedCostFromQuota: number; + selfLogBillingMeta: SelfLogBillingMeta | null; +} + +export interface SelfLogBillingMeta { + modelRatio: number; + completionRatio: number; + cacheRatio: number; + cacheCreationRatio: number; + groupRatio: number; + cacheReadTokens: number; + cacheCreationTokens: number; + promptTokensIncludeCache: boolean; } export interface SelfLogItem { @@ -56,6 +72,7 @@ export interface SelfLogItem { quota: number; createdAtMs: number; requestTimeMs: number; + billingMeta: SelfLogBillingMeta | null; } interface SelfLogMatchInput { @@ -80,6 +97,12 @@ function roundCost(value: number): number { return Math.round(Math.max(0, value) * 1_000_000) / 1_000_000; } +function normalizePositiveRatio(value: unknown, fallback: number): number { + const ratio = toNumber(value, Number.NaN); + if (Number.isFinite(ratio) && ratio >= 0) return ratio; + return fallback; +} + function toTimestampMs(value: unknown): number { if (typeof value === 'number' && Number.isFinite(value)) { if (value > 1_000_000_000_000) return Math.round(value); @@ -196,6 +219,72 @@ function mapSelfLogItem(raw: unknown): SelfLogItem | null { quota: toPositiveInt(row.quota), createdAtMs, requestTimeMs: toPositiveInt(row.request_time ?? row.requestTime), + billingMeta: parseSelfLogBillingMeta(row.other), + }; +} + +function parseSelfLogBillingMeta(rawOther: unknown): SelfLogBillingMeta | null { + let payload = rawOther; + if (typeof rawOther === 'string') { + const trimmed = rawOther.trim(); + if (!trimmed) return null; + try { + payload = JSON.parse(trimmed); + } catch { + return null; + } + } + + if (!payload || typeof payload !== 'object') return null; + const other = payload as Record; + + const modelRatio = normalizePositiveRatio(other.model_ratio ?? other.modelRatio, 1); + const completionRatio = normalizePositiveRatio( + other.completion_ratio ?? other.completionRatio, + 1, + ); + const cacheRatio = normalizePositiveRatio( + other.cache_ratio ?? other.cacheRatio, + 1, + ); + const cacheCreationRatio = normalizePositiveRatio( + other.cache_creation_ratio + ?? other.cacheCreationRatio + ?? other.create_cache_ratio + ?? other.createCacheRatio, + 1, + ); + const groupRatio = normalizePositiveRatio(other.group_ratio ?? other.groupRatio, 1); + const cacheReadTokens = toPositiveInt( + other.cache_tokens ?? other.cacheTokens ?? other.cache_read_tokens ?? other.cacheReadTokens, + ); + const cacheCreationTokens = toPositiveInt( + other.cache_creation_tokens + ?? other.cacheCreationTokens + ?? other.create_cache_tokens + ?? other.createCacheTokens, + ); + + const hasMeaningfulData = ( + cacheReadTokens > 0 + || cacheCreationTokens > 0 + || modelRatio !== 1 + || completionRatio !== 1 + || cacheRatio !== 1 + || cacheCreationRatio !== 1 + || groupRatio !== 1 + ); + if (!hasMeaningfulData) return null; + + return { + modelRatio, + completionRatio, + cacheRatio, + cacheCreationRatio, + groupRatio, + cacheReadTokens, + cacheCreationTokens, + promptTokensIncludeCache: true, }; } @@ -224,7 +313,11 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU }, SELF_LOG_FETCH_TIMEOUT_MS); try { - const query = `p=0&page=1&size=${SELF_LOG_PAGE_SIZE}&order=-created_at`; + const pageSizeParam = String(input.site.platform || '').toLowerCase() === 'anyrouter' + ? 'page_size' + : 'size'; + const query = `p=0&page=1&${pageSizeParam}=${SELF_LOG_PAGE_SIZE}&order=-created_at`; + const url = `${baseUrl}/api/log/self?${query}`; const headers: Record = { Authorization: `Bearer ${token}`, }; @@ -235,7 +328,25 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU headers['New-Api-User'] = String(userId); } } - const response = await fetch(`${baseUrl}/api/log/self?${query}`, withExplicitProxyRequestInit(input.site.proxyUrl, { + + const shouldTryShieldCookie = platform === 'anyrouter' || token.includes('='); + if (shouldTryShieldCookie) { + for (const cookie of buildNewApiCookieCandidates(token)) { + const result = await fetchJsonWithShieldCookieRetry(url, { + method: 'GET', + headers: { + ...Object.fromEntries( + Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'authorization'), + ), + Cookie: cookie, + }, + signal: controller.signal, + }); + if (result.data) return result.data; + } + } + + const response = await fetch(url, withExplicitProxyRequestInit(input.site.proxyUrl, { method: 'GET', headers, signal: controller.signal, @@ -347,6 +458,7 @@ export async function resolveProxyUsageWithSelfLogFallback( ...normalizedUsage, recoveredFromSelfLog: false, estimatedCostFromQuota: 0, + selfLogBillingMeta: null, }; const platform = String(input.site.platform || '').toLowerCase(); @@ -383,6 +495,7 @@ export async function resolveProxyUsageWithSelfLogFallback( totalTokens: resolvedTokens.totalTokens, recoveredFromSelfLog: true, estimatedCostFromQuota: toQuotaCost(matched.quota), + selfLogBillingMeta: matched.billingMeta, }; } catch { return fallback; diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 37956cf..dac1f4f 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { api } from '../api.js'; import { useToast } from '../components/Toast.js'; import { ModelBadge } from '../components/BrandIcon.js'; @@ -266,7 +266,7 @@ export default function ProxyLogs() { 时间 模型 - 状态 + {tr('状态')} 用时 输入 输出 @@ -327,13 +327,13 @@ export default function ProxyLogs() { - {log.promptTokens?.toLocaleString() || '—'} + {log.promptTokens?.toLocaleString() || '-'} - {log.completionTokens?.toLocaleString() || '—'} + {log.completionTokens?.toLocaleString() || '-'} - {typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '—'} + {typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'} {log.retryCount > 0 ? ( @@ -356,17 +356,17 @@ export default function ProxyLogs() { lineHeight: 1.9, color: 'var(--color-text-secondary)', }}> - {/* 日志详情 section */} + {/* 鏃ュ織璇︽儏 section */}
日志详情
请求模型: {log.modelRequested} {log.modelActual && log.modelActual !== log.modelRequested && ( - <> → 实际模型: {log.modelActual} + <>{' -> '}实际模型: {log.modelActual} )} ,状态: {log.status === 'success' ? '成功' : '失败'} - ,用时 {formatLatency(log.latencyMs)} + ,用时: {formatLatency(log.latencyMs)} ,站点: {log.siteName || '未知站点'} ,账号: {log.username || '未知账号'}
@@ -390,7 +390,7 @@ export default function ProxyLogs() {
)} - {/* 计费过程 section */} + {/* 璁¤垂杩囩▼ section */}
计费过程 {billingProcessLines.length > 0 ? ( @@ -412,7 +412,7 @@ export default function ProxyLogs() { )}
- {/* 下游请求路径 */} + {/* 涓嬫父璇锋眰璺緞 */}
下游请求路径 {pathMeta.downstreamPath ? ( @@ -428,7 +428,7 @@ export default function ProxyLogs() { )}
- {/* 上游请求路径 */} + {/* 涓婃父璇锋眰璺緞 */}
上游请求路径 {pathMeta.upstreamPath ? ( @@ -444,7 +444,7 @@ export default function ProxyLogs() { )}
- {/* 错误信息 */} + {/* 閿欒淇℃伅 */} {pathMeta.errorMessage.trim().length > 0 && (
错误信息 @@ -512,3 +512,4 @@ export default function ProxyLogs() {
); } +