diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index a9b8675b9..c6282c457 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -286,7 +286,17 @@ "fast": "fast", "fastPriority": "Priority service tier (fast mode)", "context1m": "1M Context", - "context1mPricing": "Input 2x >200k, Output 1.5x >200k" + "context1mPricing": "Input 2x >200k, Output 1.5x >200k", + "pricingProvider": "Pricing provider", + "pricingSourceLabel": "Pricing source", + "pricingSource": { + "local_manual": "Local manual pricing", + "cloud_exact": "Exact cloud provider pricing", + "cloud_model_fallback": "Fallback model provider pricing", + "priority_fallback": "Priority provider fallback pricing", + "single_provider_top_level": "Top-level single-provider pricing", + "official_fallback": "Official fallback pricing" + } }, "performance": { "title": "Performance", @@ -359,12 +369,12 @@ "targetModel": "Target Model" }, "statusCodes": { - "not200": "Non-200 (errors/blocked)", "200": "200 (Success)", "400": "400 (Bad Request)", "401": "401 (Unauthorized)", "429": "429 (Rate Limited)", - "500": "500 (Server Error)" + "500": "500 (Server Error)", + "not200": "Non-200 (errors/blocked)" }, "billingDetails": { "input": "Input", @@ -379,7 +389,17 @@ "fast": "fast", "fastPriority": "Priority service tier (fast mode)", "context1m": "1M Context", - "context1mPricing": "Input 2x >200k, Output 1.5x >200k" + "context1mPricing": "Input 2x >200k, Output 1.5x >200k", + "pricingProvider": "Pricing provider", + "pricingSourceLabel": "Pricing source", + "pricingSource": { + "local_manual": "Local manual pricing", + "cloud_exact": "Exact cloud provider pricing", + "cloud_model_fallback": "Fallback model provider pricing", + "priority_fallback": "Priority provider fallback pricing", + "single_provider_top_level": "Top-level single-provider pricing", + "official_fallback": "Official fallback pricing" + } } }, "leaderboard": { @@ -1685,11 +1705,11 @@ "rolling": "Rolling window (24h)" }, "quickValues": { - "unlimited": "Unlimited", "10": "$10", "50": "$50", "100": "$100", - "500": "$500" + "500": "$500", + "unlimited": "Unlimited" }, "alreadySet": "Configured", "confirmAdd": "Add" diff --git a/messages/en/settings/prices.json b/messages/en/settings/prices.json index 10bf957e7..a2fe29abe 100644 --- a/messages/en/settings/prices.json +++ b/messages/en/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "Local" + "local": "Local", + "multi": "Multi" }, "capabilities": { "assistantPrefill": "Assistant prefill", @@ -196,7 +197,8 @@ "actions": { "edit": "Edit", "more": "More actions", - "delete": "Delete" + "delete": "Delete", + "comparePricing": "Compare pricing" }, "toast": { "createSuccess": "Model added", @@ -204,5 +206,17 @@ "deleteSuccess": "Model deleted", "saveFailed": "Failed to save", "deleteFailed": "Failed to delete" + }, + "providerPricing": { + "title": "Provider pricing · {model}", + "description": "Compare provider-specific cloud prices and pin one as a local model price.", + "input": "Input", + "output": "Output", + "cacheRead": "Cache Read", + "pinAction": "Pin as local price", + "pinSuccess": "Pinned {provider} pricing as local model price", + "pinFailed": "Failed to pin provider pricing", + "pinned": "Pinned", + "priority": "Priority" } } diff --git a/messages/ja/dashboard.json b/messages/ja/dashboard.json index d0da5713b..d60e8fcbf 100644 --- a/messages/ja/dashboard.json +++ b/messages/ja/dashboard.json @@ -286,7 +286,17 @@ "fast": "fast", "fastPriority": "Priority service tier (fast モード)", "context1m": "1M コンテキスト", - "context1mPricing": "入力 2x >200k, 出力 1.5x >200k" + "context1mPricing": "入力 2x >200k, 出力 1.5x >200k", + "pricingProvider": "課金価格プロバイダー", + "pricingSourceLabel": "課金価格ソース", + "pricingSource": { + "local_manual": "ローカル手動価格", + "cloud_exact": "クラウドの正確なプロバイダー価格", + "cloud_model_fallback": "フォールバックモデルのプロバイダー価格", + "priority_fallback": "優先フォールバック価格", + "single_provider_top_level": "トップレベル単一プロバイダー価格", + "official_fallback": "公式フォールバック価格" + } }, "performance": { "title": "パフォーマンス", @@ -359,12 +369,12 @@ "targetModel": "ターゲットモデル" }, "statusCodes": { - "not200": "非 200(エラー/ブロック)", "200": "200(成功)", "400": "400 (不正なリクエスト)", "401": "401 (未認証)", "429": "429 (レート制限)", - "500": "500 (サーバーエラー)" + "500": "500 (サーバーエラー)", + "not200": "非 200(エラー/ブロック)" }, "billingDetails": { "input": "入力", @@ -379,7 +389,17 @@ "fast": "fast", "fastPriority": "Priority service tier (fast モード)", "context1m": "1M コンテキスト", - "context1mPricing": "入力 >200k 2倍, 出力 >200k 1.5倍" + "context1mPricing": "入力 >200k 2倍, 出力 >200k 1.5倍", + "pricingProvider": "課金価格プロバイダー", + "pricingSourceLabel": "課金価格ソース", + "pricingSource": { + "local_manual": "ローカル手動価格", + "cloud_exact": "クラウドの正確なプロバイダー価格", + "cloud_model_fallback": "フォールバックモデルのプロバイダー価格", + "priority_fallback": "優先フォールバック価格", + "single_provider_top_level": "トップレベル単一プロバイダー価格", + "official_fallback": "公式フォールバック価格" + } } }, "leaderboard": { @@ -1622,11 +1642,11 @@ "rolling": "ローリングウィンドウ(24時間)" }, "quickValues": { - "unlimited": "無制限", "10": "$10", "50": "$50", "100": "$100", - "500": "$500" + "500": "$500", + "unlimited": "無制限" }, "alreadySet": "設定済み", "confirmAdd": "追加", diff --git a/messages/ja/settings/prices.json b/messages/ja/settings/prices.json index 7d62d8ba3..3619fd08e 100644 --- a/messages/ja/settings/prices.json +++ b/messages/ja/settings/prices.json @@ -196,7 +196,8 @@ "actions": { "edit": "編集", "more": "その他の操作", - "delete": "削除" + "delete": "削除", + "comparePricing": "価格を比較" }, "toast": { "createSuccess": "モデルを追加しました", @@ -204,5 +205,17 @@ "deleteSuccess": "モデルを削除しました", "saveFailed": "保存に失敗しました", "deleteFailed": "削除に失敗しました" + }, + "providerPricing": { + "title": "プロバイダー価格 · {model}", + "description": "プロバイダー別のクラウド価格を比較し、そのうち一つをローカルモデル価格として固定します。", + "input": "入力", + "output": "出力", + "cacheRead": "キャッシュ読取", + "pinAction": "ローカル価格として固定", + "pinSuccess": "{provider} の価格をローカルモデル価格として固定しました", + "pinFailed": "プロバイダー価格の固定に失敗しました", + "pinned": "固定済み", + "priority": "priority" } } diff --git a/messages/ru/dashboard.json b/messages/ru/dashboard.json index 0937b3c1a..58ec1755f 100644 --- a/messages/ru/dashboard.json +++ b/messages/ru/dashboard.json @@ -286,7 +286,17 @@ "fast": "fast", "fastPriority": "Приоритетный уровень обслуживания (режим fast)", "context1m": "1M контекст", - "context1mPricing": "Вход 2x >200k, Выход 1.5x >200k" + "context1mPricing": "Вход 2x >200k, Выход 1.5x >200k", + "pricingProvider": "Провайдер тарифа", + "pricingSourceLabel": "Источник тарифа", + "pricingSource": { + "local_manual": "Локальная ручная цена", + "cloud_exact": "Точная облачная цена провайдера", + "cloud_model_fallback": "Цена провайдера из резервной модели", + "priority_fallback": "Приоритетная резервная цена", + "single_provider_top_level": "Цена верхнего уровня для одного провайдера", + "official_fallback": "Официальная резервная цена" + } }, "performance": { "title": "Производительность", @@ -359,12 +369,12 @@ "targetModel": "Целевая модель" }, "statusCodes": { - "not200": "Не 200 (ошибки/блокировки)", "200": "200 (Успех)", "400": "400 (Неверный запрос)", "401": "401 (Не авторизован)", "429": "429 (Ограничение)", - "500": "500 (Ошибка сервера)" + "500": "500 (Ошибка сервера)", + "not200": "Не 200 (ошибки/блокировки)" }, "billingDetails": { "input": "Входные", @@ -379,7 +389,17 @@ "fast": "fast", "fastPriority": "Приоритетный уровень обслуживания (режим fast)", "context1m": "1M Контекст", - "context1mPricing": "Вход >200k 2x, Выход >200k 1.5x" + "context1mPricing": "Вход >200k 2x, Выход >200k 1.5x", + "pricingProvider": "Провайдер тарифа", + "pricingSourceLabel": "Источник тарифа", + "pricingSource": { + "local_manual": "Локальная ручная цена", + "cloud_exact": "Точная облачная цена провайдера", + "cloud_model_fallback": "Цена провайдера из резервной модели", + "priority_fallback": "Приоритетная резервная цена", + "single_provider_top_level": "Цена верхнего уровня для одного провайдера", + "official_fallback": "Официальная резервная цена" + } } }, "leaderboard": { @@ -1668,11 +1688,11 @@ "rolling": "Скользящее окно (24ч)" }, "quickValues": { - "unlimited": "Без ограничений", "10": "$10", "50": "$50", "100": "$100", - "500": "$500" + "500": "$500", + "unlimited": "Без ограничений" }, "alreadySet": "Уже настроено", "confirmAdd": "Добавить" diff --git a/messages/ru/settings/prices.json b/messages/ru/settings/prices.json index c8a6efa53..18f12f361 100644 --- a/messages/ru/settings/prices.json +++ b/messages/ru/settings/prices.json @@ -196,7 +196,8 @@ "actions": { "edit": "Редактировать", "more": "Больше действий", - "delete": "Удалить" + "delete": "Удалить", + "comparePricing": "Сравнить цены" }, "toast": { "createSuccess": "Модель добавлена", @@ -204,5 +205,17 @@ "deleteSuccess": "Модель удалена", "saveFailed": "Ошибка сохранения", "deleteFailed": "Ошибка удаления" + }, + "providerPricing": { + "title": "Цены провайдеров · {model}", + "description": "Сравните облачные цены по провайдерам и закрепите одну из них как локальную цену модели.", + "input": "Вход", + "output": "Выход", + "cacheRead": "Чтение кэша", + "pinAction": "Закрепить как локальную цену", + "pinSuccess": "Цена {provider} закреплена как локальная цена модели", + "pinFailed": "Не удалось закрепить цену провайдера", + "pinned": "Закреплено", + "priority": "Priority" } } diff --git a/messages/zh-CN/dashboard.json b/messages/zh-CN/dashboard.json index 5cf1689f9..a8a89475e 100644 --- a/messages/zh-CN/dashboard.json +++ b/messages/zh-CN/dashboard.json @@ -286,7 +286,17 @@ "fast": "fast", "fastPriority": "优先服务等级(fast 模式)", "context1m": "1M 上下文", - "context1mPricing": "输入 2x >200k, 输出 1.5x >200k" + "context1mPricing": "输入 2x >200k, 输出 1.5x >200k", + "pricingProvider": "计费价格提供商", + "pricingSourceLabel": "计费价格来源", + "pricingSource": { + "local_manual": "本地手动价格", + "cloud_exact": "云端精确提供商价格", + "cloud_model_fallback": "回退模型的提供商价格", + "priority_fallback": "优先级回退价格", + "single_provider_top_level": "顶层单提供商价格", + "official_fallback": "官方兜底价格" + } }, "performance": { "title": "性能数据", @@ -359,12 +369,12 @@ "targetModel": "目标模型" }, "statusCodes": { - "not200": "非 200(全部非成功请求)", "200": "200 (成功)", "400": "400 (错误请求)", "401": "401 (未授权)", "429": "429 (限流)", - "500": "500 (服务器错误)" + "500": "500 (服务器错误)", + "not200": "非 200(全部非成功请求)" }, "billingDetails": { "input": "输入", @@ -379,7 +389,17 @@ "fast": "fast", "fastPriority": "优先服务等级(fast 模式)", "context1m": "1M 上下文", - "context1mPricing": "输入 >200k 2倍, 输出 >200k 1.5倍" + "context1mPricing": "输入 >200k 2倍, 输出 >200k 1.5倍", + "pricingProvider": "计费价格提供商", + "pricingSourceLabel": "计费价格来源", + "pricingSource": { + "local_manual": "本地手动价格", + "cloud_exact": "云端精确提供商价格", + "cloud_model_fallback": "回退模型的提供商价格", + "priority_fallback": "优先级回退价格", + "single_provider_top_level": "顶层单提供商价格", + "official_fallback": "官方兜底价格" + } } }, "leaderboard": { @@ -1645,11 +1665,11 @@ "rolling": "滚动窗口(24h)" }, "quickValues": { - "unlimited": "无限", "10": "$10", "50": "$50", "100": "$100", - "500": "$500" + "500": "$500", + "unlimited": "无限" }, "alreadySet": "已配置", "confirmAdd": "添加", diff --git a/messages/zh-CN/settings/prices.json b/messages/zh-CN/settings/prices.json index 3ddf19039..c9608854b 100644 --- a/messages/zh-CN/settings/prices.json +++ b/messages/zh-CN/settings/prices.json @@ -31,7 +31,8 @@ "openrouter": "OpenRouter" }, "badges": { - "local": "本地" + "local": "本地", + "multi": "多供应商" }, "capabilities": { "assistantPrefill": "助手预填充", @@ -196,7 +197,8 @@ "actions": { "edit": "编辑", "more": "更多操作", - "delete": "删除" + "delete": "删除", + "comparePricing": "对比价格" }, "toast": { "createSuccess": "模型已添加", @@ -204,5 +206,17 @@ "deleteSuccess": "模型已删除", "saveFailed": "保存失败", "deleteFailed": "删除失败" + }, + "providerPricing": { + "title": "多供应商价格 · {model}", + "description": "对比不同供应商的云端价格,并将其中一项固化为本地模型价格。", + "input": "输入", + "output": "输出", + "cacheRead": "缓存读取", + "pinAction": "固化为本地价格", + "pinSuccess": "已将 {provider} 价格固化为本地模型价格", + "pinFailed": "固化供应商价格失败", + "pinned": "已固化", + "priority": "优先" } } diff --git a/messages/zh-TW/dashboard.json b/messages/zh-TW/dashboard.json index 453fc5306..0ba49e063 100644 --- a/messages/zh-TW/dashboard.json +++ b/messages/zh-TW/dashboard.json @@ -286,7 +286,17 @@ "fast": "fast", "fastPriority": "優先服務層級(fast 模式)", "context1m": "1M 上下文", - "context1mPricing": "輸入 2x >200k, 輸出 1.5x >200k" + "context1mPricing": "輸入 2x >200k, 輸出 1.5x >200k", + "pricingProvider": "計費價格供應商", + "pricingSourceLabel": "計費價格來源", + "pricingSource": { + "local_manual": "本地手動價格", + "cloud_exact": "雲端精確供應商價格", + "cloud_model_fallback": "回退模型的供應商價格", + "priority_fallback": "優先級回退價格", + "single_provider_top_level": "頂層單供應商價格", + "official_fallback": "官方兜底價格" + } }, "performance": { "title": "效能資料", @@ -359,12 +369,12 @@ "targetModel": "目標模型" }, "statusCodes": { - "not200": "非 200(所有非成功請求)", "200": "200(成功)", "400": "400(錯誤請求)", "401": "401(未授權)", "429": "429(限流)", - "500": "500(服務器錯誤)" + "500": "500(服務器錯誤)", + "not200": "非 200(所有非成功請求)" }, "billingDetails": { "input": "輸入", @@ -379,7 +389,17 @@ "fast": "fast", "fastPriority": "優先服務層級(fast 模式)", "context1m": "1M 上下文長度", - "context1mPricing": "輸入 >200k 2倍, 輸出 >200k 1.5倍" + "context1mPricing": "輸入 >200k 2倍, 輸出 >200k 1.5倍", + "pricingProvider": "計費價格供應商", + "pricingSourceLabel": "計費價格來源", + "pricingSource": { + "local_manual": "本地手動價格", + "cloud_exact": "雲端精確供應商價格", + "cloud_model_fallback": "回退模型的供應商價格", + "priority_fallback": "優先級回退價格", + "single_provider_top_level": "頂層單供應商價格", + "official_fallback": "官方兜底價格" + } } }, "leaderboard": { @@ -1630,11 +1650,11 @@ "rolling": "滾動視窗(24h)" }, "quickValues": { - "unlimited": "無限", "10": "$10", "50": "$50", "100": "$100", - "500": "$500" + "500": "$500", + "unlimited": "無限" }, "alreadySet": "已設定", "confirmAdd": "新增", diff --git a/messages/zh-TW/settings/prices.json b/messages/zh-TW/settings/prices.json index 03d5bb149..587377e70 100644 --- a/messages/zh-TW/settings/prices.json +++ b/messages/zh-TW/settings/prices.json @@ -196,7 +196,8 @@ "actions": { "edit": "編輯", "more": "更多動作", - "delete": "刪除" + "delete": "刪除", + "comparePricing": "比較價格" }, "toast": { "createSuccess": "模型已新增", @@ -204,5 +205,17 @@ "deleteSuccess": "模型已刪除", "saveFailed": "儲存失敗", "deleteFailed": "刪除失敗" + }, + "providerPricing": { + "title": "多供應商價格 · {model}", + "description": "比較不同供應商的雲端價格,並將其中一項固化為本地模型價格。", + "input": "輸入", + "output": "輸出", + "cacheRead": "快取讀取", + "pinAction": "固化為本地價格", + "pinSuccess": "已將 {provider} 價格固化為本地模型價格", + "pinFailed": "固化供應商價格失敗", + "pinned": "已固化", + "priority": "優先" } } diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index 5ba943946..dbf6d6009 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -13,6 +13,7 @@ import { findAllLatestPrices, findAllLatestPricesPaginated, findAllManualPrices, + findLatestPriceByModelAndSource, hasAnyPriceRecords, type PaginatedResult, type PaginationParams, @@ -66,6 +67,32 @@ function isPriceDataEqual(data1: ModelPriceData, data2: ModelPriceData): boolean return stableStringify(data1) === stableStringify(data2); } +function buildManualPriceDataFromProviderPricing( + modelName: string, + basePriceData: ModelPriceData, + pricingProviderKey: string +): ModelPriceData | null { + const pricing = basePriceData.pricing; + if (!pricing || typeof pricing !== "object" || Array.isArray(pricing)) { + return null; + } + + const pricingNode = pricing[pricingProviderKey]; + if (!pricingNode || typeof pricingNode !== "object" || Array.isArray(pricingNode)) { + return null; + } + + return { + ...basePriceData, + ...pricingNode, + pricing: undefined, + litellm_provider: pricingProviderKey, + selected_pricing_provider: pricingProviderKey, + selected_pricing_source_model: modelName, + selected_pricing_resolution: "manual_pin", + }; +} + /** * 价格表处理核心逻辑(内部函数,无权限检查) * 用于系统初始化和 Web UI 上传 @@ -587,7 +614,6 @@ export async function deleteSingleModelPrice(modelName: string): Promise> { + try { + const session = await getSession(); + if (!session || session.user.role !== "admin") { + return { ok: false, error: "无权限执行此操作" }; + } + + const modelName = input.modelName?.trim(); + const pricingProviderKey = input.pricingProviderKey?.trim(); + if (!modelName) { + return { ok: false, error: "模型名称不能为空" }; + } + if (!pricingProviderKey) { + return { ok: false, error: "价格提供商不能为空" }; + } + + const latestCloudPrice = await findLatestPriceByModelAndSource(modelName, "litellm"); + if (!latestCloudPrice) { + return { ok: false, error: "未找到云端模型价格" }; + } + + const manualPriceData = buildManualPriceDataFromProviderPricing( + modelName, + latestCloudPrice.priceData, + pricingProviderKey + ); + if (!manualPriceData) { + return { ok: false, error: "未找到对应的多供应商价格节点" }; + } + + const result = await upsertModelPrice(modelName, manualPriceData); + + try { + revalidatePath("/settings/prices"); + } catch (error) { + logger.debug("[ModelPrices] revalidatePath skipped", { + error: error instanceof Error ? error.message : String(error), + }); + } + + return { ok: true, data: result }; + } catch (error) { + logger.error("固化多供应商价格失败:", error); + const message = error instanceof Error ? error.message : "操作失败,请稍后重试"; + return { ok: false, error: message }; + } +} diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx index 83178a60e..a99a0432f 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/MetadataTab.tsx @@ -20,6 +20,10 @@ import { Link } from "@/i18n/routing"; import { cn, formatTokenAmount } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/currency"; import { formatProviderTimeline } from "@/lib/utils/provider-chain-formatter"; +import { + getPricingResolutionSpecialSetting, + hasPriorityServiceTierSpecialSetting, +} from "@/lib/utils/special-settings"; import type { MetadataTabProps } from "../types"; export function MetadataTab({ @@ -49,6 +53,11 @@ export function MetadataTab({ const specialSettingsContent = specialSettings && specialSettings.length > 0 ? JSON.stringify(specialSettings, null, 2) : null; + const pricingResolution = getPricingResolutionSpecialSetting(specialSettings); + const pricingSourceLabel = pricingResolution + ? t(`billingDetails.pricingSource.${pricingResolution.source}`) + : null; + const hasPriorityServiceTier = hasPriorityServiceTierSpecialSetting(specialSettings); const handleCopyTimeline = () => { if (!providerChain) return; @@ -240,6 +249,37 @@ export function MetadataTab({ )} + {hasPriorityServiceTier ? ( +
+ {t("billingDetails.fast")}: + + {t("billingDetails.fastPriority")} + +
+ ) : null} + + {pricingResolution && pricingSourceLabel ? ( + <> +
+ + {t("billingDetails.pricingProvider")}: + + + {pricingResolution.resolvedPricingProviderKey} + +
+
+ + {t("billingDetails.pricingSourceLabel")}: + + {pricingSourceLabel} +
+ + ) : null} + {/* Cost Multiplier */} {(() => { if (costMultiplier === "" || costMultiplier == null) return null; diff --git a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx index a4fba2095..0ce9ca098 100644 --- a/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx +++ b/src/app/[locale]/dashboard/logs/_components/error-details-dialog/components/SummaryTab.tsx @@ -20,6 +20,10 @@ import { Button } from "@/components/ui/button"; import { Link } from "@/i18n/routing"; import { cn, formatTokenAmount } from "@/lib/utils"; import { formatCurrency } from "@/lib/utils/currency"; +import { + getPricingResolutionSpecialSetting, + hasPriorityServiceTierSpecialSetting, +} from "@/lib/utils/special-settings"; import { getFake200ReasonKey } from "../../fake200-reason"; import { calculateOutputRate, @@ -67,6 +71,11 @@ export function SummaryTab({ const hasRedirect = originalModel && currentModel && originalModel !== currentModel; const specialSettingsContent = specialSettings && specialSettings.length > 0 ? JSON.stringify(specialSettings, null, 2) : null; + const pricingResolution = getPricingResolutionSpecialSetting(specialSettings); + const pricingSourceLabel = pricingResolution + ? t(`billingDetails.pricingSource.${pricingResolution.source}`) + : null; + const hasPriorityServiceTier = hasPriorityServiceTierSpecialSetting(specialSettings); const isFake200PostStreamFailure = typeof errorMessage === "string" && errorMessage.startsWith("FAKE_200_"); const fake200Code = @@ -353,6 +362,37 @@ export function SummaryTab({ )} + {hasPriorityServiceTier ? ( +
+ {t("billingDetails.fast")}: + + {t("billingDetails.fastPriority")} + +
+ ) : null} + + {pricingResolution && pricingSourceLabel ? ( + <> +
+ + {t("billingDetails.pricingProvider")}: + + + {pricingResolution.resolvedPricingProviderKey} + +
+
+ + {t("billingDetails.pricingSourceLabel")}: + + {pricingSourceLabel} +
+ + ) : null} + {/* Cost Multiplier */} {(() => { if (costMultiplier === "" || costMultiplier == null) return null; diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx index 501d15db6..6de455381 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.test.tsx @@ -348,3 +348,37 @@ describe("usage-logs-table multiplier badge", () => { container.remove(); }); }); + +describe("usage-logs-table pricing resolution", () => { + test("renders pricing provider and source details when pricing_resolution special setting exists", () => { + const html = renderToStaticMarkup( + {}} + isPending={false} + /> + ); + + expect(html).toContain("logs.billingDetails.pricingProvider"); + expect(html).toContain("openai"); + expect(html).toContain("logs.billingDetails.pricingSource.priority_fallback"); + }); +}); diff --git a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx index 333357edf..7b87a0f56 100644 --- a/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/usage-logs-table.tsx @@ -26,7 +26,10 @@ import { shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; import { formatProviderSummary } from "@/lib/utils/provider-chain-formatter"; -import { hasPriorityServiceTierSpecialSetting } from "@/lib/utils/special-settings"; +import { + getPricingResolutionSpecialSetting, + hasPriorityServiceTierSpecialSetting, +} from "@/lib/utils/special-settings"; import type { UsageLogRow } from "@/repository/usage-logs"; import type { BillingModelSource } from "@/types/system-config"; import { ErrorDetailsDialog } from "./error-details-dialog"; @@ -59,6 +62,8 @@ export function UsageLogsTable({ const t = useTranslations("dashboard"); const tChain = useTranslations("provider-chain"); const totalPages = Math.ceil(total / pageSize); + const getPricingSourceLabel = (source: string) => + t(`logs.billingDetails.pricingSource.${source}`); // 弹窗状态管理:记录当前打开的行 ID 和是否需要滚动到重定向部分 const [dialogState, setDialogState] = useState<{ @@ -111,6 +116,7 @@ export function UsageLogsTable({ const isNonBilling = log.endpoint === NON_BILLING_ENDPOINT; const isWarmupSkipped = log.blockedBy === "warmup"; const isMutedRow = isNonBilling || isWarmupSkipped; + const pricingResolution = getPricingResolutionSpecialSetting(log.specialSettings); // 计算倍率(用于 Provider 列 Badge 和成本明细) const successfulProvider = @@ -412,6 +418,17 @@ export function UsageLogsTable({ {t("logs.billingDetails.context1m")} )} + {pricingResolution && ( + <> +
+ {t("logs.billingDetails.pricingProvider")}:{" "} + + {pricingResolution.resolvedPricingProviderKey} + +
+
{getPricingSourceLabel(pricingResolution.source)}
+ + )}
{t("logs.billingDetails.input")}:{" "} {formatTokenAmount(log.inputTokens)} tokens diff --git a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx index 163c3f8d6..8a20b905a 100644 --- a/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx +++ b/src/app/[locale]/dashboard/logs/_components/virtualized-logs-table.tsx @@ -22,7 +22,10 @@ import { NON_BILLING_ENDPOINT, shouldHideOutputRate, } from "@/lib/utils/performance-formatter"; -import { hasPriorityServiceTierSpecialSetting } from "@/lib/utils/special-settings"; +import { + getPricingResolutionSpecialSetting, + hasPriorityServiceTierSpecialSetting, +} from "@/lib/utils/special-settings"; import type { ProviderChainItem } from "@/types/message"; import type { BillingModelSource } from "@/types/system-config"; import { ErrorDetailsDialog } from "./error-details-dialog"; @@ -56,6 +59,7 @@ interface VirtualizedLogsTableProps { hideScrollToTop?: boolean; hiddenColumns?: LogsTableColumn[]; bodyClassName?: string; + serverTimeZone?: string; } export function VirtualizedLogsTable({ @@ -68,8 +72,10 @@ export function VirtualizedLogsTable({ hideScrollToTop = false, hiddenColumns, bodyClassName, + serverTimeZone: _serverTimeZone, }: VirtualizedLogsTableProps) { const t = useTranslations("dashboard"); + const getPricingSourceLabel = (source: string) => t(`logs.billingDetails.pricingSource.`); const tChain = useTranslations("provider-chain"); const parentRef = useRef(null); const [showScrollToTop, setShowScrollToTop] = useState(false); @@ -332,6 +338,8 @@ export function VirtualizedLogsTable({ } const isNonBilling = log.endpoint === NON_BILLING_ENDPOINT; + const _isWarmupSkipped = log.blockedBy === "warmup"; + const pricingResolution = getPricingResolutionSpecialSetting(log.specialSettings); return (
)} + {pricingResolution && ( + <> +
+ {t("logs.billingDetails.pricingProvider")}:{" "} + + {pricingResolution.resolvedPricingProviderKey} + +
+
{getPricingSourceLabel(pricingResolution.source)}
+ + )}
{t("logs.billingDetails.input")}:{" "} {formatTokenAmount(log.inputTokens)} tokens diff --git a/src/app/[locale]/settings/prices/_components/price-list.tsx b/src/app/[locale]/settings/prices/_components/price-list.tsx index 9c3c9f04d..1acd822c7 100644 --- a/src/app/[locale]/settings/prices/_components/price-list.tsx +++ b/src/app/[locale]/settings/prices/_components/price-list.tsx @@ -2,6 +2,7 @@ import { formatInTimeZone } from "date-fns-tz"; import { + ArrowRightLeft, Braces, ChevronLeft, ChevronRight, @@ -42,9 +43,11 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { useDebounce } from "@/lib/hooks/use-debounce"; import { PRICE_FILTER_VENDORS } from "@/lib/model-vendor-icons"; import { copyToClipboard } from "@/lib/utils/clipboard"; +import { resolvePricingForModelRecords } from "@/lib/utils/pricing-resolution"; import type { ModelPrice, ModelPriceSource } from "@/types/model-price"; import { DeleteModelDialog } from "./delete-model-dialog"; import { ModelPriceDrawer } from "./model-price-drawer"; +import { ProviderPricingDialog } from "./provider-pricing-dialog"; interface PriceListProps { initialPrices: ModelPrice[]; @@ -445,206 +448,246 @@ export function PriceList({ ) : filteredPrices.length > 0 ? ( - filteredPrices.map((price) => ( - - -
- - {price.priceData.display_name?.trim() || price.modelName} - - {price.priceData.litellm_provider ? ( - - {price.priceData.litellm_provider} - - ) : null} - {price.source === "manual" && ( - {t("badges.local")} - )} -
-
- - - - - {t("table.copyModelId")} - -
- - -
- {capabilityItems.map(({ key, icon: Icon, label }) => { - const enabled = price.priceData[key] === true; - const status = enabled - ? t("capabilities.statusSupported") - : t("capabilities.statusUnsupported"); - const tooltipText = t("capabilities.tooltip", { label, status }); - return ( - - - - - {tooltipText} - - ); - })} -
- - -
-
- - {t("table.priceInput")} - - - {price.priceData.mode === "image_generation" - ? "-" - : formatPerMillionTokenPriceLabel(price.priceData.input_cost_per_token)} + filteredPrices.map((price) => { + const displayPricing = resolvePricingForModelRecords({ + provider: null, + primaryModelName: price.modelName, + fallbackModelName: null, + primaryRecord: price, + fallbackRecord: null, + }); + const displayPriceData = displayPricing?.priceData ?? price.priceData; + const displayPricingProviderKey = + displayPricing?.resolvedPricingProviderKey ?? + (typeof displayPriceData.selected_pricing_provider === "string" + ? displayPriceData.selected_pricing_provider + : null); + + return ( + + +
+ + {price.priceData.display_name?.trim() || price.modelName} + {price.priceData.litellm_provider ? ( + + {price.priceData.litellm_provider} + + ) : null} + {displayPricingProviderKey && + displayPricingProviderKey !== price.priceData.litellm_provider ? ( + + {displayPricingProviderKey} + + ) : null} + {price.priceData.pricing && + Object.keys(price.priceData.pricing).length > 1 ? ( + {t("badges.multi")} + ) : null} + {price.source === "manual" && ( + {t("badges.local")} + )}
-
- - {t("table.priceOutput")} - - - {price.priceData.mode === "image_generation" - ? formatPerImagePriceLabel(price.priceData.output_cost_per_image) - : formatPerMillionTokenPriceLabel( - price.priceData.output_cost_per_token - )} - +
+ + + + + {t("table.copyModelId")} +
- {formatPerRequestPriceLabel(price.priceData.input_cost_per_request) !== - "-" ? ( -
- - {t("table.pricePerRequest")} - - - {formatPerRequestPriceLabel(price.priceData.input_cost_per_request)} - -
- ) : null} -
- - - - {price.priceData.supports_prompt_caching === true - ? formatPerMillionTokenPriceLabel( - price.priceData.cache_read_input_token_cost - ) - : "-"} - - - - {price.priceData.supports_prompt_caching === true ? ( + + +
+ {capabilityItems.map(({ key, icon: Icon, label }) => { + const enabled = price.priceData[key] === true; + const status = enabled + ? t("capabilities.statusSupported") + : t("capabilities.statusUnsupported"); + const tooltipText = t("capabilities.tooltip", { label, status }); + return ( + + + + + {tooltipText} + + ); + })} +
+ +
- {t("table.cache5m")} + {t("table.priceInput")} - {formatPerMillionTokenPriceLabel( - price.priceData.cache_creation_input_token_cost - )} + {displayPriceData.mode === "image_generation" + ? "-" + : formatPerMillionTokenPriceLabel( + displayPriceData.input_cost_per_token + )}
- {t("table.cache1h")} + {t("table.priceOutput")} - {formatPerMillionTokenPriceLabel( - price.priceData.cache_creation_input_token_cost_above_1hr - )} + {displayPriceData.mode === "image_generation" + ? formatPerImagePriceLabel(displayPriceData.output_cost_per_image) + : formatPerMillionTokenPriceLabel( + displayPriceData.output_cost_per_token + )}
+ {formatPerRequestPriceLabel(displayPriceData.input_cost_per_request) !== + "-" ? ( +
+ + {t("table.pricePerRequest")} + + + {formatPerRequestPriceLabel(displayPriceData.input_cost_per_request)} + +
+ ) : null}
- ) : ( - - - )} - - - {formatInTimeZone( - new Date(price.updatedAt ?? price.createdAt), - timeZone, - "yyyy-MM-dd" - )} - - - - - - - - e.preventDefault()}> - - {t("actions.edit")} - - } - /> - { - const willBeEmpty = filteredPrices.length <= 1 && page > 1; - const targetPage = willBeEmpty ? page - 1 : page; - if (targetPage !== page) { - pendingRefreshPage.current = targetPage; - setPage(targetPage); - updateURL( - debouncedSearchTerm, - targetPage, - pageSize, - sourceFilter, - litellmProviderFilter - ); + + + + {displayPriceData.supports_prompt_caching === true + ? formatPerMillionTokenPriceLabel( + displayPriceData.cache_read_input_token_cost + ) + : "-"} + + + + {displayPriceData.supports_prompt_caching === true ? ( +
+
+ + {t("table.cache5m")} + + + {formatPerMillionTokenPriceLabel( + displayPriceData.cache_creation_input_token_cost + )} + +
+
+ + {t("table.cache1h")} + + + {formatPerMillionTokenPriceLabel( + displayPriceData.cache_creation_input_token_cost_above_1hr + )} + +
+
+ ) : ( + - + )} + + + {formatInTimeZone( + new Date(price.updatedAt ?? price.createdAt), + timeZone, + "yyyy-MM-dd" + )} + + + + + + + + {price.priceData.pricing && + Object.keys(price.priceData.pricing).length > 0 ? ( + e.preventDefault()}> + + {t("actions.comparePricing")} + + } + /> + ) : null} + e.preventDefault()}> + + {t("actions.edit")} + } - }} - trigger={ - e.preventDefault()} - className="text-destructive focus:text-destructive" - > - - {t("actions.delete")} - - } - /> - - - - - )) + /> + { + const willBeEmpty = filteredPrices.length <= 1 && page > 1; + const targetPage = willBeEmpty ? page - 1 : page; + if (targetPage !== page) { + pendingRefreshPage.current = targetPage; + setPage(targetPage); + updateURL( + debouncedSearchTerm, + targetPage, + pageSize, + sourceFilter, + litellmProviderFilter + ); + } + }} + trigger={ + e.preventDefault()} + className="text-destructive focus:text-destructive" + > + + {t("actions.delete")} + + } + /> +
+
+ + + ); + }) ) : ( diff --git a/src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx b/src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx new file mode 100644 index 000000000..4dea729d4 --- /dev/null +++ b/src/app/[locale]/settings/prices/_components/provider-pricing-dialog.tsx @@ -0,0 +1,197 @@ +"use client"; + +import { ArrowRightLeft, Loader2, Pin } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { pinModelPricingProviderAsManual } from "@/actions/model-prices"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import type { ModelPrice } from "@/types/model-price"; + +interface ProviderPricingDialogProps { + price: ModelPrice; + trigger?: React.ReactNode; + onSuccess?: () => void; +} + +function formatScalar(value?: number): string { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return "-"; + } + if (value < 0.01) return value.toFixed(4); + if (value < 1) return value.toFixed(3); + if (value < 100) return value.toFixed(2); + return value.toFixed(0); +} + +function formatTokenPrice(value?: number): string { + const formatted = formatScalar(typeof value === "number" ? value * 1000000 : undefined); + return formatted === "-" ? "-" : `$${formatted}/M`; +} + +export function ProviderPricingDialog({ price, trigger, onSuccess }: ProviderPricingDialogProps) { + const t = useTranslations("settings.prices"); + const tCommon = useTranslations("common"); + const [open, setOpen] = useState(false); + const [pinningKey, setPinningKey] = useState(null); + + const pricingEntries = useMemo(() => { + const pricing = price.priceData.pricing; + if (!pricing || typeof pricing !== "object" || Array.isArray(pricing)) { + return [] as Array<[string, Record]>; + } + + return Object.entries(pricing) + .filter((entry): entry is [string, Record] => { + return !!entry[1] && typeof entry[1] === "object" && !Array.isArray(entry[1]); + }) + .sort((a, b) => a[0].localeCompare(b[0])); + }, [price.priceData.pricing]); + + const handlePin = async (pricingProviderKey: string) => { + setPinningKey(pricingProviderKey); + try { + const result = await pinModelPricingProviderAsManual({ + modelName: price.modelName, + pricingProviderKey, + }); + if (!result.ok) { + toast.error(result.error); + return; + } + + toast.success(t("providerPricing.pinSuccess", { provider: pricingProviderKey })); + setOpen(false); + onSuccess?.(); + window.dispatchEvent(new Event("price-data-updated")); + } catch (error) { + console.error("pin provider pricing failed", error); + toast.error(t("providerPricing.pinFailed")); + } finally { + setPinningKey(null); + } + }; + + if (pricingEntries.length === 0) { + return null; + } + + const defaultTrigger = ( + + ); + + return ( + + {trigger || defaultTrigger} + + + {t("providerPricing.title", { model: price.modelName })} + {t("providerPricing.description")} + + +
+ {pricingEntries.map(([providerKey, providerPricing]) => { + const isPinning = pinningKey === providerKey; + return ( +
+
+
+ + {providerKey} + + {price.priceData.selected_pricing_provider === providerKey ? ( + {t("providerPricing.pinned")} + ) : null} +
+ +
+ +
+
+
{t("providerPricing.input")}
+
+ {formatTokenPrice(providerPricing.input_cost_per_token as number | undefined)} +
+ {typeof providerPricing.input_cost_per_token_priority === "number" ? ( +
+ {t("providerPricing.priority")}:{" "} + {formatTokenPrice( + providerPricing.input_cost_per_token_priority as number | undefined + )} +
+ ) : null} +
+
+
{t("providerPricing.output")}
+
+ {formatTokenPrice( + providerPricing.output_cost_per_token as number | undefined + )} +
+ {typeof providerPricing.output_cost_per_token_priority === "number" ? ( +
+ {t("providerPricing.priority")}:{" "} + {formatTokenPrice( + providerPricing.output_cost_per_token_priority as number | undefined + )} +
+ ) : null} +
+
+
{t("providerPricing.cacheRead")}
+
+ {formatTokenPrice( + providerPricing.cache_read_input_token_cost as number | undefined + )} +
+ {typeof providerPricing.cache_read_input_token_cost_priority === "number" ? ( +
+ {t("providerPricing.priority")}:{" "} + {formatTokenPrice( + providerPricing.cache_read_input_token_cost_priority as number | undefined + )} +
+ ) : null} +
+
+
+ ); + })} +
+ +
+ +
+
+
+ ); +} diff --git a/src/app/v1/_lib/proxy/forwarder.ts b/src/app/v1/_lib/proxy/forwarder.ts index 4bb3ffc7a..56eb16489 100644 --- a/src/app/v1/_lib/proxy/forwarder.ts +++ b/src/app/v1/_lib/proxy/forwarder.ts @@ -2891,7 +2891,17 @@ export class ProxyForwarder { // - 'disabled': 不应用(已在调度阶段被过滤) // - 'force_enable': 强制应用(仅对支持的模型) // - 'inherit' 或 null: 遵循客户端请求 - if (session.getContext1mApplied?.()) { + if ( + session.getContext1mApplied?.() && + (provider.providerType === "claude" || provider.providerType === "claude-auth") + ) { + session.addSpecialSetting({ + type: "anthropic_context_1m_header_override", + scope: "request_header", + hit: true, + header: "anthropic-beta", + flag: CONTEXT_1M_BETA_HEADER, + }); const existingBeta = overrides["anthropic-beta"] || session.headers.get("anthropic-beta") || ""; const betaFlags = new Set( diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index b533b240e..6ffc4aeea 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -11,6 +11,7 @@ import { SessionTracker } from "@/lib/session-tracker"; import type { CostBreakdown } from "@/lib/utils/cost-calculation"; import { calculateRequestCost, calculateRequestCostBreakdown } from "@/lib/utils/cost-calculation"; import { hasValidPriceData } from "@/lib/utils/price-data"; +import { resolvePricingForModelRecords } from "@/lib/utils/pricing-resolution"; import { isSSEText, parseSSEData } from "@/lib/utils/sse"; import { detectUpstreamErrorFromSseOrJsonText, @@ -23,6 +24,7 @@ import { } from "@/repository/message"; import { findLatestPriceByModel } from "@/repository/model-price"; import { getSystemSettings } from "@/repository/system-config"; +import type { Provider } from "@/types/provider"; import type { SessionUsageUpdate } from "@/types/session"; import { GeminiAdapter } from "../gemini/adapter"; import type { GeminiResponse } from "../gemini/types"; @@ -108,6 +110,123 @@ function cleanResponseHeaders(headers: Headers): Headers { return cleaned; } +function ensurePricingResolutionSpecialSetting( + session: ProxySession, + resolvedPricing: Awaited> +): void { + if (!resolvedPricing) return; + + const existing = session + .getSpecialSettings() + ?.find( + (setting) => + setting.type === "pricing_resolution" && + setting.resolvedModelName === resolvedPricing.resolvedModelName && + setting.resolvedPricingProviderKey === resolvedPricing.resolvedPricingProviderKey && + setting.source === resolvedPricing.source + ); + + if (existing) return; + + session.addSpecialSetting({ + type: "pricing_resolution", + scope: "billing", + hit: true, + modelName: session.getCurrentModel() ?? resolvedPricing.resolvedModelName, + resolvedModelName: resolvedPricing.resolvedModelName, + resolvedPricingProviderKey: resolvedPricing.resolvedPricingProviderKey, + source: resolvedPricing.source, + }); +} + +function getRequestedCodexServiceTier(session: ProxySession): string | null { + if (session.provider?.providerType !== "codex") { + return null; + } + + const request = session.request.message as Record; + return typeof request.service_tier === "string" ? request.service_tier : null; +} + +export function parseServiceTierFromResponseText(responseText: string): string | null { + let lastSeenServiceTier: string | null = null; + + const applyValue = (value: unknown) => { + if (typeof value === "string" && value.trim()) { + lastSeenServiceTier = value.trim(); + } + }; + + try { + const parsedValue = JSON.parse(responseText); + if (parsedValue && typeof parsedValue === "object" && !Array.isArray(parsedValue)) { + const parsed = parsedValue as Record; + applyValue(parsed.service_tier); + if (parsed.response && typeof parsed.response === "object") { + applyValue((parsed.response as Record).service_tier); + } + } + } catch { + // ignore, fallback to SSE parsing below + } + + if (lastSeenServiceTier) { + return lastSeenServiceTier; + } + + if (isSSEText(responseText)) { + const events = parseSSEData(responseText); + for (const event of events) { + if (!event.data || typeof event.data !== "object") continue; + const data = event.data as Record; + applyValue(data.service_tier); + if (data.response && typeof data.response === "object") { + applyValue((data.response as Record).service_tier); + } + } + } + + return lastSeenServiceTier; +} + +function isPriorityServiceTierApplied( + session: ProxySession, + actualServiceTier: string | null +): boolean { + if (actualServiceTier != null) { + return actualServiceTier === "priority"; + } + return getRequestedCodexServiceTier(session) === "priority"; +} + +function ensureCodexServiceTierResultSpecialSetting( + session: ProxySession, + actualServiceTier: string | null +): void { + if (session.provider?.providerType !== "codex") { + return; + } + + const requestedServiceTier = getRequestedCodexServiceTier(session); + const effectivePriority = isPriorityServiceTierApplied(session, actualServiceTier); + const existing = session + .getSpecialSettings() + ?.find((setting) => setting.type === "codex_service_tier_result"); + + if (existing && existing.type === "codex_service_tier_result") { + return; + } + + session.addSpecialSetting({ + type: "codex_service_tier_result", + scope: "response", + hit: effectivePriority || requestedServiceTier != null || actualServiceTier != null, + requestedServiceTier, + actualServiceTier, + effectivePriority, + }); +} + type FinalizeDeferredStreamingResult = { /** * “内部结算用”的状态码。 @@ -386,7 +505,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( const { recordEndpointSuccess } = await import("@/lib/endpoint-circuit-breaker"); await recordEndpointSuccess(meta.endpointId); } catch (endpointError) { - logger.warn("[ResponseHandler] Failed to record endpoint success (stream)", { + logger.warn("[ResponseHandler] Failed to record endpoint success (stream finalized)", { endpointId: meta.endpointId, providerId: meta.providerId, error: endpointError, @@ -441,7 +560,7 @@ async function finalizeDeferredStreamingFinalizationIfNeeded( providerId: meta.providerId, providerName: meta.providerName, }).catch((err) => { - logger.error("[ResponseHandler] Failed to update session provider info (stream)", { + logger.error("[ResponseHandler] Failed to update session provider info (stream finalized)", { error: err, }); }); @@ -730,6 +849,9 @@ export class ProxyResponseHandler { const usageResult = parseUsageFromResponseText(responseText, provider.providerType); usageRecord = usageResult.usageRecord; usageMetrics = usageResult.usageMetrics; + const actualServiceTier = parseServiceTierFromResponseText(responseText); + ensureCodexServiceTierResultSpecialSetting(session, actualServiceTier); + const priorityServiceTierApplied = isPriorityServiceTierApplied(session, actualServiceTier); if (usageMetrics) { usageMetrics = normalizeUsageWithSwap( @@ -775,12 +897,14 @@ export class ProxyResponseHandler { session.getOriginalModel(), session.getCurrentModel(), usageMetrics, + provider, provider.costMultiplier, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); // 追踪消费到 Redis(用于限流) - await trackCostToRedis(session, usageMetrics); + await trackCostToRedis(session, usageMetrics, priorityServiceTierApplied); } // Calculate cost for session tracking (with multiplier) and Langfuse (raw) @@ -790,13 +914,15 @@ export class ProxyResponseHandler { if (usageMetrics) { try { if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { + const resolvedPricing = await session.getResolvedPricingByBillingSource(provider); + if (resolvedPricing) { + ensurePricingResolutionSpecialSetting(session, resolvedPricing); const cost = calculateRequestCost( usageMetrics, - priceData, + resolvedPricing.priceData, provider.costMultiplier, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); if (cost.gt(0)) { costUsdStr = cost.toString(); @@ -805,9 +931,10 @@ export class ProxyResponseHandler { if (provider.costMultiplier !== 1) { const rawCost = calculateRequestCost( usageMetrics, - priceData, + resolvedPricing.priceData, 1.0, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); if (rawCost.gt(0)) { rawCostUsdStr = rawCost.toString(); @@ -819,8 +946,9 @@ export class ProxyResponseHandler { try { costBreakdown = calculateRequestCostBreakdown( usageMetrics, - priceData, - session.getContext1mApplied() + resolvedPricing.priceData, + session.getContext1mApplied(), + priorityServiceTierApplied ); } catch { /* non-critical */ @@ -895,7 +1023,8 @@ export class ProxyResponseHandler { model: session.getCurrentModel() ?? undefined, // 更新重定向后的模型 providerId: session.provider?.id, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), - swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, + swapCacheTtlApplied: session.provider?.swapCacheTtlBilling ?? false, + specialSettings: session.getSpecialSettings() ?? undefined, }); // 记录请求结束 @@ -1179,17 +1308,23 @@ export class ProxyResponseHandler { }; // 优先填充 head;超过 head 上限后切到 tail(但不代表一定发生截断,只有 tail 溢出才算截断) - if (!inTailMode) { - if (headBufferedBytes + bytes <= MAX_STATS_HEAD_BYTES) { + if (!inTailMode && headBufferedBytes < MAX_STATS_HEAD_BYTES) { + const remainingHeadBytes = MAX_STATS_HEAD_BYTES - headBufferedBytes; + if (remainingHeadBytes > 0 && bytes > remainingHeadBytes) { + const headPart = text.substring(0, remainingHeadBytes); + const tailPart = text.substring(remainingHeadBytes); + + pushChunk(headPart, remainingHeadBytes); + + inTailMode = true; + pushChunk(tailPart, bytes - remainingHeadBytes); + } else { headChunks.push(text); headBufferedBytes += bytes; - return; } - - inTailMode = true; + } else { + pushChunk(text, bytes); } - - pushToTail(); }; const decoder = new TextDecoder(); let isFirstChunk = true; @@ -1304,6 +1439,7 @@ export class ProxyResponseHandler { const headText = decoder.decode(headPart, { stream: true }); pushChunk(headText, remainingHeadBytes); + inTailMode = true; const tailText = decoder.decode(tailPart, { stream: true }); pushChunk(tailText, chunkSize - remainingHeadBytes); } else { @@ -1486,6 +1622,7 @@ export class ProxyResponseHandler { }); } try { + // 取消 reader lock reader?.releaseLock(); } catch (e) { logger.warn("[ResponseHandler] Gemini passthrough: Failed to release reader lock", { @@ -1574,7 +1711,7 @@ export class ProxyResponseHandler { const statusCode = response.status; // 使用 AsyncTaskManager 管理后台处理任务 - const taskId = `stream-${messageContext.id}`; + const taskId = `stream-${messageContext?.id || `unknown-${Date.now()}`}`; const abortController = new AbortController(); // ⭐ 提升 idleTimeoutId 到外部作用域,以便客户端断开时能清除 @@ -1688,7 +1825,7 @@ export class ProxyResponseHandler { allContent, session.requestSequence ).catch((err) => { - logger.error("[ResponseHandler] Failed to store stream response:", err); + logger.error("[ResponseHandler] Failed to store response:", err); }); } @@ -1701,6 +1838,10 @@ export class ProxyResponseHandler { const usageResult = parseUsageFromResponseText(allContent, provider.providerType); usageForCost = usageResult.usageMetrics; + const actualServiceTier = parseServiceTierFromResponseText(allContent); + ensureCodexServiceTierResultSpecialSetting(session, actualServiceTier); + const priorityServiceTierApplied = isPriorityServiceTierApplied(session, actualServiceTier); + if (usageForCost) { usageForCost = normalizeUsageWithSwap( usageForCost, @@ -1740,12 +1881,14 @@ export class ProxyResponseHandler { session.getOriginalModel(), session.getCurrentModel(), usageForCost, + provider, provider.costMultiplier, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); // 追踪消费到 Redis(用于限流) - await trackCostToRedis(session, usageForCost); + await trackCostToRedis(session, usageForCost, priorityServiceTierApplied); // Calculate cost for session tracking (with multiplier) and Langfuse (raw) let costUsdStr: string | undefined; @@ -1754,13 +1897,15 @@ export class ProxyResponseHandler { if (usageForCost) { try { if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { + const resolvedPricing = await session.getResolvedPricingByBillingSource(provider); + if (resolvedPricing) { + ensurePricingResolutionSpecialSetting(session, resolvedPricing); const cost = calculateRequestCost( usageForCost, - priceData, + resolvedPricing.priceData, provider.costMultiplier, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); if (cost.gt(0)) { costUsdStr = cost.toString(); @@ -1769,9 +1914,10 @@ export class ProxyResponseHandler { if (provider.costMultiplier !== 1) { const rawCost = calculateRequestCost( usageForCost, - priceData, + resolvedPricing.priceData, 1.0, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); if (rawCost.gt(0)) { rawCostUsdStr = rawCost.toString(); @@ -1783,8 +1929,9 @@ export class ProxyResponseHandler { try { costBreakdown = calculateRequestCostBreakdown( usageForCost, - priceData, - session.getContext1mApplied() + resolvedPricing.priceData, + session.getContext1mApplied(), + priorityServiceTierApplied ); } catch { /* non-critical */ @@ -1838,6 +1985,7 @@ export class ProxyResponseHandler { providerId: providerIdForPersistence ?? session.provider?.id, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, + specialSettings: session.getSpecialSettings() ?? undefined, }); emitLangfuseTrace(session, { @@ -1862,6 +2010,7 @@ export class ProxyResponseHandler { logger.info("ResponseHandler: Stream processing cancelled", { taskId, providerId: provider.id, + providerName: provider.name, chunksCollected: chunks.length, }); break; // 提前终止 @@ -2172,7 +2321,7 @@ export class ProxyResponseHandler { } } -function extractUsageMetrics(value: unknown): UsageMetrics | null { +export function extractUsageMetrics(value: unknown): UsageMetrics | null { if (!value || typeof value !== "object") { return null; } @@ -2202,7 +2351,7 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null { } // OpenAI chat completion format: prompt_tokens → input_tokens - // Priority: Claude (input_tokens) > Gemini (promptTokenCount) > OpenAI (prompt_tokens) + // Priority: Claude (input_tokens) > Gemini (candidatesTokenCount) > OpenAI (prompt_tokens) if (result.input_tokens === undefined && typeof usage.prompt_tokens === "number") { result.input_tokens = usage.prompt_tokens; hasAny = true; @@ -2386,7 +2535,7 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null { if (inputTokensDetails && typeof inputTokensDetails.cached_tokens === "number") { result.cache_read_input_tokens = inputTokensDetails.cached_tokens; hasAny = true; - logger.debug("[UsageMetrics] Extracted cached tokens from OpenAI Response API format", { + logger.debug("[ResponseHandler] Parsed cached tokens from OpenAI Response API format", { cachedTokens: inputTokensDetails.cached_tokens, }); } @@ -2695,7 +2844,7 @@ function normalizeUsageWithSwap( let resolvedCacheTtl = swapped.cache_ttl ?? session.getCacheTtlResolved?.() ?? null; - // When the upstream response had no cache_ttl, we fell through to the session-level + // When the upstream response had no cache_ttl,we fell through to the session-level // getCacheTtlResolved() fallback which reflects the *original* (un-swapped) value. // We must invert it here to stay consistent with the already-swapped bucket tokens. if (swapCacheTtlBilling && !usageMetrics.cache_ttl) { @@ -2726,8 +2875,10 @@ async function updateRequestCostFromUsage( originalModel: string | null, redirectedModel: string | null, usage: UsageMetrics | null, + provider: Provider | null, costMultiplier: number = 1.0, - context1mApplied: boolean = false + context1mApplied: boolean = false, + priorityServiceTierApplied: boolean = false ): Promise { if (!usage) { logger.warn("[CostCalculation] No usage data, skipping cost update", { @@ -2742,20 +2893,16 @@ async function updateRequestCostFromUsage( } try { - // 获取系统设置中的计费模型来源配置 const systemSettings = await getSystemSettings(); const billingModelSource = systemSettings.billingModelSource; - // 根据配置决定计费模型优先级 let primaryModel: string | null; let fallbackModel: string | null; if (billingModelSource === "original") { - // 优先使用重定向前的原始模型 primaryModel = originalModel; fallbackModel = redirectedModel; } else { - // 优先使用重定向后的实际模型 primaryModel = redirectedModel; fallbackModel = originalModel; } @@ -2767,50 +2914,21 @@ async function updateRequestCostFromUsage( fallbackModel, }); - // Fallback 逻辑:优先主要模型,找不到则用备选模型 - let priceData = null; - let usedModelForPricing = null; - - const resolveValidPriceData = async (modelName: string) => { - const record = await findLatestPriceByModel(modelName); - const data = record?.priceData; - if (!data || !hasValidPriceData(data)) { - return null; - } - return record; - }; - - // Step 1: 尝试主要模型 - if (primaryModel) { - const resolved = await resolveValidPriceData(primaryModel); - if (resolved) { - priceData = resolved; - usedModelForPricing = primaryModel; - logger.debug("[CostCalculation] Using primary model for pricing", { - messageId, - model: primaryModel, - billingModelSource, - }); - } - } - - // Step 2: Fallback 到备选模型 - if (!priceData && fallbackModel && fallbackModel !== primaryModel) { - const resolved = await resolveValidPriceData(fallbackModel); - if (resolved) { - priceData = resolved; - usedModelForPricing = fallbackModel; - logger.warn("[CostCalculation] Primary model price not found, using fallback model", { - messageId, - primaryModel, - fallbackModel, - billingModelSource, - }); - } - } + const primaryRecord = primaryModel ? await findLatestPriceByModel(primaryModel) : null; + const fallbackRecord = + fallbackModel && fallbackModel !== primaryModel + ? await findLatestPriceByModel(fallbackModel) + : null; + + const resolvedPricing = resolvePricingForModelRecords({ + provider, + primaryModelName: primaryModel, + fallbackModelName: fallbackModel, + primaryRecord, + fallbackRecord, + }); - // Step 3: 完全失败(无价格或价格表暂不可用):不计费放行,并异步触发一次同步 - if (!priceData?.priceData) { + if (!resolvedPricing?.priceData || !hasValidPriceData(resolvedPricing.priceData)) { logger.warn("[CostCalculation] No price data found, skipping billing", { messageId, originalModel, @@ -2822,13 +2940,19 @@ async function updateRequestCostFromUsage( return; } - // 计算费用 - const cost = calculateRequestCost(usage, priceData.priceData, costMultiplier, context1mApplied); + const cost = calculateRequestCost( + usage, + resolvedPricing.priceData, + costMultiplier, + context1mApplied, + priorityServiceTierApplied + ); logger.info("[CostCalculation] Cost calculated successfully", { messageId, - usedModelForPricing, - billingModelSource, + usedModelForPricing: resolvedPricing.resolvedModelName, + resolvedPricingProviderKey: resolvedPricing.resolvedPricingProviderKey, + pricingResolutionSource: resolvedPricing.source, costUsd: cost.toString(), costMultiplier, usage, @@ -2839,11 +2963,12 @@ async function updateRequestCostFromUsage( } else { logger.warn("[CostCalculation] Calculated cost is zero or negative", { messageId, - usedModelForPricing, + usedModelForPricing: resolvedPricing.resolvedModelName, + resolvedPricingProviderKey: resolvedPricing.resolvedPricingProviderKey, costUsd: cost.toString(), priceData: { - inputCost: priceData.priceData.input_cost_per_token, - outputCost: priceData.priceData.output_cost_per_token, + inputCost: resolvedPricing.priceData.input_cost_per_token, + outputCost: resolvedPricing.priceData.output_cost_per_token, }, }); } @@ -2877,27 +3002,21 @@ export async function finalizeRequestStats( } const providerIdForPersistence = providerIdOverride ?? session.provider?.id; - - // 1. 结束请求状态追踪 - ProxyStatusTracker.getInstance().endRequest(messageContext.user.id, messageContext.id); - - // 2. 更新请求时长 - await updateMessageRequestDuration(messageContext.id, duration); - - // 3. 解析 usage metrics const { usageMetrics } = parseUsageFromResponseText(responseText, provider.providerType); - + const actualServiceTier = parseServiceTierFromResponseText(responseText); + ensureCodexServiceTierResultSpecialSetting(session, actualServiceTier); + const priorityServiceTierApplied = isPriorityServiceTierApplied(session, actualServiceTier); if (!usageMetrics) { - // 即使没有 usageMetrics,也需要更新状态码和 provider chain await updateMessageRequestDetails(messageContext.id, { statusCode: statusCode, ...(errorMessage ? { errorMessage } : {}), ttfbMs: session.ttfbMs ?? duration, providerChain: session.getProviderChain(), model: session.getCurrentModel() ?? undefined, - providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) + providerId: providerIdForPersistence, context1mApplied: session.getContext1mApplied(), - swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, + swapCacheTtlApplied: session.provider?.swapCacheTtlBilling ?? false, + specialSettings: session.getSpecialSettings() ?? undefined, }); return null; } @@ -2916,25 +3035,29 @@ export async function finalizeRequestStats( session.getOriginalModel(), session.getCurrentModel(), normalizedUsage, + provider, provider.costMultiplier, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); // 5. 追踪消费到 Redis(用于限流) - await trackCostToRedis(session, normalizedUsage); + await trackCostToRedis(session, normalizedUsage, priorityServiceTierApplied); // 6. 更新 session usage if (session.sessionId) { let costUsdStr: string | undefined; try { if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { + const resolvedPricing = await session.getResolvedPricingByBillingSource(provider); + if (resolvedPricing) { + ensurePricingResolutionSpecialSetting(session, resolvedPricing); const cost = calculateRequestCost( normalizedUsage, - priceData, + resolvedPricing.priceData, provider.costMultiplier, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); if (cost.gt(0)) { costUsdStr = cost.toString(); @@ -2978,6 +3101,7 @@ export async function finalizeRequestStats( providerId: providerIdForPersistence, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), swapCacheTtlApplied: provider.swapCacheTtlBilling ?? false, + specialSettings: session.getSpecialSettings() ?? undefined, }); return normalizedUsage; @@ -2986,7 +3110,11 @@ export async function finalizeRequestStats( /** * 追踪消费到 Redis(用于限流) */ -async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | null): Promise { +async function trackCostToRedis( + session: ProxySession, + usage: UsageMetrics | null, + priorityServiceTierApplied: boolean = false +): Promise { if (!usage || !session.sessionId) return; try { @@ -3000,15 +3128,17 @@ async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | nul const modelName = session.request.model; if (!modelName) return; - // 计算成本(应用倍率)- 使用 session 缓存避免重复查询 - const priceData = await session.getCachedPriceDataByBillingSource(); - if (!priceData) return; + const resolvedPricing = await session.getResolvedPricingByBillingSource(provider); + if (!resolvedPricing) return; + + ensurePricingResolutionSpecialSetting(session, resolvedPricing); const cost = calculateRequestCost( usage, - priceData, + resolvedPricing.priceData, provider.costMultiplier, - session.getContext1mApplied() + session.getContext1mApplied(), + priorityServiceTierApplied ); if (cost.lte(0)) return; @@ -3137,6 +3267,7 @@ async function persistRequestFailure(options: { providerId: session.provider?.id, // 更新最终供应商ID(重试切换后) context1mApplied: session.getContext1mApplied(), swapCacheTtlApplied: session.provider?.swapCacheTtlBilling ?? false, + specialSettings: session.getSpecialSettings() ?? undefined, }); const isAsyncWrite = getEnvConfig().MESSAGE_REQUEST_WRITE_MODE !== "sync"; diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 74afe9400..67afc2ef5 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -1,7 +1,10 @@ import type { Context } from "hono"; import { logger } from "@/lib/logger"; import { clientRequestsContext1m as clientRequestsContext1mHelper } from "@/lib/special-attributes"; -import { hasValidPriceData } from "@/lib/utils/price-data"; +import { + type ResolvedPricing, + resolvePricingForModelRecords, +} from "@/lib/utils/pricing-resolution"; import { findLatestPriceByModel } from "@/repository/model-price"; import { findAllProviders } from "@/repository/provider"; import type { CacheTtlResolved } from "@/types/cache"; @@ -120,8 +123,8 @@ export class ProxySession { */ private billingModelSourcePromise?: Promise<"original" | "redirected">; - // Cached price data for billing model source (lazy loaded: undefined=not loaded, null=no data) - private cachedBillingPriceData?: ModelPriceData | null; + // Resolved pricing cache (per request/provider combination) + private resolvedPricingCache = new Map(); /** * 请求级 Provider 快照 @@ -701,30 +704,15 @@ export class ProxySession { return this.cachedPriceData ?? null; } - /** - * 根据系统配置的计费模型来源获取价格数据(带缓存) - * - * billingModelSource: - * - "original": 优先使用重定向前模型(getOriginalModel) - * - "redirected": 优先使用重定向后模型(request.model) - * - * Fallback:主模型无价格时尝试备选模型。 - * - * @returns 价格数据;无模型或无价格时返回 null - */ - async getCachedPriceDataByBillingSource(): Promise { - if (this.cachedBillingPriceData !== undefined) { - return this.cachedBillingPriceData; - } - + async getResolvedPricingByBillingSource( + provider?: Provider | null + ): Promise { const originalModel = this.getOriginalModel(); const redirectedModel = this.request.model; if (!originalModel && !redirectedModel) { - this.cachedBillingPriceData = null; return null; } - // 懒加载配置(每请求只读取一次;并发安全) if (this.cachedBillingModelSource === undefined) { if (!this.billingModelSourcePromise) { this.billingModelSourcePromise = (async () => { @@ -751,30 +739,64 @@ export class ProxySession { this.cachedBillingModelSource = await this.billingModelSourcePromise; } + const providerIdentity = provider ?? this.provider; + const cacheKey = [ + this.cachedBillingModelSource, + originalModel ?? "", + redirectedModel ?? "", + providerIdentity?.id ?? 0, + providerIdentity?.name ?? "", + providerIdentity?.url ?? "", + ].join("|"); + + if (this.resolvedPricingCache.has(cacheKey)) { + return this.resolvedPricingCache.get(cacheKey) ?? null; + } + const useOriginal = this.cachedBillingModelSource === "original"; const primaryModel = useOriginal ? originalModel : redirectedModel; const fallbackModel = useOriginal ? redirectedModel : originalModel; - const findValidPriceDataByModel = async (modelName: string): Promise => { - const result = await findLatestPriceByModel(modelName); - const data = result?.priceData; - if (!data || !hasValidPriceData(data)) { - return null; - } - return data; - }; + const primaryRecord = primaryModel ? await findLatestPriceByModel(primaryModel) : null; + let resolved = resolvePricingForModelRecords({ + provider: providerIdentity, + primaryModelName: primaryModel, + fallbackModelName: null, + primaryRecord, + fallbackRecord: null, + }); - let priceData: ModelPriceData | null = null; - if (primaryModel) { - priceData = await findValidPriceDataByModel(primaryModel); + if (!resolved && fallbackModel && fallbackModel !== primaryModel) { + const fallbackRecord = await findLatestPriceByModel(fallbackModel); + resolved = resolvePricingForModelRecords({ + provider: providerIdentity, + primaryModelName: primaryModel, + fallbackModelName: fallbackModel, + primaryRecord, + fallbackRecord, + }); } - if (!priceData && fallbackModel && fallbackModel !== primaryModel) { - priceData = await findValidPriceDataByModel(fallbackModel); - } + this.resolvedPricingCache.set(cacheKey, resolved ?? null); + return resolved ?? null; + } - this.cachedBillingPriceData = priceData; - return this.cachedBillingPriceData; + /** + * 根据系统配置的计费模型来源获取价格数据(带缓存) + * + * billingModelSource: + * - "original": 优先使用重定向前模型(getOriginalModel) + * - "redirected": 优先使用重定向后模型(request.model) + * + * Fallback:主模型无价格时尝试备选模型。 + * + * @returns 价格数据;无模型或无价格时返回 null + */ + async getCachedPriceDataByBillingSource( + provider?: Provider | null + ): Promise { + const resolved = await this.getResolvedPricingByBillingSource(provider); + return resolved?.priceData ?? null; } } diff --git a/src/lib/utils/cost-calculation.ts b/src/lib/utils/cost-calculation.ts index 0be83453d..6ffa14bac 100644 --- a/src/lib/utils/cost-calculation.ts +++ b/src/lib/utils/cost-calculation.ts @@ -6,6 +6,8 @@ import { import type { ModelPriceData } from "@/types/model-price"; import { COST_SCALE, Decimal, toDecimal } from "./currency"; +const OPENAI_LONG_CONTEXT_TOKEN_THRESHOLD = 272000; + type UsageMetrics = { input_tokens?: number; output_tokens?: number; @@ -39,7 +41,7 @@ function multiplyCost(quantity: number | undefined, unitCost: number | undefined * @param threshold - 阈值(默认200k) * @returns 费用 */ -function calculateTieredCost( +function _calculateTieredCost( tokens: number, baseCostPerToken: number, premiumMultiplier: number, @@ -55,13 +57,7 @@ function calculateTieredCost( return new Decimal(tokens).mul(baseCostDecimal); } - // 阈值内的token按基础费率计算 - const baseCost = new Decimal(threshold).mul(baseCostDecimal); - // 超出阈值的token按溢价费率计算 - const premiumTokens = tokens - threshold; - const premiumCost = new Decimal(premiumTokens).mul(baseCostDecimal).mul(premiumMultiplier); - - return baseCost.add(premiumCost); + return new Decimal(tokens).mul(baseCostDecimal).mul(premiumMultiplier); } /** @@ -72,7 +68,7 @@ function calculateTieredCost( * @param threshold - 阈值(默认 200K) * @returns 费用 */ -function calculateTieredCostWithSeparatePrices( +function __calculateTieredCostWithSeparatePrices( tokens: number, baseCostPerToken: number, premiumCostPerToken: number, @@ -89,13 +85,41 @@ function calculateTieredCostWithSeparatePrices( return new Decimal(tokens).mul(baseCostDecimal); } - // 阈值内的 token 按基础费率计算 - const baseCost = new Decimal(threshold).mul(baseCostDecimal); - // 超出阈值的 token 按溢价费率计算 - const premiumTokens = tokens - threshold; - const premiumCost = new Decimal(premiumTokens).mul(premiumCostDecimal); + return new Decimal(tokens).mul(premiumCostDecimal); +} + +function resolveLongContextThreshold(priceData: ModelPriceData): number { + const has272kFields = + typeof priceData.input_cost_per_token_above_272k_tokens === "number" || + typeof priceData.output_cost_per_token_above_272k_tokens === "number" || + typeof priceData.cache_creation_input_token_cost_above_272k_tokens === "number" || + typeof priceData.cache_read_input_token_cost_above_272k_tokens === "number" || + typeof priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens === "number"; + + const modelFamily = typeof priceData.model_family === "string" ? priceData.model_family : ""; + if (has272kFields || modelFamily === "gpt" || modelFamily === "gpt-pro") { + return OPENAI_LONG_CONTEXT_TOKEN_THRESHOLD; + } + + return CONTEXT_1M_TOKEN_THRESHOLD; +} - return baseCost.add(premiumCost); +function getRequestInputContextTokens( + usage: UsageMetrics, + cache5mTokens?: number, + cache1hTokens?: number +): number { + const cacheCreationInputTokens = + typeof usage.cache_creation_input_tokens === "number" + ? usage.cache_creation_input_tokens + : (cache5mTokens ?? 0) + (cache1hTokens ?? 0); + + return ( + (usage.input_tokens ?? 0) + + cacheCreationInputTokens + + (usage.cache_read_input_tokens ?? 0) + + (usage.input_image_tokens ?? 0) + ); } export interface CostBreakdown { @@ -113,15 +137,24 @@ export interface CostBreakdown { export function calculateRequestCostBreakdown( usage: UsageMetrics, priceData: ModelPriceData, - context1mApplied: boolean = false + context1mApplied: boolean = false, + priorityServiceTierApplied: boolean = false ): CostBreakdown { let inputBucket = new Decimal(0); let outputBucket = new Decimal(0); let cacheCreationBucket = new Decimal(0); let cacheReadBucket = new Decimal(0); - const inputCostPerToken = priceData.input_cost_per_token; - const outputCostPerToken = priceData.output_cost_per_token; + const baseInputCostPerToken = priceData.input_cost_per_token; + const baseOutputCostPerToken = priceData.output_cost_per_token; + const inputCostPerToken = + priorityServiceTierApplied && typeof priceData.input_cost_per_token_priority === "number" + ? priceData.input_cost_per_token_priority + : baseInputCostPerToken; + const outputCostPerToken = + priorityServiceTierApplied && typeof priceData.output_cost_per_token_priority === "number" + ? priceData.output_cost_per_token_priority + : baseOutputCostPerToken; const inputCostPerRequest = priceData.input_cost_per_request; // Per-request cost -> input bucket @@ -138,19 +171,22 @@ export function calculateRequestCostBreakdown( const cacheCreation5mCost = priceData.cache_creation_input_token_cost ?? - (inputCostPerToken != null ? inputCostPerToken * 1.25 : undefined); + (baseInputCostPerToken != null ? baseInputCostPerToken * 1.25 : undefined); const cacheCreation1hCost = priceData.cache_creation_input_token_cost_above_1hr ?? - (inputCostPerToken != null ? inputCostPerToken * 2 : undefined) ?? + (baseInputCostPerToken != null ? baseInputCostPerToken * 2 : undefined) ?? cacheCreation5mCost; const cacheReadCost = - priceData.cache_read_input_token_cost ?? - (inputCostPerToken != null - ? inputCostPerToken * 0.1 - : outputCostPerToken != null - ? outputCostPerToken * 0.1 + (priorityServiceTierApplied && + typeof priceData.cache_read_input_token_cost_priority === "number" + ? priceData.cache_read_input_token_cost_priority + : priceData.cache_read_input_token_cost) ?? + (baseInputCostPerToken != null + ? baseInputCostPerToken * 0.1 + : baseOutputCostPerToken != null + ? baseOutputCostPerToken * 0.1 : undefined); // Derive cache creation tokens by TTL @@ -171,92 +207,110 @@ export function calculateRequestCostBreakdown( } } - const inputAbove200k = priceData.input_cost_per_token_above_200k_tokens; - const outputAbove200k = priceData.output_cost_per_token_above_200k_tokens; + const inputAboveThreshold = + priceData.input_cost_per_token_above_272k_tokens ?? + priceData.input_cost_per_token_above_200k_tokens; + const outputAboveThreshold = + priceData.output_cost_per_token_above_272k_tokens ?? + priceData.output_cost_per_token_above_200k_tokens; + const cacheCreationAboveThreshold = + priceData.cache_creation_input_token_cost_above_272k_tokens ?? + priceData.cache_creation_input_token_cost_above_200k_tokens; + const cacheCreation1hAboveThreshold = + priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens ?? + priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens ?? + cacheCreationAboveThreshold; + const cacheReadAboveThreshold = + priceData.cache_read_input_token_cost_above_272k_tokens ?? + priceData.cache_read_input_token_cost_above_200k_tokens; + const longContextThreshold = resolveLongContextThreshold(priceData); + const longContextThresholdExceeded = + getRequestInputContextTokens(usage, cache5mTokens, cache1hTokens) > longContextThreshold; + const hasRealCacheCreationBase = priceData.cache_creation_input_token_cost != null; + const hasRealCacheReadBase = priceData.cache_read_input_token_cost != null; // Input tokens -> input bucket - if (context1mApplied && inputCostPerToken != null && usage.input_tokens != null) { - inputBucket = inputBucket.add( - calculateTieredCost( - usage.input_tokens, - inputCostPerToken, - CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER - ) - ); - } else if (inputAbove200k != null && inputCostPerToken != null && usage.input_tokens != null) { + // 注意:一旦请求的“输入上下文总量”超过阈值,供应商官方定价按整次请求的全量 token + // 应用 long-context 价格,而不是仅对超过阈值的部分加价。 + if (longContextThresholdExceeded && inputAboveThreshold != null && usage.input_tokens != null) { + inputBucket = inputBucket.add(multiplyCost(usage.input_tokens, inputAboveThreshold)); + } else if ( + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && + inputCostPerToken != null && + usage.input_tokens != null + ) { inputBucket = inputBucket.add( - calculateTieredCostWithSeparatePrices(usage.input_tokens, inputCostPerToken, inputAbove200k) + multiplyCost(usage.input_tokens, inputCostPerToken * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) ); } else { inputBucket = inputBucket.add(multiplyCost(usage.input_tokens, inputCostPerToken)); } // Output tokens -> output bucket - if (context1mApplied && outputCostPerToken != null && usage.output_tokens != null) { - outputBucket = outputBucket.add( - calculateTieredCost( - usage.output_tokens, - outputCostPerToken, - CONTEXT_1M_OUTPUT_PREMIUM_MULTIPLIER - ) - ); - } else if (outputAbove200k != null && outputCostPerToken != null && usage.output_tokens != null) { + // 与 input 相同:阈值判断基于整次请求的输入上下文,而不是 output bucket 自己的 token 数。 + if (longContextThresholdExceeded && outputAboveThreshold != null && usage.output_tokens != null) { + outputBucket = outputBucket.add(multiplyCost(usage.output_tokens, outputAboveThreshold)); + } else if ( + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && + outputCostPerToken != null && + usage.output_tokens != null + ) { outputBucket = outputBucket.add( - calculateTieredCostWithSeparatePrices( - usage.output_tokens, - outputCostPerToken, - outputAbove200k - ) + multiplyCost(usage.output_tokens, outputCostPerToken * CONTEXT_1M_OUTPUT_PREMIUM_MULTIPLIER) ); } else { outputBucket = outputBucket.add(multiplyCost(usage.output_tokens, outputCostPerToken)); } // Cache costs - const cacheCreationAbove200k = priceData.cache_creation_input_token_cost_above_200k_tokens; - const cacheReadAbove200k = priceData.cache_read_input_token_cost_above_200k_tokens; - const hasRealCacheCreationBase = priceData.cache_creation_input_token_cost != null; - const hasRealCacheReadBase = priceData.cache_read_input_token_cost != null; // Cache creation 5m -> cache_creation bucket - if (context1mApplied && cacheCreation5mCost != null && cache5mTokens != null) { + if ( + longContextThresholdExceeded && + hasRealCacheCreationBase && + cacheCreationAboveThreshold != null && + cache5mTokens != null + ) { cacheCreationBucket = cacheCreationBucket.add( - calculateTieredCost(cache5mTokens, cacheCreation5mCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) + multiplyCost(cache5mTokens, cacheCreationAboveThreshold) ); } else if ( - hasRealCacheCreationBase && - cacheCreationAbove200k != null && + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && cacheCreation5mCost != null && cache5mTokens != null ) { cacheCreationBucket = cacheCreationBucket.add( - calculateTieredCostWithSeparatePrices( - cache5mTokens, - cacheCreation5mCost, - cacheCreationAbove200k - ) + multiplyCost(cache5mTokens, cacheCreation5mCost * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) ); } else { cacheCreationBucket = cacheCreationBucket.add(multiplyCost(cache5mTokens, cacheCreation5mCost)); } // Cache creation 1h -> cache_creation bucket - if (context1mApplied && cacheCreation1hCost != null && cache1hTokens != null) { + if ( + longContextThresholdExceeded && + hasRealCacheCreationBase && + cacheCreation1hAboveThreshold != null && + cache1hTokens != null + ) { cacheCreationBucket = cacheCreationBucket.add( - calculateTieredCost(cache1hTokens, cacheCreation1hCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) + multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold) ); } else if ( - hasRealCacheCreationBase && - cacheCreationAbove200k != null && + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && cacheCreation1hCost != null && cache1hTokens != null ) { cacheCreationBucket = cacheCreationBucket.add( - calculateTieredCostWithSeparatePrices( - cache1hTokens, - cacheCreation1hCost, - cacheCreationAbove200k - ) + multiplyCost(cache1hTokens, cacheCreation1hCost * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) ); } else { cacheCreationBucket = cacheCreationBucket.add(multiplyCost(cache1hTokens, cacheCreation1hCost)); @@ -264,17 +318,13 @@ export function calculateRequestCostBreakdown( // Cache read -> cache_read bucket if ( + longContextThresholdExceeded && hasRealCacheReadBase && - cacheReadAbove200k != null && - cacheReadCost != null && + cacheReadAboveThreshold != null && usage.cache_read_input_tokens != null ) { cacheReadBucket = cacheReadBucket.add( - calculateTieredCostWithSeparatePrices( - usage.cache_read_input_tokens, - cacheReadCost, - cacheReadAbove200k - ) + multiplyCost(usage.cache_read_input_tokens, cacheReadAboveThreshold) ); } else { cacheReadBucket = cacheReadBucket.add( @@ -318,12 +368,21 @@ export function calculateRequestCost( usage: UsageMetrics, priceData: ModelPriceData, multiplier: number = 1.0, - context1mApplied: boolean = false + context1mApplied: boolean = false, + priorityServiceTierApplied: boolean = false ): Decimal { const segments: Decimal[] = []; - const inputCostPerToken = priceData.input_cost_per_token; - const outputCostPerToken = priceData.output_cost_per_token; + const baseInputCostPerToken = priceData.input_cost_per_token; + const baseOutputCostPerToken = priceData.output_cost_per_token; + const inputCostPerToken = + priorityServiceTierApplied && typeof priceData.input_cost_per_token_priority === "number" + ? priceData.input_cost_per_token_priority + : baseInputCostPerToken; + const outputCostPerToken = + priorityServiceTierApplied && typeof priceData.output_cost_per_token_priority === "number" + ? priceData.output_cost_per_token_priority + : baseOutputCostPerToken; const inputCostPerRequest = priceData.input_cost_per_request; if ( @@ -339,19 +398,22 @@ export function calculateRequestCost( const cacheCreation5mCost = priceData.cache_creation_input_token_cost ?? - (inputCostPerToken != null ? inputCostPerToken * 1.25 : undefined); + (baseInputCostPerToken != null ? baseInputCostPerToken * 1.25 : undefined); const cacheCreation1hCost = priceData.cache_creation_input_token_cost_above_1hr ?? - (inputCostPerToken != null ? inputCostPerToken * 2 : undefined) ?? + (baseInputCostPerToken != null ? baseInputCostPerToken * 2 : undefined) ?? cacheCreation5mCost; const cacheReadCost = - priceData.cache_read_input_token_cost ?? - (inputCostPerToken != null - ? inputCostPerToken * 0.1 - : outputCostPerToken != null - ? outputCostPerToken * 0.1 + (priorityServiceTierApplied && + typeof priceData.cache_read_input_token_cost_priority === "number" + ? priceData.cache_read_input_token_cost_priority + : priceData.cache_read_input_token_cost) ?? + (baseInputCostPerToken != null + ? baseInputCostPerToken * 0.1 + : baseOutputCostPerToken != null + ? baseOutputCostPerToken * 0.1 : undefined); // Derive cache creation tokens by TTL @@ -372,126 +434,119 @@ export function calculateRequestCost( } } - // 检查是否有 200K 分层价格(Gemini 等模型) - const inputAbove200k = priceData.input_cost_per_token_above_200k_tokens; - const outputAbove200k = priceData.output_cost_per_token_above_200k_tokens; + const inputAboveThreshold = + priceData.input_cost_per_token_above_272k_tokens ?? + priceData.input_cost_per_token_above_200k_tokens; + const outputAboveThreshold = + priceData.output_cost_per_token_above_272k_tokens ?? + priceData.output_cost_per_token_above_200k_tokens; + const cacheCreationAboveThreshold = + priceData.cache_creation_input_token_cost_above_272k_tokens ?? + priceData.cache_creation_input_token_cost_above_200k_tokens; + const cacheCreation1hAboveThreshold = + priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens ?? + priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens ?? + cacheCreationAboveThreshold; + const cacheReadAboveThreshold = + priceData.cache_read_input_token_cost_above_272k_tokens ?? + priceData.cache_read_input_token_cost_above_200k_tokens; + const longContextThreshold = resolveLongContextThreshold(priceData); + const longContextThresholdExceeded = + getRequestInputContextTokens(usage, cache5mTokens, cache1hTokens) > longContextThreshold; + const hasRealCacheCreationBase = priceData.cache_creation_input_token_cost != null; + const hasRealCacheReadBase = priceData.cache_read_input_token_cost != null; - // 计算 input 费用:优先级 context1mApplied > 200K分层 > 普通 - if (context1mApplied && inputCostPerToken != null && usage.input_tokens != null) { - // Claude 1M context: 使用倍数计算 - segments.push( - calculateTieredCost( - usage.input_tokens, - inputCostPerToken, - CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER - ) - ); - } else if (inputAbove200k != null && inputCostPerToken != null && usage.input_tokens != null) { - // Gemini 等: 使用独立价格字段 + // Input tokens + // 注意:阈值命中后按整次请求的全量 token 应用 long-context 价格。 + if (longContextThresholdExceeded && inputAboveThreshold != null && usage.input_tokens != null) { + segments.push(multiplyCost(usage.input_tokens, inputAboveThreshold)); + } else if ( + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && + inputCostPerToken != null && + usage.input_tokens != null + ) { segments.push( - calculateTieredCostWithSeparatePrices(usage.input_tokens, inputCostPerToken, inputAbove200k) + multiplyCost(usage.input_tokens, inputCostPerToken * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) ); } else { - // 普通计算 segments.push(multiplyCost(usage.input_tokens, inputCostPerToken)); } - // 计算 output 费用:优先级 context1mApplied > 200K分层 > 普通 - if (context1mApplied && outputCostPerToken != null && usage.output_tokens != null) { - // Claude 1M context: 使用倍数计算 - segments.push( - calculateTieredCost( - usage.output_tokens, - outputCostPerToken, - CONTEXT_1M_OUTPUT_PREMIUM_MULTIPLIER - ) - ); - } else if (outputAbove200k != null && outputCostPerToken != null && usage.output_tokens != null) { - // Gemini 等: 使用独立价格字段 + // Output tokens + if (longContextThresholdExceeded && outputAboveThreshold != null && usage.output_tokens != null) { + segments.push(multiplyCost(usage.output_tokens, outputAboveThreshold)); + } else if ( + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && + outputCostPerToken != null && + usage.output_tokens != null + ) { segments.push( - calculateTieredCostWithSeparatePrices( - usage.output_tokens, - outputCostPerToken, - outputAbove200k - ) + multiplyCost(usage.output_tokens, outputCostPerToken * CONTEXT_1M_OUTPUT_PREMIUM_MULTIPLIER) ); } else { - // 普通计算 segments.push(multiplyCost(usage.output_tokens, outputCostPerToken)); } // 缓存相关费用 // 检查是否有 200K 分层的缓存价格 // 注意:只有当价格表中的原始基础价格存在时才启用分层计费,避免派生价格与分层价格混用导致误计费 - const cacheCreationAbove200k = priceData.cache_creation_input_token_cost_above_200k_tokens; - const cacheReadAbove200k = priceData.cache_read_input_token_cost_above_200k_tokens; - const hasRealCacheCreationBase = priceData.cache_creation_input_token_cost != null; - const hasRealCacheReadBase = priceData.cache_read_input_token_cost != null; - // 缓存创建费用(5分钟 TTL):优先级 context1mApplied > 200K分层 > 普通 - if (context1mApplied && cacheCreation5mCost != null && cache5mTokens != null) { - // Claude 1M context: 使用 input 倍数计算(cache creation 属于 input 类别) - segments.push( - calculateTieredCost(cache5mTokens, cacheCreation5mCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) - ); - } else if ( + // 缓存创建费用(5分钟 TTL):优先级 explicit long-context > context1m fallback > 普通 + if ( + longContextThresholdExceeded && hasRealCacheCreationBase && - cacheCreationAbove200k != null && + cacheCreationAboveThreshold != null && + cache5mTokens != null + ) { + segments.push(multiplyCost(cache5mTokens, cacheCreationAboveThreshold)); + } else if ( + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && cacheCreation5mCost != null && cache5mTokens != null ) { - // Gemini 等: 使用独立价格字段 segments.push( - calculateTieredCostWithSeparatePrices( - cache5mTokens, - cacheCreation5mCost, - cacheCreationAbove200k - ) + multiplyCost(cache5mTokens, cacheCreation5mCost * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) ); } else { - // 普通计算 segments.push(multiplyCost(cache5mTokens, cacheCreation5mCost)); } - // 缓存创建费用(1小时 TTL):优先级 context1mApplied > 200K分层 > 普通 - if (context1mApplied && cacheCreation1hCost != null && cache1hTokens != null) { - // Claude 1M context: 使用 input 倍数计算(cache creation 属于 input 类别) - segments.push( - calculateTieredCost(cache1hTokens, cacheCreation1hCost, CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) - ); - } else if ( + // 缓存创建费用(1小时 TTL):优先级 explicit long-context > context1m fallback > 普通 + if ( + longContextThresholdExceeded && hasRealCacheCreationBase && - cacheCreationAbove200k != null && + cacheCreation1hAboveThreshold != null && + cache1hTokens != null + ) { + segments.push(multiplyCost(cache1hTokens, cacheCreation1hAboveThreshold)); + } else if ( + longContextThresholdExceeded && + context1mApplied && + !priorityServiceTierApplied && cacheCreation1hCost != null && cache1hTokens != null ) { - // Gemini 等: 使用独立价格字段 segments.push( - calculateTieredCostWithSeparatePrices( - cache1hTokens, - cacheCreation1hCost, - cacheCreationAbove200k - ) + multiplyCost(cache1hTokens, cacheCreation1hCost * CONTEXT_1M_INPUT_PREMIUM_MULTIPLIER) ); } else { - // 普通计算 segments.push(multiplyCost(cache1hTokens, cacheCreation1hCost)); } // 缓存读取费用 if ( + longContextThresholdExceeded && hasRealCacheReadBase && - cacheReadAbove200k != null && - cacheReadCost != null && + cacheReadAboveThreshold != null && usage.cache_read_input_tokens != null ) { - segments.push( - calculateTieredCostWithSeparatePrices( - usage.cache_read_input_tokens, - cacheReadCost, - cacheReadAbove200k - ) - ); + segments.push(multiplyCost(usage.cache_read_input_tokens, cacheReadAboveThreshold)); } else { segments.push(multiplyCost(usage.cache_read_input_tokens, cacheReadCost)); } diff --git a/src/lib/utils/price-data.ts b/src/lib/utils/price-data.ts index efadf36a3..01a903820 100644 --- a/src/lib/utils/price-data.ts +++ b/src/lib/utils/price-data.ts @@ -1,11 +1,11 @@ import type { ModelPriceData } from "@/types/model-price"; -/** - * 判断价格数据是否包含至少一个可用于计费的价格字段。 - * 避免把数据库中的 `{}` 或仅包含元信息的记录当成有效价格。 - */ -export function hasValidPriceData(priceData: ModelPriceData): boolean { - const numericCosts = [ +function hasValidNumericPrice(values: unknown[]): boolean { + return values.some((value) => typeof value === "number" && Number.isFinite(value) && value >= 0); +} + +function collectNumericCosts(priceData: ModelPriceData): unknown[] { + return [ priceData.input_cost_per_token, priceData.output_cost_per_token, priceData.input_cost_per_request, @@ -16,15 +16,39 @@ export function hasValidPriceData(priceData: ModelPriceData): boolean { priceData.output_cost_per_token_above_200k_tokens, priceData.cache_creation_input_token_cost_above_200k_tokens, priceData.cache_read_input_token_cost_above_200k_tokens, + priceData.cache_creation_input_token_cost_above_1hr_above_200k_tokens, + priceData.input_cost_per_token_above_272k_tokens, + priceData.output_cost_per_token_above_272k_tokens, + priceData.cache_creation_input_token_cost_above_272k_tokens, + priceData.cache_read_input_token_cost_above_272k_tokens, + priceData.cache_creation_input_token_cost_above_1hr_above_272k_tokens, + priceData.input_cost_per_token_priority, + priceData.output_cost_per_token_priority, + priceData.cache_read_input_token_cost_priority, priceData.output_cost_per_image, ]; +} - if ( - numericCosts.some((value) => typeof value === "number" && Number.isFinite(value) && value >= 0) - ) { +/** + * 判断价格数据是否包含至少一个可用于计费的价格字段。 + * 避免把数据库中的 `{}` 或仅包含元信息的记录当成有效价格。 + */ +export function hasValidPriceData(priceData: ModelPriceData): boolean { + if (hasValidNumericPrice(collectNumericCosts(priceData))) { return true; } + const pricing = priceData.pricing; + if (pricing && typeof pricing === "object" && !Array.isArray(pricing)) { + for (const value of Object.values(pricing)) { + if (value && typeof value === "object" && !Array.isArray(value)) { + if (hasValidNumericPrice(Object.values(value))) { + return true; + } + } + } + } + const searchCosts = priceData.search_context_cost_per_query; if (searchCosts) { const searchCostFields = [ @@ -32,9 +56,7 @@ export function hasValidPriceData(priceData: ModelPriceData): boolean { searchCosts.search_context_size_low, searchCosts.search_context_size_medium, ]; - return searchCostFields.some( - (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 - ); + return hasValidNumericPrice(searchCostFields); } return false; diff --git a/src/lib/utils/pricing-resolution.ts b/src/lib/utils/pricing-resolution.ts new file mode 100644 index 000000000..6b440f9e9 --- /dev/null +++ b/src/lib/utils/pricing-resolution.ts @@ -0,0 +1,405 @@ +import type { ModelPrice, ModelPriceData } from "@/types/model-price"; +import type { Provider } from "@/types/provider"; +import { hasValidPriceData } from "./price-data"; + +export type ResolvedPricingSource = + | "local_manual" + | "cloud_exact" + | "cloud_model_fallback" + | "priority_fallback" + | "single_provider_top_level" + | "official_fallback"; + +export interface ResolvedPricing { + resolvedModelName: string; + resolvedPricingProviderKey: string; + source: ResolvedPricingSource; + priceData: ModelPriceData; + pricingNode?: Record | null; +} + +interface ModelRecordCandidate { + modelName: string | null; + record: ModelPrice | null; + isPrimary: boolean; +} + +interface PricingKeyCandidate { + key: string; + type: "exact" | "official"; +} + +export interface ResolvePricingForModelRecordsInput { + provider: Provider | null | undefined; + primaryModelName: string | null; + fallbackModelName: string | null; + primaryRecord: ModelPrice | null; + fallbackRecord: ModelPrice | null; +} + +const DETAIL_FIELDS = [ + "input_cost_per_token", + "output_cost_per_token", + "input_cost_per_request", + "cache_creation_input_token_cost", + "cache_creation_input_token_cost_above_1hr", + "cache_read_input_token_cost", + "input_cost_per_token_above_200k_tokens", + "output_cost_per_token_above_200k_tokens", + "cache_creation_input_token_cost_above_200k_tokens", + "cache_read_input_token_cost_above_200k_tokens", + "cache_creation_input_token_cost_above_1hr_above_200k_tokens", + "input_cost_per_token_above_272k_tokens", + "output_cost_per_token_above_272k_tokens", + "cache_creation_input_token_cost_above_272k_tokens", + "cache_read_input_token_cost_above_272k_tokens", + "cache_creation_input_token_cost_above_1hr_above_272k_tokens", + "input_cost_per_token_priority", + "output_cost_per_token_priority", + "cache_read_input_token_cost_priority", + "output_cost_per_image", + "input_cost_per_image", +] as const; + +const DETAIL_TIE_BREAK_ORDER = [ + "openrouter", + "opencode", + "cloudflare-ai-gateway", + "github-copilot", + "chatgpt", +] as const; + +function pushUnique( + candidates: PricingKeyCandidate[], + key: string, + type: PricingKeyCandidate["type"] +) { + if (!key || candidates.some((candidate) => candidate.key === key)) { + return; + } + candidates.push({ key, type }); +} + +function normalizeText(value: string | null | undefined): string { + return (value ?? "").trim().toLowerCase(); +} + +function extractHost(urlValue: string | null | undefined): string { + if (!urlValue) return ""; + try { + return new URL(urlValue).host.toLowerCase(); + } catch { + return ""; + } +} + +function getOfficialProviderKeys( + modelName: string | null | undefined, + priceData?: ModelPriceData +): string[] { + const family = normalizeText( + typeof priceData?.model_family === "string" ? priceData.model_family : "" + ); + const normalizedModelName = normalizeText(modelName); + + if ( + family === "gpt" || + family === "gpt-pro" || + normalizedModelName.startsWith("gpt-") || + normalizedModelName.includes("chatgpt") + ) { + return ["openai"]; + } + + if (family.startsWith("claude") || normalizedModelName.startsWith("claude")) { + return ["anthropic"]; + } + + if (family.includes("gemini") || normalizedModelName.startsWith("gemini")) { + return ["vertex_ai", "vertex", "google"]; + } + + return []; +} + +export function resolvePricingKeyCandidates( + provider: Provider | null | undefined, + modelName: string | null | undefined, + priceData?: ModelPriceData +): PricingKeyCandidate[] { + const candidates: PricingKeyCandidate[] = []; + const name = normalizeText(provider?.name); + const url = normalizeText(provider?.url); + const host = extractHost(provider?.url); + + if (name.includes("openrouter") || host.includes("openrouter")) { + pushUnique(candidates, "openrouter", "exact"); + } + if (name.includes("opencode") || host.includes("opencode")) { + pushUnique(candidates, "opencode", "exact"); + } + if ( + name.includes("cloudflare") || + host.includes("cloudflare") || + url.includes("cloudflare-ai-gateway") + ) { + pushUnique(candidates, "cloudflare-ai-gateway", "exact"); + } + if (name.includes("github") || name.includes("copilot") || host.includes("githubcopilot")) { + pushUnique(candidates, "github-copilot", "exact"); + } + if (name.includes("chatgpt") || host.includes("chatgpt.com")) { + pushUnique(candidates, "chatgpt", "exact"); + } + if (name.includes("openai") || host.includes("openai.com") || host.includes("api.openai.com")) { + pushUnique(candidates, "openai", "exact"); + } + if (name.includes("anthropic") || host.includes("anthropic.com")) { + pushUnique(candidates, "anthropic", "exact"); + } + if (name.includes("vertex") || host.includes("googleapis.com") || name.includes("google")) { + pushUnique(candidates, "vertex_ai", "exact"); + pushUnique(candidates, "vertex", "exact"); + pushUnique(candidates, "google", "exact"); + } + + for (const officialKey of getOfficialProviderKeys(modelName, priceData)) { + pushUnique(candidates, officialKey, "official"); + } + + return candidates; +} + +function getPricingMap(record: ModelPrice | null): Record> | null { + const pricing = record?.priceData?.pricing; + if (!pricing || typeof pricing !== "object" || Array.isArray(pricing)) { + return null; + } + return pricing; +} + +function mergePriceData( + base: ModelPriceData, + pricingNode: Record | null, + pricingProviderKey: string +): ModelPriceData { + if (!pricingNode) { + return typeof base.selected_pricing_provider === "string" + ? { + ...base, + selected_pricing_provider: base.selected_pricing_provider, + } + : { ...base }; + } + + return { + ...base, + ...pricingNode, + pricing: base.pricing, + selected_pricing_provider: pricingProviderKey, + }; +} + +function getDetailScore(pricingNode: Record): number { + return DETAIL_FIELDS.reduce((score, field) => { + const value = pricingNode[field]; + return typeof value === "number" && Number.isFinite(value) ? score + 1 : score; + }, 0); +} + +function compareDetailKeys( + a: string, + b: string, + pricingMap: Record> +): number { + const scoreDiff = getDetailScore(pricingMap[b] ?? {}) - getDetailScore(pricingMap[a] ?? {}); + if (scoreDiff !== 0) return scoreDiff; + + const indexA = DETAIL_TIE_BREAK_ORDER.indexOf(a as (typeof DETAIL_TIE_BREAK_ORDER)[number]); + const indexB = DETAIL_TIE_BREAK_ORDER.indexOf(b as (typeof DETAIL_TIE_BREAK_ORDER)[number]); + + if (indexA >= 0 || indexB >= 0) { + if (indexA < 0) return 1; + if (indexB < 0) return -1; + return indexA - indexB; + } + + return a.localeCompare(b); +} + +function resolveManualPricing( + record: ModelPrice, + modelName: string | null +): ResolvedPricing | null { + if (!hasValidPriceData(record.priceData)) { + return null; + } + + const resolvedPricingProviderKey = + (typeof record.priceData.selected_pricing_provider === "string" && + record.priceData.selected_pricing_provider.trim()) || + (typeof record.priceData.litellm_provider === "string" && + record.priceData.litellm_provider.trim()) || + "manual"; + + return { + resolvedModelName: modelName ?? record.modelName, + resolvedPricingProviderKey, + source: "local_manual", + priceData: mergePriceData(record.priceData, null, resolvedPricingProviderKey), + pricingNode: null, + }; +} + +function resolveFromPricingMap( + candidate: ModelRecordCandidate, + keyCandidates: PricingKeyCandidate[], + type: PricingKeyCandidate["type"] +): ResolvedPricing | null { + const pricingMap = getPricingMap(candidate.record); + if (!candidate.record || !pricingMap) { + return null; + } + + for (const keyCandidate of keyCandidates) { + if (keyCandidate.type !== type) continue; + const pricingNode = pricingMap[keyCandidate.key]; + if (!pricingNode) continue; + + const mergedPriceData = mergePriceData( + candidate.record.priceData, + pricingNode, + keyCandidate.key + ); + if (!hasValidPriceData(mergedPriceData)) { + continue; + } + + const source: ResolvedPricingSource = + type === "official" + ? "official_fallback" + : candidate.isPrimary + ? "cloud_exact" + : "cloud_model_fallback"; + + return { + resolvedModelName: candidate.modelName ?? candidate.record.modelName, + resolvedPricingProviderKey: keyCandidate.key, + source, + priceData: mergedPriceData, + pricingNode, + }; + } + + return null; +} + +function resolveDetailedFallback(candidate: ModelRecordCandidate): ResolvedPricing | null { + const pricingMap = getPricingMap(candidate.record); + if (!candidate.record || !pricingMap) { + return null; + } + + const keys = Object.keys(pricingMap).sort((a, b) => compareDetailKeys(a, b, pricingMap)); + const selectedKey = keys[0]; + if (!selectedKey) { + return null; + } + + const pricingNode = pricingMap[selectedKey]; + const mergedPriceData = mergePriceData(candidate.record.priceData, pricingNode, selectedKey); + if (!hasValidPriceData(mergedPriceData)) { + return null; + } + + return { + resolvedModelName: candidate.modelName ?? candidate.record.modelName, + resolvedPricingProviderKey: selectedKey, + source: "priority_fallback", + priceData: mergedPriceData, + pricingNode, + }; +} + +function resolveTopLevel(candidate: ModelRecordCandidate): ResolvedPricing | null { + if (!candidate.record || !hasValidPriceData(candidate.record.priceData)) { + return null; + } + + const officialKeys = getOfficialProviderKeys(candidate.modelName, candidate.record.priceData); + const resolvedPricingProviderKey = + (typeof candidate.record.priceData.selected_pricing_provider === "string" && + candidate.record.priceData.selected_pricing_provider.trim()) || + (typeof candidate.record.priceData.litellm_provider === "string" && + candidate.record.priceData.litellm_provider.trim()) || + officialKeys[0] || + candidate.record.modelName; + + return { + resolvedModelName: candidate.modelName ?? candidate.record.modelName, + resolvedPricingProviderKey, + source: + candidate.record.source === "manual" + ? "local_manual" + : candidate.isPrimary + ? "single_provider_top_level" + : "cloud_model_fallback", + priceData: mergePriceData(candidate.record.priceData, null, resolvedPricingProviderKey), + pricingNode: null, + }; +} + +export function resolvePricingForModelRecords( + input: ResolvePricingForModelRecordsInput +): ResolvedPricing | null { + const candidates: ModelRecordCandidate[] = [ + { + modelName: input.primaryModelName, + record: input.primaryRecord, + isPrimary: true, + }, + ]; + + if (input.fallbackModelName && input.fallbackModelName !== input.primaryModelName) { + candidates.push({ + modelName: input.fallbackModelName, + record: input.fallbackRecord, + isPrimary: false, + }); + } + + for (const candidate of candidates) { + if (candidate.record?.source === "manual") { + const resolved = resolveManualPricing(candidate.record, candidate.modelName); + if (resolved) return resolved; + } + } + + const keyCandidates = resolvePricingKeyCandidates( + input.provider, + input.primaryModelName ?? input.fallbackModelName, + input.primaryRecord?.priceData ?? input.fallbackRecord?.priceData + ); + + for (const candidate of candidates) { + const resolved = resolveFromPricingMap(candidate, keyCandidates, "exact"); + if (resolved) return resolved; + } + + for (const candidate of candidates) { + const resolved = resolveFromPricingMap(candidate, keyCandidates, "official"); + if (resolved) return resolved; + } + + for (const candidate of candidates) { + const resolved = resolveDetailedFallback(candidate); + if (resolved) return resolved; + } + + for (const candidate of candidates) { + const resolved = resolveTopLevel(candidate); + if (resolved) return resolved; + } + + return null; +} diff --git a/src/lib/utils/special-settings.ts b/src/lib/utils/special-settings.ts index 6e68c8a34..ffd33ada6 100644 --- a/src/lib/utils/special-settings.ts +++ b/src/lib/utils/special-settings.ts @@ -1,4 +1,3 @@ -import { CONTEXT_1M_BETA_HEADER } from "@/lib/special-attributes"; import type { SpecialSetting } from "@/types/special-settings"; type BuildUnifiedSpecialSettingsParams = { @@ -23,7 +22,7 @@ type BuildUnifiedSpecialSettingsParams = { */ cacheTtlApplied?: string | null; /** - * 1M 上下文是否应用(用于展示 1M 标头覆写命中) + * 1M 上下文是否应用(保留参数用于兼容调用方;不再自动派生 header 覆写审计) */ context1mApplied?: boolean | null; }; @@ -108,6 +107,23 @@ function buildSettingKey(setting: SpecialSetting): string { setting.preference, setting.hadGoogleSearchInRequest, ]); + case "pricing_resolution": + return JSON.stringify([ + setting.type, + setting.hit, + setting.modelName, + setting.resolvedModelName, + setting.resolvedPricingProviderKey, + setting.source, + ]); + case "codex_service_tier_result": + return JSON.stringify([ + setting.type, + setting.hit, + setting.requestedServiceTier, + setting.actualServiceTier, + setting.effectivePriority, + ]); default: { // 兜底:保证即使未来扩展类型也不会导致运行时崩溃 const _exhaustive: never = setting; @@ -152,13 +168,21 @@ export function buildUnifiedSpecialSettings( }); } - if (params.context1mApplied === true) { + const hasExplicitAnthropicContext1m = base.some( + (item) => item.type === "anthropic_context_1m_header_override" + ); + const hasCodexServiceTierResult = base.some((item) => item.type === "codex_service_tier_result"); + if ( + params.context1mApplied === true && + !hasExplicitAnthropicContext1m && + !hasCodexServiceTierResult + ) { derived.push({ type: "anthropic_context_1m_header_override", scope: "request_header", hit: true, header: "anthropic-beta", - flag: CONTEXT_1M_BETA_HEADER, + flag: "context-1m-2025-08-07", }); } @@ -185,6 +209,17 @@ export function hasPriorityServiceTierSpecialSetting( return false; } + const codexServiceTierResult = specialSettings.find( + (setting): setting is Extract => + setting.type === "codex_service_tier_result" + ); + if (codexServiceTierResult) { + if (codexServiceTierResult.actualServiceTier != null) { + return codexServiceTierResult.actualServiceTier === "priority"; + } + return codexServiceTierResult.effectivePriority; + } + return specialSettings.some( (setting) => setting.type === "provider_parameter_override" && @@ -194,3 +229,33 @@ export function hasPriorityServiceTierSpecialSetting( ) ); } + +export function getPriorityServiceTierSpecialSetting( + specialSettings?: SpecialSetting[] | null +): Extract | null { + if (!Array.isArray(specialSettings) || specialSettings.length === 0) { + return null; + } + + return ( + specialSettings.find( + (setting): setting is Extract => + setting.type === "codex_service_tier_result" + ) ?? null + ); +} + +export function getPricingResolutionSpecialSetting( + specialSettings?: SpecialSetting[] | null +): Extract | null { + if (!Array.isArray(specialSettings) || specialSettings.length === 0) { + return null; + } + + return ( + specialSettings.find( + (setting): setting is Extract => + setting.type === "pricing_resolution" + ) ?? null + ); +} diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index 764d0e442..f8ad21c67 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -1,6 +1,6 @@ "use server"; -import { desc, eq, sql } from "drizzle-orm"; +import { desc, eq, inArray, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { modelPrices } from "@/drizzle/schema"; import { logger } from "@/lib/logger"; @@ -48,7 +48,6 @@ export async function findLatestPriceByModel(modelName: string): Promise { + try { + const selection = { + id: modelPrices.id, + modelName: modelPrices.modelName, + priceData: modelPrices.priceData, + source: modelPrices.source, + createdAt: modelPrices.createdAt, + updatedAt: modelPrices.updatedAt, + }; + + const [price] = await db + .select(selection) + .from(modelPrices) + .where(sql`${modelPrices.modelName} = ${modelName} AND ${modelPrices.source} = ${source}`) + .orderBy(sql`${modelPrices.createdAt} DESC NULLS LAST`, desc(modelPrices.id)) + .limit(1); + + if (!price) return null; + return toModelPrice(price); + } catch (error) { + logger.error("[ModelPrice] Failed to query latest price by model and source", { + modelName, + source, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } +} + +export async function findLatestPricesByModels( + modelNames: string[] +): Promise> { + const uniqueNames = Array.from(new Set(modelNames.map((name) => name.trim()).filter(Boolean))); + if (uniqueNames.length === 0) { + return new Map(); + } + + const query = sql` + SELECT DISTINCT ON (model_name) + id, + model_name as "modelName", + price_data as "priceData", + source, + created_at as "createdAt", + updated_at as "updatedAt" + FROM model_prices + WHERE ${inArray(modelPrices.modelName, uniqueNames)} + ORDER BY + model_name, + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC + `; + + const result = await db.execute(query); + const rows = Array.from(result).map(toModelPrice); + return new Map(rows.map((row) => [row.modelName, row])); +} + /** * 获取所有模型的最新价格(非分页版本,保持向后兼容) * 注意:使用原生 SQL(DISTINCT ON),并确保 manual 来源优先 diff --git a/src/types/model-price.ts b/src/types/model-price.ts index 295d00f77..59da132ba 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -17,6 +17,19 @@ export interface ModelPriceData { output_cost_per_token_above_200k_tokens?: number; cache_creation_input_token_cost_above_200k_tokens?: number; cache_read_input_token_cost_above_200k_tokens?: number; + cache_creation_input_token_cost_above_1hr_above_200k_tokens?: number; + + // 272K 分层价格(GPT-5.4 等模型保留扩展) + input_cost_per_token_above_272k_tokens?: number; + output_cost_per_token_above_272k_tokens?: number; + cache_creation_input_token_cost_above_272k_tokens?: number; + cache_read_input_token_cost_above_272k_tokens?: number; + cache_creation_input_token_cost_above_1hr_above_272k_tokens?: number; + + // 优先服务等级价格(例如 OpenAI priority tier) + input_cost_per_token_priority?: number; + output_cost_per_token_priority?: number; + cache_read_input_token_cost_priority?: number; // 图片生成价格 output_cost_per_image?: number; @@ -38,10 +51,14 @@ export interface ModelPriceData { display_name?: string; litellm_provider?: string; providers?: string[]; + pricing?: Record>; + selected_pricing_provider?: string; + selected_pricing_source_model?: string; + selected_pricing_resolution?: "manual_pin"; max_input_tokens?: number; max_output_tokens?: number; max_tokens?: number; - mode?: "chat" | "image_generation" | "completion"; + mode?: "chat" | "image_generation" | "completion" | "responses"; // 支持的功能 supports_assistant_prefill?: boolean; diff --git a/src/types/special-settings.ts b/src/types/special-settings.ts index e07e0bb8b..aae2ce19c 100644 --- a/src/types/special-settings.ts +++ b/src/types/special-settings.ts @@ -16,7 +16,9 @@ export type SpecialSetting = | ClaudeMetadataUserIdInjectionSpecialSetting | AnthropicCacheTtlHeaderOverrideSpecialSetting | AnthropicContext1mHeaderOverrideSpecialSetting - | GeminiGoogleSearchOverrideSpecialSetting; + | GeminiGoogleSearchOverrideSpecialSetting + | PricingResolutionSpecialSetting + | CodexServiceTierResultSpecialSetting; export type SpecialSettingChangeValue = string | number | boolean | null; @@ -198,3 +200,28 @@ export type GeminiGoogleSearchOverrideSpecialSetting = { preference: "enabled" | "disabled"; hadGoogleSearchInRequest: boolean; }; + +export type PricingResolutionSpecialSetting = { + type: "pricing_resolution"; + scope: "billing"; + hit: boolean; + modelName: string; + resolvedModelName: string; + resolvedPricingProviderKey: string; + source: + | "local_manual" + | "cloud_exact" + | "cloud_model_fallback" + | "priority_fallback" + | "single_provider_top_level" + | "official_fallback"; +}; + +export type CodexServiceTierResultSpecialSetting = { + type: "codex_service_tier_result"; + scope: "response"; + hit: boolean; + requestedServiceTier: string | null; + actualServiceTier: string | null; + effectivePriority: boolean; +}; diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index 580275668..396d73b6f 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -141,11 +141,15 @@ function createSession({ redirectedModel, sessionId, messageId, + providerOverrides, + requestMessage, }: { originalModel: string; redirectedModel: string; sessionId: string; messageId: number; + providerOverrides?: Record; + requestMessage?: Record; }): ProxySession { const session = new ( ProxySession as unknown as { @@ -167,7 +171,7 @@ function createSession({ requestUrl: new URL("http://localhost/v1/messages"), headers: new Headers(), headerLog: "", - request: { message: {}, log: "(test)", model: redirectedModel }, + request: { message: requestMessage ?? {}, log: "(test)", model: redirectedModel }, userAgent: null, context: {}, clientAbortSignal: null, @@ -179,9 +183,11 @@ function createSession({ const provider = { id: 99, name: "test-provider", + url: "https://api.anthropic.com", providerType: "claude", costMultiplier: 1.0, streamingIdleTimeoutMs: 0, + ...providerOverrides, } as any; const user = { @@ -216,11 +222,15 @@ function createSession({ return session; } -function createNonStreamResponse(usage: { input_tokens: number; output_tokens: number }): Response { +function createNonStreamResponse( + usage: { input_tokens: number; output_tokens: number }, + extras?: Record +): Response { return new Response( JSON.stringify({ type: "message", usage, + ...(extras ?? {}), }), { status: 200, @@ -369,6 +379,245 @@ describe("Billing model source - Redis session cost vs DB cost", () => { expect(redirected.sessionCostUsd).toBe("50"); expect(original.sessionCostUsd).not.toBe(redirected.sessionCostUsd); }); + + it("nested pricing: gpt-5.4 alias model should bill from pricing.openai when provider is chatgpt", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); + vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); + vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); + + vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "gpt-5.4") { + return makePriceRecord(modelName, { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 2.5, + output_cost_per_token: 15, + }, + }, + }); + } + return null; + }); + + const dbCosts: string[] = []; + vi.mocked(updateMessageRequestCost).mockImplementation( + async (_id: number, costUsd: unknown) => { + dbCosts.push(String(costUsd)); + } + ); + + const sessionCosts: string[] = []; + vi.mocked(SessionManager.updateSessionUsage).mockImplementation( + async (_sessionId: string, payload: Record) => { + if (typeof payload.costUsd === "string") { + sessionCosts.push(payload.costUsd); + } + } + ); + + const session = createSession({ + originalModel: "gpt-5.4", + redirectedModel: "gpt-5.4", + sessionId: "sess-gpt54-chatgpt", + messageId: 3100, + providerOverrides: { + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + providerType: "codex", + }, + }); + + const response = createNonStreamResponse({ input_tokens: 2, output_tokens: 3 }); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(dbCosts[0]).toBe("50"); + expect(sessionCosts[0]).toBe("50"); + }); + + it("codex fast: uses priority pricing when response reports service_tier=priority", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); + vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); + vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); + + vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "gpt-5.4") { + return makePriceRecord(modelName, { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 1, + output_cost_per_token: 10, + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + }, + }, + }); + } + return null; + }); + + const dbCosts: string[] = []; + vi.mocked(updateMessageRequestCost).mockImplementation( + async (_id: number, costUsd: unknown) => { + dbCosts.push(String(costUsd)); + } + ); + + const sessionCosts: string[] = []; + vi.mocked(SessionManager.updateSessionUsage).mockImplementation( + async (_sessionId: string, payload: Record) => { + if (typeof payload.costUsd === "string") { + sessionCosts.push(payload.costUsd); + } + } + ); + + const session = createSession({ + originalModel: "gpt-5.4", + redirectedModel: "gpt-5.4", + sessionId: "sess-gpt54-priority-actual", + messageId: 3200, + providerOverrides: { + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + providerType: "codex", + }, + requestMessage: { service_tier: "default" }, + }); + + const response = createNonStreamResponse( + { input_tokens: 2, output_tokens: 3 }, + { service_tier: "priority" } + ); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(dbCosts[0]).toBe("64"); + expect(sessionCosts[0]).toBe("64"); + }); + + it("codex fast: falls back to requested priority pricing when response omits service_tier", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); + vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); + vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); + + vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "gpt-5.4") { + return makePriceRecord(modelName, { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 1, + output_cost_per_token: 10, + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + }, + }, + }); + } + return null; + }); + + const dbCosts: string[] = []; + vi.mocked(updateMessageRequestCost).mockImplementation( + async (_id: number, costUsd: unknown) => { + dbCosts.push(String(costUsd)); + } + ); + + const session = createSession({ + originalModel: "gpt-5.4", + redirectedModel: "gpt-5.4", + sessionId: "sess-gpt54-priority-requested", + messageId: 3201, + providerOverrides: { + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + providerType: "codex", + }, + requestMessage: { service_tier: "priority" }, + }); + + const response = createNonStreamResponse({ input_tokens: 2, output_tokens: 3 }); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(dbCosts[0]).toBe("64"); + }); + + it("codex fast: does not use priority pricing when response explicitly reports non-priority tier", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(updateMessageRequestDetails).mockResolvedValue(undefined); + vi.mocked(updateMessageRequestDuration).mockResolvedValue(undefined); + vi.mocked(SessionManager.storeSessionResponse).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackUserDailyCost).mockResolvedValue(undefined); + vi.mocked(SessionTracker.refreshSession).mockResolvedValue(undefined); + + vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "gpt-5.4") { + return makePriceRecord(modelName, { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 1, + output_cost_per_token: 10, + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + }, + }, + }); + } + return null; + }); + + const dbCosts: string[] = []; + vi.mocked(updateMessageRequestCost).mockImplementation( + async (_id: number, costUsd: unknown) => { + dbCosts.push(String(costUsd)); + } + ); + + const session = createSession({ + originalModel: "gpt-5.4", + redirectedModel: "gpt-5.4", + sessionId: "sess-gpt54-priority-downgraded", + messageId: 3202, + providerOverrides: { + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + providerType: "codex", + }, + requestMessage: { service_tier: "priority" }, + }); + + const response = createNonStreamResponse( + { input_tokens: 2, output_tokens: 3 }, + { service_tier: "default" } + ); + await ProxyResponseHandler.dispatch(session, response); + await drainAsyncTasks(); + + expect(dbCosts[0]).toBe("32"); + }); }); describe("价格表缺失/查询失败:不计费放行", () => { diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts index a464440fa..3fe297e2f 100644 --- a/tests/unit/actions/model-prices.test.ts +++ b/tests/unit/actions/model-prices.test.ts @@ -7,6 +7,7 @@ const revalidatePathMock = vi.fn(); // Repository mocks const findLatestPriceByModelMock = vi.fn(); +const findLatestPriceByModelAndSourceMock = vi.fn(); const findAllLatestPricesMock = vi.fn(); const createModelPriceMock = vi.fn(); const upsertModelPriceMock = vi.fn(); @@ -36,6 +37,8 @@ vi.mock("@/lib/logger", () => ({ vi.mock("@/repository/model-price", () => ({ findLatestPriceByModel: () => findLatestPriceByModelMock(), + findLatestPriceByModelAndSource: (...args: unknown[]) => + findLatestPriceByModelAndSourceMock(...args), createModelPrice: (...args: unknown[]) => createModelPriceMock(...args), upsertModelPrice: (...args: unknown[]) => upsertModelPriceMock(...args), deleteModelPriceByName: (...args: unknown[]) => deleteModelPriceByNameMock(...args), @@ -490,4 +493,61 @@ describe("Model Price Actions", () => { expect(createModelPriceMock).not.toHaveBeenCalled(); }); }); + + describe("pinModelPricingProviderAsManual", () => { + it("should pin a cloud provider pricing node as a local manual model price", async () => { + findLatestPriceByModelAndSourceMock.mockResolvedValue( + makeMockPrice( + "gpt-5.4", + { + mode: "responses", + display_name: "GPT-5.4", + model_family: "gpt", + pricing: { + openrouter: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + }, + }, + }, + "litellm" + ) + ); + upsertModelPriceMock.mockResolvedValue( + makeMockPrice( + "gpt-5.4", + { + mode: "responses", + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + selected_pricing_provider: "openrouter", + }, + "manual" + ) + ); + + const { pinModelPricingProviderAsManual } = await import("@/actions/model-prices"); + const result = await pinModelPricingProviderAsManual({ + modelName: "gpt-5.4", + pricingProviderKey: "openrouter", + }); + + expect(result.ok).toBe(true); + expect(findLatestPriceByModelAndSourceMock).toHaveBeenCalledWith("gpt-5.4", "litellm"); + expect(upsertModelPriceMock).toHaveBeenCalledWith( + "gpt-5.4", + expect.objectContaining({ + mode: "responses", + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + selected_pricing_provider: "openrouter", + selected_pricing_source_model: "gpt-5.4", + selected_pricing_resolution: "manual_pin", + }) + ); + }); + }); }); diff --git a/tests/unit/lib/cost-calculation-long-context.test.ts b/tests/unit/lib/cost-calculation-long-context.test.ts new file mode 100644 index 000000000..0ee5fdca2 --- /dev/null +++ b/tests/unit/lib/cost-calculation-long-context.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { calculateRequestCost } from "@/lib/utils/cost-calculation"; + +describe("calculateRequestCost long-context", () => { + test("uses long-context output pricing when total input context exceeds threshold", () => { + const cost = calculateRequestCost( + { + input_tokens: 250000, + output_tokens: 100000, + }, + { + mode: "chat", + model_family: "claude-sonnet", + input_cost_per_token: 0.000003, + input_cost_per_token_above_200k_tokens: 0.000006, + output_cost_per_token: 0.000015, + output_cost_per_token_above_200k_tokens: 0.0000225, + }, + 1, + false + ); + + expect(Number(cost.toString())).toBe(3.75); + }); + + test("does not charge 1h cache long-context price when base cache creation price is missing", () => { + const cost = calculateRequestCost( + { + input_tokens: 250000, + cache_creation_1h_input_tokens: 1000, + }, + { + mode: "chat", + model_family: "gpt", + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_creation_input_token_cost_above_1hr_above_272k_tokens: 0.5, + }, + 1, + false, + false + ); + + expect(Number(cost.toString())).toBe(0.63); + }); +}); diff --git a/tests/unit/lib/cost-calculation-priority.test.ts b/tests/unit/lib/cost-calculation-priority.test.ts new file mode 100644 index 000000000..17f020ee9 --- /dev/null +++ b/tests/unit/lib/cost-calculation-priority.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "vitest"; +import { calculateRequestCost } from "@/lib/utils/cost-calculation"; +import type { ModelPriceData } from "@/types/model-price"; + +function makePriceData(overrides: Partial = {}): ModelPriceData { + return { + mode: "responses", + input_cost_per_token: 1, + output_cost_per_token: 10, + cache_read_input_token_cost: 0.1, + input_cost_per_token_priority: 2, + output_cost_per_token_priority: 20, + cache_read_input_token_cost_priority: 0.2, + ...overrides, + }; +} + +describe("calculateRequestCost priority service tier", () => { + test("uses priority pricing fields when priority service tier is applied", () => { + const cost = calculateRequestCost( + { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: 5 }, + makePriceData(), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(65); + }); + + test("falls back to regular pricing when priority fields are absent", () => { + const cost = calculateRequestCost( + { input_tokens: 2, output_tokens: 3, cache_read_input_tokens: 5 }, + makePriceData({ + input_cost_per_token_priority: undefined, + output_cost_per_token_priority: undefined, + cache_read_input_token_cost_priority: undefined, + }), + 1, + false, + true + ); + + expect(Number(cost.toString())).toBe(32.5); + }); +}); diff --git a/tests/unit/lib/utils/pricing-resolution.test.ts b/tests/unit/lib/utils/pricing-resolution.test.ts new file mode 100644 index 000000000..9a3cc80ec --- /dev/null +++ b/tests/unit/lib/utils/pricing-resolution.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, test } from "vitest"; +import type { ModelPrice } from "@/types/model-price"; +import { resolvePricingForModelRecords } from "@/lib/utils/pricing-resolution"; + +function makeRecord( + modelName: string, + priceData: ModelPrice["priceData"], + source: ModelPrice["source"] = "litellm" +): ModelPrice { + const now = new Date("2026-03-06T00:00:00.000Z"); + return { + id: Math.floor(Math.random() * 100000), + modelName, + priceData, + source, + createdAt: now, + updatedAt: now, + }; +} + +describe("resolvePricingForModelRecords", () => { + test("falls back from chatgpt to openai pricing for gpt-5.4 alias models", () => { + const aliasRecord = makeRecord("gpt-5.4", { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + }, + openrouter: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: { + id: 1, + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + } as never, + primaryModelName: "gpt-5.4", + fallbackModelName: null, + primaryRecord: aliasRecord, + fallbackRecord: null, + }); + + expect(resolved).not.toBeNull(); + expect(resolved?.resolvedPricingProviderKey).toBe("openai"); + expect(resolved?.source).toBe("official_fallback"); + expect(resolved?.priceData.input_cost_per_token).toBe(0.0000025); + }); + + test("falls back from redirected date model to alias model for provider-specific pricing", () => { + const datedRecord = makeRecord("gpt-5.4-2026-03-05", { + mode: "responses", + model_family: "gpt", + litellm_provider: "openai", + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + pricing: { + openai: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + }, + }, + }); + + const aliasRecord = makeRecord("gpt-5.4", { + mode: "responses", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openrouter: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + cache_read_input_token_cost: 2.5e-7, + }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: { + id: 2, + name: "OpenRouter", + url: "https://openrouter.ai/api/v1", + } as never, + primaryModelName: "gpt-5.4-2026-03-05", + fallbackModelName: "gpt-5.4", + primaryRecord: datedRecord, + fallbackRecord: aliasRecord, + }); + + expect(resolved).not.toBeNull(); + expect(resolved?.resolvedModelName).toBe("gpt-5.4"); + expect(resolved?.resolvedPricingProviderKey).toBe("openrouter"); + expect(resolved?.source).toBe("cloud_model_fallback"); + }); + + test("prefers local manual prices over cloud multi-provider pricing", () => { + const manualRecord = makeRecord( + "gpt-5.4", + { + mode: "responses", + input_cost_per_token: 0.0000099, + output_cost_per_token: 0.0000199, + selected_pricing_provider: "manual-custom", + }, + "manual" + ); + + const cloudRecord = makeRecord("gpt-5.4", { + mode: "responses", + pricing: { + openai: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + }, + }, + }); + + const resolved = resolvePricingForModelRecords({ + provider: { + id: 1, + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + } as never, + primaryModelName: "gpt-5.4", + fallbackModelName: null, + primaryRecord: manualRecord, + fallbackRecord: cloudRecord, + }); + + expect(resolved).not.toBeNull(); + expect(resolved?.source).toBe("local_manual"); + expect(resolved?.priceData.input_cost_per_token).toBe(0.0000099); + expect(resolved?.resolvedPricingProviderKey).toBe("manual-custom"); + }); +}); diff --git a/tests/unit/lib/utils/special-settings.test.ts b/tests/unit/lib/utils/special-settings.test.ts index 5bd4e041e..f7aabdf4c 100644 --- a/tests/unit/lib/utils/special-settings.test.ts +++ b/tests/unit/lib/utils/special-settings.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "vitest"; import type { SpecialSetting } from "@/types/special-settings"; -import { buildUnifiedSpecialSettings } from "@/lib/utils/special-settings"; +import { + buildUnifiedSpecialSettings, + hasPriorityServiceTierSpecialSetting, +} from "@/lib/utils/special-settings"; describe("buildUnifiedSpecialSettings", () => { test("无任何输入时应返回 null", () => { @@ -72,7 +75,7 @@ describe("buildUnifiedSpecialSettings", () => { ); }); - test("context1mApplied=true 时应派生 anthropic_context_1m_header_override 特殊设置", () => { + test("context1mApplied=true 时应在无显式记录时回退派生 anthropic_context_1m_header_override", () => { const settings = buildUnifiedSpecialSettings({ existing: null, context1mApplied: true, @@ -91,6 +94,26 @@ describe("buildUnifiedSpecialSettings", () => { ); }); + test("context1mApplied=true 但已有 codex service tier result 时不应回退派生 anthropic_context_1m_header_override", () => { + const settings = buildUnifiedSpecialSettings({ + existing: [ + { + type: "codex_service_tier_result", + scope: "response", + hit: true, + requestedServiceTier: "priority", + actualServiceTier: null, + effectivePriority: true, + }, + ], + context1mApplied: true, + }); + + expect(settings?.some((item) => item.type === "anthropic_context_1m_header_override")).toBe( + false + ); + }); + test("应合并 existing specialSettings 与派生 specialSettings", () => { const existing: SpecialSetting[] = [ { @@ -169,3 +192,45 @@ describe("buildUnifiedSpecialSettings", () => { expect(settings?.filter((s) => s.type === "guard_intercept").length).toBe(1); }); }); + +describe("hasPriorityServiceTierSpecialSetting", () => { + test("returns true when codex actual service tier is priority", () => { + expect( + hasPriorityServiceTierSpecialSetting([ + { + type: "codex_service_tier_result", + scope: "response", + hit: true, + requestedServiceTier: "default", + actualServiceTier: "priority", + effectivePriority: true, + }, + ]) + ).toBe(true); + }); + + test("returns false when codex actual service tier is non-priority even if request was priority", () => { + expect( + hasPriorityServiceTierSpecialSetting([ + { + type: "provider_parameter_override", + scope: "provider", + providerId: 1, + providerName: "p", + providerType: "codex", + hit: true, + changed: true, + changes: [{ path: "service_tier", before: null, after: "priority", changed: true }], + }, + { + type: "codex_service_tier_result", + scope: "response", + hit: true, + requestedServiceTier: "priority", + actualServiceTier: "default", + effectivePriority: false, + }, + ]) + ).toBe(false); + }); +}); diff --git a/tests/unit/proxy/pricing-no-price.test.ts b/tests/unit/proxy/pricing-no-price.test.ts index 2dc34301c..28703de48 100644 --- a/tests/unit/proxy/pricing-no-price.test.ts +++ b/tests/unit/proxy/pricing-no-price.test.ts @@ -1,12 +1,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelPriceData } from "@/types/model-price"; import type { SystemSettings } from "@/types/system-config"; -const { cloudSyncRequests, requestCloudPriceTableSyncMock } = vi.hoisted(() => { - const cloudSyncRequests: Array<{ reason: string }> = []; +const asyncTasks: Promise[] = []; +const cloudSyncRequests: Array<{ reason: string }> = []; + +const { requestCloudPriceTableSyncMock } = vi.hoisted(() => { const requestCloudPriceTableSyncMock = vi.fn((payload: { reason: string }) => { cloudSyncRequests.push(payload); }); - return { cloudSyncRequests, requestCloudPriceTableSyncMock }; + return { requestCloudPriceTableSyncMock }; }); vi.mock("@/lib/price-sync/cloud-price-updater", () => ({ @@ -64,7 +67,7 @@ vi.mock("@/lib/proxy-status-tracker", () => ({ import { finalizeRequestStats } from "@/app/v1/_lib/proxy/response-handler"; import { ProxySession } from "@/app/v1/_lib/proxy/session"; import { RateLimitService } from "@/lib/rate-limit"; -import { updateMessageRequestCost } from "@/repository/message"; +import { updateMessageRequestCost, updateMessageRequestDetails } from "@/repository/message"; import { findLatestPriceByModel } from "@/repository/model-price"; import { getSystemSettings } from "@/repository/system-config"; @@ -182,6 +185,31 @@ describe("价格表缺失/查询失败:请求不计费且不报错", () => { vi.clearAllMocks(); }); + it("无 usageMetrics 时仍应写入 request details", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(findLatestPriceByModel).mockResolvedValue(null); + + const session = createSession({ originalModel: "m1", redirectedModel: "m2" }); + await finalizeRequestStats( + session, + JSON.stringify({ type: "message", usage: null }), + 502, + 9, + "bad upstream" + ); + + expect(updateMessageRequestDetails).toHaveBeenCalledWith( + 2000, + expect.objectContaining({ + statusCode: 502, + errorMessage: "bad upstream", + model: "m2", + providerId: 99, + }) + ); + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + }); + it("无价格:应跳过 DB cost 更新与限流 cost 追踪,并触发异步同步", async () => { vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); vi.mocked(findLatestPriceByModel).mockResolvedValue(null); diff --git a/tests/unit/proxy/session.test.ts b/tests/unit/proxy/session.test.ts index 9771ea4df..36792ec9e 100644 --- a/tests/unit/proxy/session.test.ts +++ b/tests/unit/proxy/session.test.ts @@ -385,15 +385,46 @@ describe("ProxySession.getCachedPriceDataByBillingSource", () => { expect(findLatestPriceByModel).toHaveBeenCalledTimes(1); }); - it("应在无模型时返回 null", async () => { + it("应缓存 resolved pricing 避免重复查询", async () => { + const redirectedPriceData: ModelPriceData = { + mode: "responses", + model_family: "gpt", + pricing: { + openai: { + input_cost_per_token: 3, + output_cost_per_token: 4, + }, + }, + }; + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(findLatestPriceByModel).mockResolvedValue( + makePriceRecord("redirected-model", redirectedPriceData) + ); + + const session = createSession({ + originalModel: "original-model", + redirectedModel: "redirected-model", + }); + + const provider = { + id: 77, + name: "ChatGPT", + url: "https://chatgpt.com/backend-api/codex", + providerType: "codex", + } as any; + + await session.getResolvedPricingByBillingSource(provider); + await session.getResolvedPricingByBillingSource(provider); + + expect(findLatestPriceByModel).toHaveBeenCalledTimes(1); + }); + it("无模型时应返回 null", async () => { const session = createSession({ redirectedModel: null }); const result = await session.getCachedPriceDataByBillingSource(); expect(result).toBeNull(); - expect(getSystemSettings).not.toHaveBeenCalled(); - expect(findLatestPriceByModel).not.toHaveBeenCalled(); }); }); diff --git a/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx b/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx new file mode 100644 index 000000000..7c92a1bd6 --- /dev/null +++ b/tests/unit/settings/prices/price-list-multi-provider-ui.test.tsx @@ -0,0 +1,81 @@ +/** + * @vitest-environment happy-dom + */ + +import type { ReactNode } from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { NextIntlClientProvider } from "next-intl"; +import { describe, expect, test } from "vitest"; +import { PriceList } from "@/app/[locale]/settings/prices/_components/price-list"; +import type { ModelPrice } from "@/types/model-price"; +import { loadMessages } from "./test-messages"; + +function render(node: ReactNode) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(node); + }); + + return { + unmount: () => { + act(() => root.unmount()); + container.remove(); + }, + }; +} + +describe("PriceList multi-provider pricing", () => { + test("renders a Multi badge when a model contains multiple provider pricing nodes", () => { + const messages = loadMessages(); + const now = new Date("2026-03-06T00:00:00.000Z"); + + const prices: ModelPrice[] = [ + { + id: 1, + modelName: "gpt-5.4", + priceData: { + mode: "responses", + display_name: "GPT-5.4", + model_family: "gpt", + litellm_provider: "chatgpt", + pricing: { + openai: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + }, + openrouter: { + input_cost_per_token: 0.0000025, + output_cost_per_token: 0.000015, + }, + }, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }, + ]; + + const { unmount } = render( + + + + ); + + expect(document.body.textContent).toContain("Multi"); + expect(document.body.textContent).toContain("$2.50/M"); + expect(document.body.textContent).toContain("$15.00/M"); + unmount(); + }); +});