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:
1 parent
6a648f3fa7
commit
bdb4825cdc
8 files changed
+150
-188
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>☁️</text></svg>" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Awsl One API - 管理控制台</title>
|
||||
</head>
|
||||
|
||||
@@ -27,7 +27,7 @@ export function Pricing() {
|
||||
const [editMode, setEditMode] = useState<EditMode>('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 */}
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="grid gap-2 px-4 py-2.5 border-b bg-muted/30" style={{ gridTemplateColumns: '2fr 1fr 1fr 40px' }}>
|
||||
<div className="grid gap-2 px-4 py-2.5 border-b bg-muted/30" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 40px' }}>
|
||||
<span className="text-xs font-medium text-muted-foreground">模型名称</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">输入倍率</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">输出倍率</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">缓存倍率</span>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* Rows */}
|
||||
<div className="divide-y">
|
||||
{filteredRows.map((row) => (
|
||||
<div key={row._i} className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors" style={{ gridTemplateColumns: '2fr 1fr 1fr 40px' }}>
|
||||
<div key={row._i} className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 40px' }}>
|
||||
<Input
|
||||
value={row.model}
|
||||
onChange={(e) => updateRow(row._i, 'model', e.target.value)}
|
||||
@@ -244,6 +249,14 @@ export function Pricing() {
|
||||
min="0"
|
||||
className="font-mono text-sm h-8"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={row.cache}
|
||||
onChange={(e) => updateRow(row._i, 'cache', parseFloat(e.target.value) || 0)}
|
||||
step="0.001"
|
||||
min="0"
|
||||
className="font-mono text-sm h-8"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -284,10 +297,10 @@ export function Pricing() {
|
||||
onChange={(e) => setJsonValue(e.target.value)}
|
||||
rows={20}
|
||||
className="font-mono text-sm"
|
||||
placeholder='{"gpt-4": {"input": 30, "output": 60}, ...}'
|
||||
placeholder='{"gpt-4": {"input": 30, "output": 60, "cache": 3}, ...}'
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
格式:模型名称 → {"{"} input: 输入倍率, output: 输出倍率 {"}"}
|
||||
格式:模型名称 → {"{"} input: 输入倍率, output: 输出倍率, cache: 缓存倍率 {"}"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TokenConfig {
|
||||
export interface PricingModel {
|
||||
input: number
|
||||
output: number
|
||||
cache?: number
|
||||
}
|
||||
|
||||
export type PricingConfig = Record<string, PricingModel>
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in new issue
Block a user