From bdb4825cdcecd4e7a8b7cee4743c13c349cd002c Mon Sep 17 00:00:00 2001 From: dreamhunter2333 Date: Fri, 30 Jan 2026 21:06:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=20Codex=20=E7=BC=93?= =?UTF-8?q?=E5=AD=98=20token=20=E8=AE=A1=E8=B4=B9=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E4=BB=A3=E7=A0=81=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复 Codex API 缓存 token 重复计费问题 (input_tokens 包含 cached_tokens) - 提取共享代码到 src/providers/shared/usage-utils.ts,消除重复 - 前端定价页面增加缓存倍率配置列 - 修复 var 为 const,favicon 改为云图标 Co-Authored-By: Claude Opus 4.5 --- frontend/index.html | 2 +- frontend/src/pages/Pricing.tsx | 27 +++-- frontend/src/types/index.ts | 1 + src/admin/token_utils.ts | 17 ++- src/providers/azure-openai-responses-proxy.ts | 93 +--------------- src/providers/openai-responses-proxy.ts | 93 +--------------- src/providers/shared/usage-utils.ts | 103 ++++++++++++++++++ type.d.ts | 2 + 8 files changed, 150 insertions(+), 188 deletions(-) create mode 100644 src/providers/shared/usage-utils.ts diff --git a/frontend/index.html b/frontend/index.html index 72cba87..1ff0aaa 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,7 +2,7 @@ - + Awsl One API - 管理控制台 diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx index f9819bc..d7a3821 100644 --- a/frontend/src/pages/Pricing.tsx +++ b/frontend/src/pages/Pricing.tsx @@ -27,7 +27,7 @@ export function Pricing() { const [editMode, setEditMode] = useState('table') const [jsonValue, setJsonValue] = useState('') const [pricingRows, setPricingRows] = useState< - Array<{ model: string; input: number; output: number }> + Array<{ model: string; input: number; output: number; cache: number }> >([]) const [searchQuery, setSearchQuery] = useState('') @@ -48,6 +48,7 @@ export function Pricing() { model, input: pricing.input, output: pricing.output, + cache: pricing.cache || 0, })) setPricingRows(rows) setJsonValue(JSON.stringify(data, null, 2)) @@ -82,6 +83,7 @@ export function Pricing() { config[row.model] = { input: row.input || 0, output: row.output || 0, + cache: row.cache || 0, } } }) @@ -105,6 +107,7 @@ export function Pricing() { config[row.model] = { input: row.input || 0, output: row.output || 0, + cache: row.cache || 0, } } }) @@ -117,6 +120,7 @@ export function Pricing() { model, input: (pricing as { input: number }).input || 0, output: (pricing as { output: number }).output || 0, + cache: (pricing as { cache?: number }).cache || 0, })) setPricingRows(rows) setEditMode('table') @@ -127,14 +131,14 @@ export function Pricing() { } const addRow = () => { - setPricingRows([...pricingRows, { model: '', input: 0, output: 0 }]) + setPricingRows([...pricingRows, { model: '', input: 0, output: 0, cache: 0 }]) } const removeRow = (index: number) => { setPricingRows(pricingRows.filter((_, i) => i !== index)) } - const updateRow = (index: number, field: 'model' | 'input' | 'output', value: string | number) => { + const updateRow = (index: number, field: 'model' | 'input' | 'output' | 'cache', value: string | number) => { const newRows = [...pricingRows] ;(newRows[index] as any)[field] = value setPricingRows(newRows) @@ -211,17 +215,18 @@ export function Pricing() { {/* Table */}
{/* Header */} -
+
模型名称 输入倍率 输出倍率 + 缓存倍率
{/* Rows */}
{filteredRows.map((row) => ( -
+
updateRow(row._i, 'model', e.target.value)} @@ -244,6 +249,14 @@ export function Pricing() { min="0" className="font-mono text-sm h-8" /> + updateRow(row._i, 'cache', parseFloat(e.target.value) || 0)} + step="0.001" + min="0" + className="font-mono text-sm h-8" + />
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index c41ef7c..911caf4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -28,6 +28,7 @@ export interface TokenConfig { export interface PricingModel { input: number output: number + cache?: number } export type PricingConfig = Record diff --git a/src/admin/token_utils.ts b/src/admin/token_utils.ts index a6235b4..941bcab 100644 --- a/src/admin/token_utils.ts +++ b/src/admin/token_utils.ts @@ -31,23 +31,28 @@ export const TokenUtils = { async processUsage(c: Context, apiKey: string, model: string, targetChannelKey: string, targetChannelConfig: ChannelConfig, usage: Usage): Promise { console.log("Usage data:", usage); - // Get pricing and calculate cost const pricing = await this.getPricing(c, model, targetChannelConfig); const hasTokens = usage.prompt_tokens != null && usage.completion_tokens != null; if (pricing && hasTokens) { const inputCost = usage.prompt_tokens! * pricing.input; const outputCost = usage.completion_tokens! * pricing.output; - const totalCost = inputCost + outputCost; - // Update token usage and log data + + let cacheCost = 0; + if (usage.cached_tokens && usage.cached_tokens > 0 && pricing.cache) { + cacheCost = usage.cached_tokens * pricing.cache; + } + + const totalCost = inputCost + outputCost + cacheCost; + await this.updateUsage(c, apiKey, totalCost); - // 长度不够直接显示 * - var maskedApiKey = apiKey.length < 3 ? '*'.repeat(apiKey.length) : ( + + const maskedApiKey = apiKey.length < 3 ? '*'.repeat(apiKey.length) : ( apiKey.slice(0, apiKey.length / 3) + '*'.repeat(apiKey.length / 3) + apiKey.slice((2 * apiKey.length) / 3) ); - console.log(`Model: ${model}, Channel: ${targetChannelKey}, apiKey: ${maskedApiKey}, Cost: ${totalCost}`); + console.log(`Model: ${model}, Channel: ${targetChannelKey}, apiKey: ${maskedApiKey}, Cost: ${totalCost} (input: ${inputCost}, cache: ${cacheCost}, output: ${outputCost})`); } else { console.warn(`No pricing found for model: ${model} in channel: ${targetChannelKey}`); } diff --git a/src/providers/azure-openai-responses-proxy.ts b/src/providers/azure-openai-responses-proxy.ts index 2f6aecb..1e2988c 100644 --- a/src/providers/azure-openai-responses-proxy.ts +++ b/src/providers/azure-openai-responses-proxy.ts @@ -1,28 +1,10 @@ import { Context } from "hono" - -type OpenAIResponsesUsage = { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - prompt_tokens?: number; - completion_tokens?: number; -} - -type OpenAIResponsesResponse = { - usage?: OpenAIResponsesUsage; - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - prompt_tokens?: number; - completion_tokens?: number; -} - -type OpenAIResponsesStreamEvent = { - type?: string; - response?: OpenAIResponsesResponse; - delta?: string; - text?: string; -} +import { + OpenAIResponsesResponse, + OpenAIResponsesStreamEvent, + extractUsageFromResponse, + estimateUsageFromBodies, +} from "./shared/usage-utils" const buildProxyRequest = ( request: Request, @@ -52,69 +34,6 @@ const buildProxyRequest = ( }) } -const normalizeUsage = (raw?: OpenAIResponsesUsage): Usage | null => { - if (!raw) return null - const promptTokens = raw.prompt_tokens ?? raw.input_tokens - const completionTokens = raw.completion_tokens ?? raw.output_tokens - const totalTokens = raw.total_tokens ?? ( - (promptTokens ?? 0) + (completionTokens ?? 0) - ) - if (promptTokens == null && completionTokens == null && totalTokens == null) { - return null - } - return { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: totalTokens, - } -} - -const extractUsageFromResponse = (res?: OpenAIResponsesResponse): Usage | null => { - if (!res) return null - return normalizeUsage(res.usage || res) -} - -const estimateTokensFromText = (text: string): number => { - if (!text) return 0 - const bytes = new TextEncoder().encode(text).length - return Math.ceil(bytes / 4) -} - -const estimateUsageFromBodies = ( - requestBody: any, - responseBody?: any, - streamedOutputText?: string -): Usage | null => { - const promptText = requestBody ? JSON.stringify(requestBody) : "" - const completionText = streamedOutputText - ? streamedOutputText - : (responseBody ? JSON.stringify(responseBody) : "") - const promptTokens = estimateTokensFromText(promptText) - const completionTokens = estimateTokensFromText(completionText) - if (!promptTokens && !completionTokens) return null - return { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - } -} - -const checkoutUsageData = async ( - saveUsage: (usage: Usage) => Promise, - response: Response | OpenAIResponsesResponse -): Promise => { - try { - const res = response instanceof Response - ? await response.clone().json() - : response - const usage = extractUsageFromResponse(res) - if (!usage) return - await saveUsage(usage) - } catch (error) { - console.error("Error logging usage data:", error) - } -} - const processStreamData = async ( lines: string[], usageSaved: { value: boolean }, diff --git a/src/providers/openai-responses-proxy.ts b/src/providers/openai-responses-proxy.ts index ea96bc5..fa5c1f0 100644 --- a/src/providers/openai-responses-proxy.ts +++ b/src/providers/openai-responses-proxy.ts @@ -1,28 +1,10 @@ import { Context } from "hono" - -type OpenAIResponsesUsage = { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - prompt_tokens?: number; - completion_tokens?: number; -} - -type OpenAIResponsesResponse = { - usage?: OpenAIResponsesUsage; - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - prompt_tokens?: number; - completion_tokens?: number; -} - -type OpenAIResponsesStreamEvent = { - type?: string; - response?: OpenAIResponsesResponse; - delta?: string; - text?: string; -} +import { + OpenAIResponsesResponse, + OpenAIResponsesStreamEvent, + extractUsageFromResponse, + estimateUsageFromBodies, +} from "./shared/usage-utils" const buildProxyRequest = ( request: Request, @@ -44,69 +26,6 @@ const buildProxyRequest = ( }) } -const normalizeUsage = (raw?: OpenAIResponsesUsage): Usage | null => { - if (!raw) return null - const promptTokens = raw.prompt_tokens ?? raw.input_tokens - const completionTokens = raw.completion_tokens ?? raw.output_tokens - const totalTokens = raw.total_tokens ?? ( - (promptTokens ?? 0) + (completionTokens ?? 0) - ) - if (promptTokens == null && completionTokens == null && totalTokens == null) { - return null - } - return { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: totalTokens, - } -} - -const extractUsageFromResponse = (res?: OpenAIResponsesResponse): Usage | null => { - if (!res) return null - return normalizeUsage(res.usage || res) -} - -const estimateTokensFromText = (text: string): number => { - if (!text) return 0 - const bytes = new TextEncoder().encode(text).length - return Math.ceil(bytes / 4) -} - -const estimateUsageFromBodies = ( - requestBody: any, - responseBody?: any, - streamedOutputText?: string -): Usage | null => { - const promptText = requestBody ? JSON.stringify(requestBody) : "" - const completionText = streamedOutputText - ? streamedOutputText - : (responseBody ? JSON.stringify(responseBody) : "") - const promptTokens = estimateTokensFromText(promptText) - const completionTokens = estimateTokensFromText(completionText) - if (!promptTokens && !completionTokens) return null - return { - prompt_tokens: promptTokens, - completion_tokens: completionTokens, - total_tokens: promptTokens + completionTokens, - } -} - -const checkoutUsageData = async ( - saveUsage: (usage: Usage) => Promise, - response: Response | OpenAIResponsesResponse -): Promise => { - try { - const res = response instanceof Response - ? await response.clone().json() - : response - const usage = extractUsageFromResponse(res) - if (!usage) return - await saveUsage(usage) - } catch (error) { - console.error("Error logging usage data:", error) - } -} - const processStreamData = async ( lines: string[], usageSaved: { value: boolean }, diff --git a/src/providers/shared/usage-utils.ts b/src/providers/shared/usage-utils.ts new file mode 100644 index 0000000..dd61a70 --- /dev/null +++ b/src/providers/shared/usage-utils.ts @@ -0,0 +1,103 @@ +export type InputTokensDetails = { + cached_tokens?: number; +} + +export type OpenAIResponsesUsage = { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + input_tokens_details?: InputTokensDetails; + prompt_tokens_details?: InputTokensDetails; +} + +export type OpenAIResponsesResponse = { + usage?: OpenAIResponsesUsage; + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + prompt_tokens?: number; + completion_tokens?: number; + input_tokens_details?: InputTokensDetails; + prompt_tokens_details?: InputTokensDetails; +} + +export type OpenAIResponsesStreamEvent = { + type?: string; + response?: OpenAIResponsesResponse; + delta?: string; + text?: string; +} + +export const normalizeUsage = (raw?: OpenAIResponsesUsage): Usage | null => { + if (!raw) return null + + const cachedTokens = + raw.input_tokens_details?.cached_tokens ?? + raw.prompt_tokens_details?.cached_tokens ?? + 0 + + const rawInputTokens = raw.prompt_tokens ?? raw.input_tokens ?? 0 + + const actualInputTokens = cachedTokens > 0 + ? Math.max(0, rawInputTokens - cachedTokens) + : rawInputTokens + + const completionTokens = raw.completion_tokens ?? raw.output_tokens + const totalTokens = raw.total_tokens ?? ( + (actualInputTokens ?? 0) + (cachedTokens ?? 0) + (completionTokens ?? 0) + ) + + if (actualInputTokens == null && completionTokens == null && totalTokens == null) { + return null + } + + return { + prompt_tokens: actualInputTokens, + completion_tokens: completionTokens, + total_tokens: totalTokens, + cached_tokens: cachedTokens > 0 ? cachedTokens : undefined, + } +} + +export const extractUsageFromResponse = (res?: OpenAIResponsesResponse): Usage | null => { + if (!res) return null + + const usageData: OpenAIResponsesUsage = { + input_tokens: res.usage?.input_tokens ?? res.input_tokens, + output_tokens: res.usage?.output_tokens ?? res.output_tokens, + total_tokens: res.usage?.total_tokens ?? res.total_tokens, + prompt_tokens: res.usage?.prompt_tokens ?? res.prompt_tokens, + completion_tokens: res.usage?.completion_tokens ?? res.completion_tokens, + input_tokens_details: res.usage?.input_tokens_details ?? res.input_tokens_details, + prompt_tokens_details: res.usage?.prompt_tokens_details ?? res.prompt_tokens_details, + } + + return normalizeUsage(usageData) +} + +export const estimateTokensFromText = (text: string): number => { + if (!text) return 0 + const bytes = new TextEncoder().encode(text).length + return Math.ceil(bytes / 4) +} + +export const estimateUsageFromBodies = ( + requestBody: any, + responseBody?: any, + streamedOutputText?: string +): Usage | null => { + const promptText = requestBody ? JSON.stringify(requestBody) : "" + const completionText = streamedOutputText + ? streamedOutputText + : (responseBody ? JSON.stringify(responseBody) : "") + const promptTokens = estimateTokensFromText(promptText) + const completionTokens = estimateTokensFromText(completionText) + if (!promptTokens && !completionTokens) return null + return { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: promptTokens + completionTokens, + } +} diff --git a/type.d.ts b/type.d.ts index 8aba9c3..3db7a6c 100644 --- a/type.d.ts +++ b/type.d.ts @@ -63,6 +63,7 @@ type Usage = { prompt_tokens?: number, completion_tokens?: number, total_tokens?: number, + cached_tokens?: number, } type AwslKeyPayload = { @@ -79,6 +80,7 @@ type CommonResponse = { type ModelPricing = { input: number; output: number; + cache?: number; } type ApiTokenData = {