feat: 修复 Codex 缓存 token 计费并优化代码结构

- 修复 Codex API 缓存 token 重复计费问题 (input_tokens 包含 cached_tokens)
- 提取共享代码到 src/providers/shared/usage-utils.ts,消除重复
- 前端定价页面增加缓存倍率配置列
- 修复 var 为 const,favicon 改为云图标

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
dreamhunter2333andClaude Opus 4.5 committed 2026-01-30 21:06:44 +08:00
1 parent 6a648f3fa7
commit bdb4825cdc
8 files changed
+150 -188

No files matched your search

+11 -6
View File
@@ -31,23 +31,28 @@ export const TokenUtils = {
async processUsage(c: Context<HonoCustomType>, apiKey: string, model: string, targetChannelKey: string, targetChannelConfig: ChannelConfig, usage: Usage): Promise<void> {
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}`);
}
+6 -87
View File
@@ -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<void>,
response: Response | OpenAIResponsesResponse
): Promise<void> => {
try {
const res = response instanceof Response
? await response.clone().json<OpenAIResponsesResponse>()
: 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 },
+6 -87
View File
@@ -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<void>,
response: Response | OpenAIResponsesResponse
): Promise<void> => {
try {
const res = response instanceof Response
? await response.clone().json<OpenAIResponsesResponse>()
: 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 },
+103
View File
@@ -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,
}
}