forked from boos/BoosAPI
feat: implement proxy billing details handling and resolveProxyLogBilling function
This commit is contained in:
@@ -200,7 +200,11 @@ export async function statsRoutes(app: FastifyInstance) {
|
|||||||
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.leftJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
.orderBy(desc(schema.proxyLogs.createdAt))
|
.orderBy(desc(schema.proxyLogs.createdAt))
|
||||||
.limit(limit).offset(offset).all()
|
.limit(limit).offset(offset).all()
|
||||||
), { includeBillingDetails: true });
|
), { includeBillingDetails: true }) as Array<{
|
||||||
|
proxy_logs: Record<string, unknown> & { billingDetails?: string | null };
|
||||||
|
accounts: { username?: string | null } | null;
|
||||||
|
sites: { name?: string | null; url?: string | null } | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
...row.proxy_logs,
|
...row.proxy_logs,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { fetch } from 'undici';
|
|||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
|
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
@@ -38,6 +37,7 @@ import {
|
|||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
const CLAUDE_SSE_EVENT_NAMES = new Set([
|
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,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName,
|
modelName,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
@@ -609,33 +589,13 @@ async function handleChatProxyRequest(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let estimatedCost = await estimateProxyCost({
|
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName,
|
modelName,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
@@ -687,33 +647,13 @@ async function handleChatProxyRequest(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let estimatedCost = await estimateProxyCost({
|
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName,
|
modelName,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, schema } from '../../db/index.js';
|
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 { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
|
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage, pullSseDataEvents } from '../../services/proxyUsageParser.js';
|
||||||
@@ -13,6 +12,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
|
|||||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -160,32 +160,13 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
totalTokens: parsedUsage.totalTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let estimatedCost = await estimateProxyCost({
|
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName: selected.actualModel || requestedModel,
|
modelName: selected.actualModel || requestedModel,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
@@ -213,32 +194,13 @@ export async function completionsProxyRoute(app: FastifyInstance) {
|
|||||||
totalTokens: parsedUsage.totalTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let estimatedCost = await estimateProxyCost({
|
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName: selected.actualModel || requestedModel,
|
modelName: selected.actualModel || requestedModel,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { db, schema } from '../../db/index.js';
|
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 { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
|
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
@@ -13,6 +12,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
|
|||||||
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
import { withExplicitProxyRequestInit } from '../../services/siteProxy.js';
|
||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -108,32 +108,13 @@ export async function embeddingsProxyRoute(app: FastifyInstance) {
|
|||||||
totalTokens: parsedUsage.totalTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let estimatedCost = await estimateProxyCost({
|
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName: selected.actualModel || requestedModel,
|
modelName: selected.actualModel || requestedModel,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import { tokenRouter } from '../../services/tokenRouter.js';
|
|||||||
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
|
||||||
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
import { reportProxyAllFailed, reportTokenExpired } from '../../services/alertService.js';
|
||||||
import { isTokenExpiredError } from '../../services/alertRules.js';
|
import { isTokenExpiredError } from '../../services/alertRules.js';
|
||||||
import { buildProxyBillingDetails, estimateProxyCost } from '../../services/modelPricingService.js';
|
|
||||||
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
import { shouldRetryProxyRequest } from '../../services/proxyRetryPolicy.js';
|
||||||
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
import { resolveProxyUsageWithSelfLogFallback } from '../../services/proxyUsageFallbackService.js';
|
||||||
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
import { mergeProxyUsage, parseProxyUsage } from '../../services/proxyUsageParser.js';
|
||||||
@@ -29,6 +28,7 @@ import { ensureModelAllowedForDownstreamKey, getDownstreamRoutingPolicy, recordD
|
|||||||
import { composeProxyLogMessage } from './logPathMeta.js';
|
import { composeProxyLogMessage } from './logPathMeta.js';
|
||||||
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
import { executeEndpointFlow, withUpstreamPath } from './endpointFlow.js';
|
||||||
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
import { formatUtcSqlDateTime } from '../../services/localTimeService.js';
|
||||||
|
import { resolveProxyLogBilling } from './proxyBilling.js';
|
||||||
|
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 2;
|
||||||
|
|
||||||
@@ -1475,32 +1475,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
totalTokens: parsedUsage.totalTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let estimatedCost = await estimateProxyCost({
|
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName: selected.actualModel || requestedModel,
|
modelName: selected.actualModel || requestedModel,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
logProxy(
|
logProxy(
|
||||||
@@ -1541,32 +1522,13 @@ export async function responsesProxyRoute(app: FastifyInstance) {
|
|||||||
totalTokens: parsedUsage.totalTokens,
|
totalTokens: parsedUsage.totalTokens,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
let estimatedCost = await estimateProxyCost({
|
const { estimatedCost, billingDetails } = await resolveProxyLogBilling({
|
||||||
site: selected.site,
|
site: selected.site,
|
||||||
account: selected.account,
|
account: selected.account,
|
||||||
modelName: selected.actualModel || requestedModel,
|
modelName: selected.actualModel || requestedModel,
|
||||||
promptTokens: resolvedUsage.promptTokens,
|
parsedUsage,
|
||||||
completionTokens: resolvedUsage.completionTokens,
|
resolvedUsage,
|
||||||
totalTokens: resolvedUsage.totalTokens,
|
|
||||||
cacheReadTokens: parsedUsage.cacheReadTokens,
|
|
||||||
cacheCreationTokens: parsedUsage.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: parsedUsage.promptTokensIncludeCache,
|
|
||||||
});
|
});
|
||||||
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);
|
tokenRouter.recordSuccess(selected.channel.id, latency, estimatedCost);
|
||||||
recordDownstreamCostUsage(request, estimatedCost);
|
recordDownstreamCostUsage(request, estimatedCost);
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ interface PricingData {
|
|||||||
groupRatio: Record<string, number>;
|
groupRatio: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProxyBillingPricingOverride {
|
||||||
|
modelRatio: number;
|
||||||
|
completionRatio: number;
|
||||||
|
cacheRatio?: number;
|
||||||
|
cacheCreationRatio?: number;
|
||||||
|
groupRatio?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface PricingCacheEntry {
|
interface PricingCacheEntry {
|
||||||
fetchedAt: number;
|
fetchedAt: number;
|
||||||
ttlMs: number;
|
ttlMs: number;
|
||||||
@@ -68,6 +76,7 @@ interface EstimateProxyCostInput {
|
|||||||
cacheReadTokens?: number;
|
cacheReadTokens?: number;
|
||||||
cacheCreationTokens?: number;
|
cacheCreationTokens?: number;
|
||||||
promptTokensIncludeCache?: boolean | null;
|
promptTokensIncludeCache?: boolean | null;
|
||||||
|
billingPricingOverride?: ProxyBillingPricingOverride | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModelGroupPricing {
|
interface ModelGroupPricing {
|
||||||
@@ -573,6 +582,26 @@ function calculatePerCallPricing(
|
|||||||
return { total: 0 };
|
return { total: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildPricingOverrideModel(
|
||||||
|
modelName: string,
|
||||||
|
pricingOverride: ProxyBillingPricingOverride,
|
||||||
|
): { model: PricingModel; groupRatio: Record<string, number> } {
|
||||||
|
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: {
|
function normalizeUsageBreakdownInput(usage: {
|
||||||
promptTokens: number;
|
promptTokens: number;
|
||||||
completionTokens: number;
|
completionTokens: number;
|
||||||
@@ -744,8 +773,21 @@ export async function estimateProxyCost(input: EstimateProxyCostInput): Promise<
|
|||||||
const promptTokens = toPositiveInt(input.promptTokens);
|
const promptTokens = toPositiveInt(input.promptTokens);
|
||||||
const completionTokens = toPositiveInt(input.completionTokens);
|
const completionTokens = toPositiveInt(input.completionTokens);
|
||||||
const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens));
|
const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens));
|
||||||
|
const usage = {
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
totalTokens,
|
||||||
|
cacheReadTokens: input.cacheReadTokens,
|
||||||
|
cacheCreationTokens: input.cacheCreationTokens,
|
||||||
|
promptTokensIncludeCache: input.promptTokensIncludeCache,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (input.billingPricingOverride) {
|
||||||
|
const pricingOverride = buildPricingOverrideModel(input.modelName, input.billingPricingOverride);
|
||||||
|
return calculateModelUsageCost(pricingOverride.model, usage, pricingOverride.groupRatio);
|
||||||
|
}
|
||||||
|
|
||||||
const pricingData = await getPricingDataCached(input);
|
const pricingData = await getPricingDataCached(input);
|
||||||
if (!pricingData) {
|
if (!pricingData) {
|
||||||
return fallbackTokenCost(totalTokens, input.site.platform);
|
return fallbackTokenCost(totalTokens, input.site.platform);
|
||||||
@@ -756,14 +798,7 @@ export async function estimateProxyCost(input: EstimateProxyCostInput): Promise<
|
|||||||
return fallbackTokenCost(totalTokens, input.site.platform);
|
return fallbackTokenCost(totalTokens, input.site.platform);
|
||||||
}
|
}
|
||||||
|
|
||||||
return calculateModelUsageCost(model, {
|
return calculateModelUsageCost(model, usage, pricingData.groupRatio);
|
||||||
promptTokens,
|
|
||||||
completionTokens,
|
|
||||||
totalTokens,
|
|
||||||
cacheReadTokens: input.cacheReadTokens,
|
|
||||||
cacheCreationTokens: input.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: input.promptTokensIncludeCache,
|
|
||||||
}, pricingData.groupRatio);
|
|
||||||
} catch {
|
} catch {
|
||||||
return fallbackTokenCost(totalTokens, input.site.platform);
|
return fallbackTokenCost(totalTokens, input.site.platform);
|
||||||
}
|
}
|
||||||
@@ -784,22 +819,28 @@ export async function buildProxyBillingDetails(input: EstimateProxyCostInput): P
|
|||||||
const promptTokens = toPositiveInt(input.promptTokens);
|
const promptTokens = toPositiveInt(input.promptTokens);
|
||||||
const completionTokens = toPositiveInt(input.completionTokens);
|
const completionTokens = toPositiveInt(input.completionTokens);
|
||||||
const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens));
|
const totalTokens = toPositiveInt(input.totalTokens || (promptTokens + completionTokens));
|
||||||
|
const usage = {
|
||||||
|
promptTokens,
|
||||||
|
completionTokens,
|
||||||
|
totalTokens,
|
||||||
|
cacheReadTokens: input.cacheReadTokens,
|
||||||
|
cacheCreationTokens: input.cacheCreationTokens,
|
||||||
|
promptTokensIncludeCache: input.promptTokensIncludeCache,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (input.billingPricingOverride) {
|
||||||
|
const pricingOverride = buildPricingOverrideModel(input.modelName, input.billingPricingOverride);
|
||||||
|
return calculateModelUsageBreakdown(pricingOverride.model, usage, pricingOverride.groupRatio);
|
||||||
|
}
|
||||||
|
|
||||||
const pricingData = await getPricingDataCached(input);
|
const pricingData = await getPricingDataCached(input);
|
||||||
if (!pricingData) return null;
|
if (!pricingData) return null;
|
||||||
|
|
||||||
const model = resolveModel(input.modelName, pricingData);
|
const model = resolveModel(input.modelName, pricingData);
|
||||||
if (!model || model.quotaType === 1) return null;
|
if (!model || model.quotaType === 1) return null;
|
||||||
|
|
||||||
return calculateModelUsageBreakdown(model, {
|
return calculateModelUsageBreakdown(model, usage, pricingData.groupRatio);
|
||||||
promptTokens,
|
|
||||||
completionTokens,
|
|
||||||
totalTokens,
|
|
||||||
cacheReadTokens: input.cacheReadTokens,
|
|
||||||
cacheCreationTokens: input.cacheCreationTokens,
|
|
||||||
promptTokensIncludeCache: input.promptTokensIncludeCache,
|
|
||||||
}, pricingData.groupRatio);
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe('proxyUsageFallbackService', () => {
|
|||||||
quota: 3300,
|
quota: 3300,
|
||||||
createdAtMs: 1_700_000_123_000,
|
createdAtMs: 1_700_000_123_000,
|
||||||
requestTimeMs: 1450,
|
requestTimeMs: 1450,
|
||||||
|
billingMeta: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { fetch } from 'undici';
|
import { fetch } from 'undici';
|
||||||
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
import { resolvePlatformUserId } from './accountExtraConfig.js';
|
||||||
|
import {
|
||||||
|
buildNewApiCookieCandidates,
|
||||||
|
fetchJsonWithShieldCookieRetry,
|
||||||
|
} from './platforms/newApiShield.js';
|
||||||
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
import { withExplicitProxyRequestInit } from './siteProxy.js';
|
||||||
|
|
||||||
const SELF_LOG_FETCH_TIMEOUT_MS = 8_000;
|
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 MATCH_MAX_LATENCY_DELTA_MS = 12_000;
|
||||||
const QUOTA_PER_UNIT = 500_000;
|
const QUOTA_PER_UNIT = 500_000;
|
||||||
const SUPPORTED_USAGE_FALLBACK_PLATFORMS = new Set(['done-hub', 'one-hub', 'new-api', 'anyrouter']);
|
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']);
|
const PLATFORM_REQUIRES_USER_HEADER = new Set(['new-api', 'anyrouter']);
|
||||||
|
|
||||||
interface ProxyUsage {
|
interface ProxyUsage {
|
||||||
@@ -45,6 +49,18 @@ interface ProxyUsageFallbackInput {
|
|||||||
interface ProxyUsageFallbackResult extends ProxyUsage {
|
interface ProxyUsageFallbackResult extends ProxyUsage {
|
||||||
recoveredFromSelfLog: boolean;
|
recoveredFromSelfLog: boolean;
|
||||||
estimatedCostFromQuota: number;
|
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 {
|
export interface SelfLogItem {
|
||||||
@@ -56,6 +72,7 @@ export interface SelfLogItem {
|
|||||||
quota: number;
|
quota: number;
|
||||||
createdAtMs: number;
|
createdAtMs: number;
|
||||||
requestTimeMs: number;
|
requestTimeMs: number;
|
||||||
|
billingMeta: SelfLogBillingMeta | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SelfLogMatchInput {
|
interface SelfLogMatchInput {
|
||||||
@@ -80,6 +97,12 @@ function roundCost(value: number): number {
|
|||||||
return Math.round(Math.max(0, value) * 1_000_000) / 1_000_000;
|
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 {
|
function toTimestampMs(value: unknown): number {
|
||||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
if (value > 1_000_000_000_000) return Math.round(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),
|
quota: toPositiveInt(row.quota),
|
||||||
createdAtMs,
|
createdAtMs,
|
||||||
requestTimeMs: toPositiveInt(row.request_time ?? row.requestTime),
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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);
|
}, SELF_LOG_FETCH_TIMEOUT_MS);
|
||||||
|
|
||||||
try {
|
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<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
};
|
};
|
||||||
@@ -235,7 +328,25 @@ async function fetchSelfLogPayload(baseUrl: string, token: string, input: ProxyU
|
|||||||
headers['New-Api-User'] = String(userId);
|
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',
|
method: 'GET',
|
||||||
headers,
|
headers,
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -347,6 +458,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
|||||||
...normalizedUsage,
|
...normalizedUsage,
|
||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
estimatedCostFromQuota: 0,
|
estimatedCostFromQuota: 0,
|
||||||
|
selfLogBillingMeta: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const platform = String(input.site.platform || '').toLowerCase();
|
const platform = String(input.site.platform || '').toLowerCase();
|
||||||
@@ -383,6 +495,7 @@ export async function resolveProxyUsageWithSelfLogFallback(
|
|||||||
totalTokens: resolvedTokens.totalTokens,
|
totalTokens: resolvedTokens.totalTokens,
|
||||||
recoveredFromSelfLog: true,
|
recoveredFromSelfLog: true,
|
||||||
estimatedCostFromQuota: toQuotaCost(matched.quota),
|
estimatedCostFromQuota: toQuotaCost(matched.quota),
|
||||||
|
selfLogBillingMeta: matched.billingMeta,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return fallback;
|
return fallback;
|
||||||
|
|||||||
+13
-12
@@ -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 { api } from '../api.js';
|
||||||
import { useToast } from '../components/Toast.js';
|
import { useToast } from '../components/Toast.js';
|
||||||
import { ModelBadge } from '../components/BrandIcon.js';
|
import { ModelBadge } from '../components/BrandIcon.js';
|
||||||
@@ -266,7 +266,7 @@ export default function ProxyLogs() {
|
|||||||
<th style={{ width: 28 }} />
|
<th style={{ width: 28 }} />
|
||||||
<th>时间</th>
|
<th>时间</th>
|
||||||
<th>模型</th>
|
<th>模型</th>
|
||||||
<th>状态</th>
|
<th>{tr('状态')}</th>
|
||||||
<th style={{ textAlign: 'center' }}>用时</th>
|
<th style={{ textAlign: 'center' }}>用时</th>
|
||||||
<th style={{ textAlign: 'right' }}>输入</th>
|
<th style={{ textAlign: 'right' }}>输入</th>
|
||||||
<th style={{ textAlign: 'right' }}>输出</th>
|
<th style={{ textAlign: 'right' }}>输出</th>
|
||||||
@@ -327,13 +327,13 @@ export default function ProxyLogs() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||||
{log.promptTokens?.toLocaleString() || '—'}
|
{log.promptTokens?.toLocaleString() || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||||
{log.completionTokens?.toLocaleString() || '—'}
|
{log.completionTokens?.toLocaleString() || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>
|
<td style={{ textAlign: 'right', fontSize: 12, fontVariantNumeric: 'tabular-nums', fontWeight: 500 }}>
|
||||||
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '—'}
|
{typeof log.estimatedCost === 'number' ? `$${log.estimatedCost.toFixed(6)}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td style={{ textAlign: 'center' }}>
|
<td style={{ textAlign: 'center' }}>
|
||||||
{log.retryCount > 0 ? (
|
{log.retryCount > 0 ? (
|
||||||
@@ -356,17 +356,17 @@ export default function ProxyLogs() {
|
|||||||
lineHeight: 1.9,
|
lineHeight: 1.9,
|
||||||
color: 'var(--color-text-secondary)',
|
color: 'var(--color-text-secondary)',
|
||||||
}}>
|
}}>
|
||||||
{/* 日志详情 section */}
|
{/* 鏃ュ織璇︽儏 section */}
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
|
<span style={{ fontWeight: 600, color: 'var(--color-warning)', flexShrink: 0 }}>日志详情</span>
|
||||||
<div>
|
<div>
|
||||||
<div>
|
<div>
|
||||||
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
|
请求模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelRequested}</strong>
|
||||||
{log.modelActual && log.modelActual !== log.modelRequested && (
|
{log.modelActual && log.modelActual !== log.modelRequested && (
|
||||||
<> → 实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
|
<>{' -> '}实际模型: <strong style={{ color: 'var(--color-text-primary)' }}>{log.modelActual}</strong></>
|
||||||
)}
|
)}
|
||||||
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
|
,状态: <strong style={{ color: log.status === 'success' ? 'var(--color-success)' : 'var(--color-danger)' }}>{log.status === 'success' ? '成功' : '失败'}</strong>
|
||||||
,用时 <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
|
,用时: <strong style={{ color: latencyColor(log.latencyMs) }}>{formatLatency(log.latencyMs)}</strong>
|
||||||
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
|
,站点: <strong style={{ color: 'var(--color-text-primary)' }}>{log.siteName || '未知站点'}</strong>
|
||||||
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
|
,账号: <strong style={{ color: 'var(--color-text-primary)' }}>{log.username || '未知账号'}</strong>
|
||||||
</div>
|
</div>
|
||||||
@@ -390,7 +390,7 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 计费过程 section */}
|
{/* 璁¤垂杩囩▼ section */}
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
|
<span style={{ fontWeight: 600, color: 'var(--color-info)', flexShrink: 0 }}>计费过程</span>
|
||||||
{billingProcessLines.length > 0 ? (
|
{billingProcessLines.length > 0 ? (
|
||||||
@@ -412,7 +412,7 @@ export default function ProxyLogs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下游请求路径 */}
|
{/* 涓嬫父璇锋眰璺緞 */}
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>下游请求路径</span>
|
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>下游请求路径</span>
|
||||||
{pathMeta.downstreamPath ? (
|
{pathMeta.downstreamPath ? (
|
||||||
@@ -428,7 +428,7 @@ export default function ProxyLogs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 上游请求路径 */}
|
{/* 涓婃父璇锋眰璺緞 */}
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>上游请求路径</span>
|
<span style={{ fontWeight: 600, color: 'var(--color-primary)', flexShrink: 0 }}>上游请求路径</span>
|
||||||
{pathMeta.upstreamPath ? (
|
{pathMeta.upstreamPath ? (
|
||||||
@@ -444,7 +444,7 @@ export default function ProxyLogs() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 错误信息 */}
|
{/* 閿欒淇℃伅 */}
|
||||||
{pathMeta.errorMessage.trim().length > 0 && (
|
{pathMeta.errorMessage.trim().length > 0 && (
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
|
<span style={{ fontWeight: 600, color: 'var(--color-danger)', flexShrink: 0 }}>错误信息</span>
|
||||||
@@ -512,3 +512,4 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user