diff --git a/drizzle/meta/0052_snapshot.json b/drizzle/meta/0052_snapshot.json index 62239cbb4..970c0d530 100644 --- a/drizzle/meta/0052_snapshot.json +++ b/drizzle/meta/0052_snapshot.json @@ -1,5 +1,5 @@ { - "id": "e7a58fbf-6e7a-4c5f-a0ac-255fcf6439d7", + "id": "313bc169-3d11-418a-a91a-89d7a10a5d1f", "prevId": "c7b01fc8-2ed8-4359-a233-9fa3a2f7e8ec", "version": "7", "dialect": "postgresql", @@ -796,6 +796,13 @@ "primaryKey": false, "notNull": true }, + "source": { + "name": "source", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'litellm'" + }, "created_at": { "name": "created_at", "type": "timestamp with time zone", @@ -809,13 +816,6 @@ "primaryKey": false, "notNull": false, "default": "now()" - }, - "source": { - "name": "source", - "type": "varchar(20)", - "primaryKey": false, - "notNull": true, - "default": "'litellm'" } }, "indexes": { @@ -1942,6 +1942,13 @@ "notNull": true, "default": false }, + "enable_thinking_signature_rectifier": { + "name": "enable_thinking_signature_rectifier", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, "enable_response_fixer": { "name": "enable_response_fixer", "type": "boolean", @@ -2371,4 +2378,4 @@ "schemas": {}, "tables": {} } -} +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9473c9cce..d33b7e70c 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -369,9 +369,9 @@ { "idx": 52, "version": "7", - "when": 1767924921400, + "when": 1768052041185, "tag": "0052_model_price_source", "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/messages/en/settings.json b/messages/en/settings.json index 0572ae511..6a75d4652 100644 --- a/messages/en/settings.json +++ b/messages/en/settings.json @@ -543,8 +543,32 @@ "description": "Manage AI model pricing configuration" }, "searchPlaceholder": "Search model name...", + "filters": { + "all": "All", + "local": "Local", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "Local" + }, + "capabilities": { + "assistantPrefill": "Assistant prefill", + "computerUse": "Computer use", + "functionCalling": "Function calling", + "pdfInput": "PDF input", + "promptCaching": "Prompt caching", + "reasoning": "Reasoning", + "responseSchema": "Response schema", + "toolChoice": "Tool choice", + "vision": "Vision", + "statusSupported": "Supported", + "statusUnsupported": "Not supported", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "Sync LiteLLM Prices", + "button": "Sync Cloud Price Table", "syncing": "Syncing...", "checking": "Checking conflicts...", "successWithChanges": "Price table updated: {added} added, {updated} updated, {unchanged} unchanged", @@ -554,6 +578,7 @@ "failedNoResult": "Price table updated but no result returned", "noModels": "No model prices found", "partialFailure": "Partial update succeeded, but {failed} models failed", + "failedModels": "Failed models: {models}", "skippedConflicts": "Skipped {count} manual models" }, "conflict": { @@ -589,8 +614,8 @@ }, "table": { "modelName": "Model Name", - "type": "Type", "provider": "Provider", + "capabilities": "Capabilities", "inputPrice": "Input Price ($/M)", "outputPrice": "Output Price ($/M)", "updatedAt": "Updated At", @@ -608,6 +633,7 @@ "showing": "Showing {from}-{to} of {total}", "previous": "Previous", "next": "Next", + "perPageLabel": "Per page", "perPage": "{size} per page" }, "stats": { @@ -617,22 +643,22 @@ }, "dialog": { "title": "Update Model Price Table", - "description": "Select and upload JSON file containing model pricing data", - "selectFile": "Click to select JSON file or drag and drop here", + "description": "Select and upload JSON or TOML file containing model pricing data", + "selectFile": "Click to select JSON/TOML file or drag and drop here", "fileSizeLimit": "File size cannot exceed 10MB", "fileSizeLimitSmall": "File size not exceeding 10MB", - "invalidFileType": "Please select a JSON format file", + "invalidFileType": "Please select a JSON or TOML file", "fileTooLarge": "File size exceeds 10MB limit", "upload": "Upload and Update", "uploading": "Uploading...", "updatePriceTable": "Update Price Table", "updating": "Updating...", - "selectJson": "Select JSON File", + "selectJson": "Select File", "updateSuccess": "Price table updated successfully, {count} models updated", "updateFailed": "Update failed", "systemHasBuiltIn": "System has built-in price table", "manualDownload": "You can also manually download", - "latestPriceTable": "latest price table", + "latestPriceTable": "cloud price table", "andUploadViaButton": ", and upload via button above", "supportedModels": "Currently supports {count} models", "results": { @@ -641,6 +667,7 @@ "success": "Success: {success}", "failed": "Failed: {failed}", "skipped": "Skipped: {skipped}", + "more": " (+{count})", "details": "Details", "viewDetails": "View detailed logs" } @@ -664,6 +691,7 @@ }, "actions": { "edit": "Edit", + "more": "More actions", "delete": "Delete" }, "toast": { diff --git a/messages/ja/settings.json b/messages/ja/settings.json index c02d1d290..ba96490e7 100644 --- a/messages/ja/settings.json +++ b/messages/ja/settings.json @@ -534,8 +534,32 @@ "description": "AIモデルの価格設定を管理します" }, "searchPlaceholder": "モデル名を検索...", + "filters": { + "all": "すべて", + "local": "ローカル", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "ローカル" + }, + "capabilities": { + "assistantPrefill": "アシスタント事前入力", + "computerUse": "コンピューター利用", + "functionCalling": "関数呼び出し", + "pdfInput": "PDF入力", + "promptCaching": "プロンプトキャッシュ", + "reasoning": "推論", + "responseSchema": "レスポンススキーマ", + "toolChoice": "ツール選択", + "vision": "ビジョン", + "statusSupported": "対応", + "statusUnsupported": "未対応", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "LiteLLM価格を同期", + "button": "クラウド価格表を同期", "syncing": "同期中...", "checking": "競合を確認中...", "successWithChanges": "価格表を更新: {added}件追加、{updated}件更新、{unchanged}件変化なし", @@ -545,6 +569,7 @@ "failedNoResult": "価格表は更新されましたが結果が返されていません", "noModels": "モデル価格が見つかりません", "partialFailure": "一部更新が成功しましたが、{failed}件のモデルが失敗しました", + "failedModels": "失敗モデル: {models}", "skippedConflicts": "{count}件の手動モデルをスキップしました" }, "conflict": { @@ -580,8 +605,8 @@ }, "table": { "modelName": "モデル名", - "type": "タイプ", "provider": "プロバイダー", + "capabilities": "機能", "inputPrice": "入力価格 ($/M)", "outputPrice": "出力価格 ($/M)", "updatedAt": "更新日時", @@ -599,6 +624,7 @@ "showing": "{from}〜{to}件を表示(全{total}件)", "previous": "前へ", "next": "次へ", + "perPageLabel": "1ページあたり", "perPage": "1ページあたり{size}件" }, "stats": { @@ -608,22 +634,22 @@ }, "dialog": { "title": "モデル価格表を更新", - "description": "モデル価格データを含むJSONファイルを選択してアップロード", - "selectFile": "JSONファイルをクリックして選択、またはドラッグしてください", + "description": "モデル価格データを含むJSONまたはTOMLファイルを選択してアップロード", + "selectFile": "JSON/TOMLファイルをクリックして選択、またはドラッグしてください", "fileSizeLimit": "ファイルサイズは10MBを超えることはできません", "fileSizeLimitSmall": "ファイルサイズは10MB以下です", - "invalidFileType": "JSON形式のファイルを選択してください", + "invalidFileType": "JSONまたはTOML形式のファイルを選択してください", "fileTooLarge": "ファイルサイズが10MBを超えています", "upload": "アップロードして更新", "uploading": "アップロード中...", "updatePriceTable": "価格表を更新", "updating": "更新中...", - "selectJson": "JSONファイルを選択", + "selectJson": "ファイルを選択", "updateSuccess": "価格表が正常に更新されました。{count}個のモデルを更新しました", "updateFailed": "更新に失敗しました", "systemHasBuiltIn": "システムは組み込み価格表を持っています", "manualDownload": "手動でダウンロードすることもできます", - "latestPriceTable": "最新価格表", + "latestPriceTable": "クラウド価格表", "andUploadViaButton": "、上のボタンでアップロードしてください", "supportedModels": "現在{count}個のモデルをサポート", "results": { @@ -632,6 +658,7 @@ "success": "成功: {success}", "failed": "失敗: {failed}", "skipped": "スキップ: {skipped}", + "more": " (+{count})", "details": "詳細", "viewDetails": "詳細ログを表示" } @@ -655,6 +682,7 @@ }, "actions": { "edit": "編集", + "more": "その他の操作", "delete": "削除" }, "toast": { diff --git a/messages/ru/settings.json b/messages/ru/settings.json index 4fe8730ae..c610de547 100644 --- a/messages/ru/settings.json +++ b/messages/ru/settings.json @@ -534,8 +534,32 @@ "description": "Управление ценами AI моделей" }, "searchPlaceholder": "Поиск по названию модели...", + "filters": { + "all": "Все", + "local": "Локальные", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "Локальная" + }, + "capabilities": { + "assistantPrefill": "Предзаполнение ассистента", + "computerUse": "Использование компьютера", + "functionCalling": "Вызов функций", + "pdfInput": "Ввод PDF", + "promptCaching": "Кэширование промпта", + "reasoning": "Рассуждение", + "responseSchema": "Схема ответа", + "toolChoice": "Выбор инструментов", + "vision": "Зрение", + "statusSupported": "Поддерживается", + "statusUnsupported": "Не поддерживается", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "Синхронизировать цены LiteLLM", + "button": "Синхронизировать облачный прайс-лист", "syncing": "Синхронизация...", "checking": "Проверка конфликтов...", "successWithChanges": "Обновление прайс-листа: добавлено {added}, обновлено {updated}, без изменений {unchanged}", @@ -545,6 +569,7 @@ "failedNoResult": "Прайс-лист обновлен но результат не возвращен", "noModels": "Цены моделей не найдены", "partialFailure": "Частичное обновление выполнено, но {failed} моделей не удалось обновить", + "failedModels": "Не удалось обновить модели: {models}", "skippedConflicts": "Пропущено {count} ручных моделей" }, "conflict": { @@ -580,8 +605,8 @@ }, "table": { "modelName": "Название модели", - "type": "Тип", "provider": "Поставщик", + "capabilities": "Возможности", "inputPrice": "Цена ввода ($/M)", "outputPrice": "Цена вывода ($/M)", "updatedAt": "Обновлено", @@ -599,6 +624,7 @@ "showing": "Показано {from}-{to} из {total}", "previous": "Назад", "next": "Вперёд", + "perPageLabel": "На странице", "perPage": "{size} на странице" }, "stats": { @@ -608,22 +634,22 @@ }, "dialog": { "title": "Обновить прайс-лист", - "description": "Выберите и загрузите JSON файл с данными о ценах моделей", - "selectFile": "Нажмите для выбора JSON или перетащите сюда", + "description": "Выберите и загрузите JSON или TOML файл с данными о ценах моделей", + "selectFile": "Нажмите для выбора JSON/TOML или перетащите сюда", "fileSizeLimit": "Размер файла не может превышать 10MB", "fileSizeLimitSmall": "Размер файла не превышает 10MB", - "invalidFileType": "Пожалуйста, выберите файл в формате JSON", + "invalidFileType": "Пожалуйста, выберите файл JSON или TOML", "fileTooLarge": "Размер файла превышает лимит 10MB", "upload": "Загрузить и обновить", "uploading": "Загрузка...", "updatePriceTable": "Обновить прайс-лист", "updating": "Обновление...", - "selectJson": "Выбрать JSON файл", + "selectJson": "Выбрать файл", "updateSuccess": "Прайс-лист успешно обновлён, {count} моделей обновлено", "updateFailed": "Ошибка обновления", "systemHasBuiltIn": "Система имеет встроенный прайс-лист", "manualDownload": "Вы также можете скачать вручную", - "latestPriceTable": "последний прайс-лист", + "latestPriceTable": "облачный прайс-лист", "andUploadViaButton": ", и загрузить через кнопку выше", "supportedModels": "Поддерживается {count} моделей", "results": { @@ -632,6 +658,7 @@ "success": "Успешно: {success}", "failed": "Ошибок: {failed}", "skipped": "Пропущено: {skipped}", + "more": " (+{count})", "details": "Подробности", "viewDetails": "Просмотреть подробный журнал" } @@ -655,6 +682,7 @@ }, "actions": { "edit": "Редактировать", + "more": "Больше действий", "delete": "Удалить" }, "toast": { diff --git a/messages/zh-CN/settings.json b/messages/zh-CN/settings.json index a63c08b93..99381c57c 100644 --- a/messages/zh-CN/settings.json +++ b/messages/zh-CN/settings.json @@ -1262,8 +1262,32 @@ "description": "管理 AI 模型的价格配置" }, "searchPlaceholder": "搜索模型名称...", + "filters": { + "all": "全部", + "local": "本地", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "本地" + }, + "capabilities": { + "assistantPrefill": "助手预填充", + "computerUse": "电脑使用", + "functionCalling": "函数调用", + "pdfInput": "PDF 输入", + "promptCaching": "Prompt 缓存", + "reasoning": "推理", + "responseSchema": "响应 Schema", + "toolChoice": "工具选择", + "vision": "视觉", + "statusSupported": "支持", + "statusUnsupported": "不支持", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "同步 LiteLLM 价格", + "button": "同步云端价格表", "syncing": "同步中...", "checking": "检查冲突...", "successWithChanges": "价格表更新: 新增 {added} 个,更新 {updated} 个,未变化 {unchanged} 个", @@ -1273,6 +1297,7 @@ "failedNoResult": "价格表更新成功但未返回处理结果", "noModels": "未找到支持的模型价格", "partialFailure": "部分更新成功,但有 {failed} 个模型失败", + "failedModels": "失败模型: {models}", "skippedConflicts": "跳过 {count} 个手动模型" }, "conflict": { @@ -1308,8 +1333,8 @@ }, "table": { "modelName": "模型名称", - "type": "类型", "provider": "提供商", + "capabilities": "能力", "inputPrice": "输入价格 ($/M)", "outputPrice": "输出价格 ($/M)", "updatedAt": "更新时间", @@ -1327,6 +1352,7 @@ "showing": "显示 {from}-{to} 条,共 {total} 条", "previous": "上一页", "next": "下一页", + "perPageLabel": "每页", "perPage": "每页 {size} 条" }, "stats": { @@ -1336,22 +1362,22 @@ }, "dialog": { "title": "更新模型价格表", - "description": "选择包含模型价格数据的 JSON 文件并上传", - "selectFile": "点击选择 JSON 文件或拖拽到此处", + "description": "选择包含模型价格数据的 JSON 或 TOML 文件并上传", + "selectFile": "点击选择 JSON/TOML 文件或拖拽到此处", "fileSizeLimit": "文件大小不能超过 10MB", "fileSizeLimitSmall": "文件大小不超过 10MB", - "invalidFileType": "请选择 JSON 格式的文件", + "invalidFileType": "请选择 JSON 或 TOML 格式的文件", "fileTooLarge": "文件大小超过 10MB 限制", "upload": "上传并更新", "uploading": "上传中...", "updatePriceTable": "更新价格表", "updating": "更新中...", - "selectJson": "选择 JSON 文件", + "selectJson": "选择文件", "updateSuccess": "价格表更新成功,共更新 {count} 个模型", "updateFailed": "更新失败", "systemHasBuiltIn": "系统已内置价格表", "manualDownload": "你也可以手动下载", - "latestPriceTable": "最新价格表", + "latestPriceTable": "云端价格表", "andUploadViaButton": ",并通过上方按钮上传", "supportedModels": "当前支持 {count} 个模型", "results": { @@ -1360,6 +1386,7 @@ "success": "成功: {success}", "failed": "失败: {failed}", "skipped": "跳过: {skipped}", + "more": " (+{count})", "details": "详细信息", "viewDetails": "查看详细日志" } @@ -1383,6 +1410,7 @@ }, "actions": { "edit": "编辑", + "more": "更多操作", "delete": "删除" }, "toast": { diff --git a/messages/zh-TW/settings.json b/messages/zh-TW/settings.json index 2cf3fe91b..078dc8d17 100644 --- a/messages/zh-TW/settings.json +++ b/messages/zh-TW/settings.json @@ -534,8 +534,32 @@ "description": "管理 AI 模型的價格設定" }, "searchPlaceholder": "搜尋模型名稱...", + "filters": { + "all": "全部", + "local": "本地", + "anthropic": "Anthropic", + "openai": "OpenAI", + "vertex": "Vertex" + }, + "badges": { + "local": "本地" + }, + "capabilities": { + "assistantPrefill": "助手預填充", + "computerUse": "電腦使用", + "functionCalling": "函數呼叫", + "pdfInput": "PDF 輸入", + "promptCaching": "Prompt 快取", + "reasoning": "推理", + "responseSchema": "回應 Schema", + "toolChoice": "工具選擇", + "vision": "視覺", + "statusSupported": "支援", + "statusUnsupported": "不支援", + "tooltip": "{label}: {status}" + }, "sync": { - "button": "同步 LiteLLM 價格", + "button": "同步雲端價格表", "syncing": "同步中...", "checking": "檢查衝突...", "successWithChanges": "價格表更新: 新增 {added} 個,更新 {updated} 個,未變化 {unchanged} 個", @@ -545,6 +569,7 @@ "failedNoResult": "價格表更新成功但未返回處理結果", "noModels": "未找到支援的模型價格", "partialFailure": "部分更新成功,但有 {failed} 個模型失敗", + "failedModels": "失敗模型: {models}", "skippedConflicts": "跳過 {count} 個手動模型" }, "conflict": { @@ -580,8 +605,8 @@ }, "table": { "modelName": "模型名稱", - "type": "類型", "provider": "提供商", + "capabilities": "能力", "inputPrice": "輸入價格 ($/M)", "outputPrice": "輸出價格 ($/M)", "updatedAt": "更新時間", @@ -599,6 +624,7 @@ "showing": "顯示 {from}-{to} 條,共 {total} 條", "previous": "上一頁", "next": "下一頁", + "perPageLabel": "每頁", "perPage": "每頁 {size} 條" }, "stats": { @@ -608,22 +634,22 @@ }, "dialog": { "title": "更新模型價格表", - "description": "選擇包含模型價格資料的 JSON 檔案並上傳", - "selectFile": "點擊選擇 JSON 檔案或拖曳到此處", + "description": "選擇包含模型價格資料的 JSON 或 TOML 檔案並上傳", + "selectFile": "點擊選擇 JSON/TOML 檔案或拖曳到此處", "fileSizeLimit": "檔案大小不能超過 10MB", "fileSizeLimitSmall": "檔案大小不超過 10MB", - "invalidFileType": "請選擇 JSON 格式的檔案", + "invalidFileType": "請選擇 JSON 或 TOML 格式的檔案", "fileTooLarge": "檔案大小超過 10MB 限制", "upload": "上傳並更新", "uploading": "上傳中...", "updatePriceTable": "更新價格表", "updating": "更新中...", - "selectJson": "選擇 JSON 檔案", + "selectJson": "選擇檔案", "updateSuccess": "價格表更新成功,共更新 {count} 個模型", "updateFailed": "更新失敗", "systemHasBuiltIn": "系統已內置價格表", "manualDownload": "你也可以手動下載", - "latestPriceTable": "最新價格表", + "latestPriceTable": "雲端價格表", "andUploadViaButton": ",並透過上方按鈕上傳", "supportedModels": "目前支援 {count} 個模型", "results": { @@ -632,6 +658,7 @@ "success": "成功: {success}", "failed": "失敗: {failed}", "skipped": "跳過: {skipped}", + "more": " (+{count})", "details": "詳細資訊", "viewDetails": "檢視詳細記錄" } @@ -655,6 +682,7 @@ }, "actions": { "edit": "編輯", + "more": "更多操作", "delete": "刪除" }, "toast": { diff --git a/package.json b/package.json index b1b43852e..9f51e3525 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@hono/swagger-ui": "^0.5", "@hono/zod-openapi": "^1", "@hookform/resolvers": "^5", + "@iarna/toml": "^2.2.5", "@lobehub/icons": "^2", "@radix-ui/react-alert-dialog": "^1", "@radix-ui/react-avatar": "^1", diff --git a/src/actions/model-prices.ts b/src/actions/model-prices.ts index 9d25caef7..85cf2d697 100644 --- a/src/actions/model-prices.ts +++ b/src/actions/model-prices.ts @@ -3,14 +3,16 @@ import { revalidatePath } from "next/cache"; import { getSession } from "@/lib/auth"; import { logger } from "@/lib/logger"; -import { getPriceTableJson } from "@/lib/price-sync"; +import { + fetchCloudPriceTableToml, + parseCloudPriceTableToml, +} from "@/lib/price-sync/cloud-price-table"; import { createModelPrice, deleteModelPriceByName, findAllLatestPrices, findAllLatestPricesPaginated, findAllManualPrices, - findLatestPriceByModel, hasAnyPriceRecords, type PaginatedResult, type PaginationParams, @@ -30,8 +32,38 @@ import type { ActionResult } from "./types"; * 检查价格数据是否相同 */ function isPriceDataEqual(data1: ModelPriceData, data2: ModelPriceData): boolean { - // 深度比较两个价格对象 - return JSON.stringify(data1) === JSON.stringify(data2); + const stableStringify = (value: unknown): string => { + const seen = new WeakSet(); + + const canonicalize = (node: unknown): unknown => { + if (node === null || node === undefined) return node; + if (typeof node !== "object") return node; + + if (seen.has(node as object)) { + return null; + } + seen.add(node as object); + + if (Array.isArray(node)) { + return node.map(canonicalize); + } + + const obj = node as Record; + const result: Record = Object.create(null); + for (const key of Object.keys(obj).sort()) { + // 防御:避免 __proto__/constructor/prototype 触发原型链污染 + if (key === "__proto__" || key === "constructor" || key === "prototype") { + continue; + } + result[key] = canonicalize(obj[key]); + } + return result; + }; + + return JSON.stringify(canonicalize(value)); + }; + + return stableStringify(data1) === stableStringify(data2); } /** @@ -77,6 +109,13 @@ export async function processPriceTableInternal( // 获取所有手动添加的模型(用于冲突检测) const manualPrices = await findAllManualPrices(); + // 批量获取数据库中“每个模型的最新价格”,避免 N+1 查询 + const existingLatestPrices = await findAllLatestPrices(); + const existingByModelName = new Map(); + for (const price of existingLatestPrices) { + existingByModelName.set(price.modelName, price); + } + const result: PriceUpdateResult = { added: [], updated: [], @@ -113,8 +152,7 @@ export async function processPriceTableInternal( continue; } - // 查找该模型的最新价格 - const existingPrice = await findLatestPriceByModel(modelName); + const existingPrice = existingByModelName.get(modelName) ?? null; if (!existingPrice) { // 模型不存在,新增记录 @@ -139,7 +177,14 @@ export async function processPriceTableInternal( } // 刷新页面数据 - revalidatePath("/settings/prices"); + try { + revalidatePath("/settings/prices"); + } catch (error) { + // 在后台任务/启动阶段可能没有 Next.js 的请求上下文,此处允许降级 + logger.debug("[ModelPrices] revalidatePath skipped", { + error: error instanceof Error ? error.message : String(error), + }); + } return { ok: true, data: result }; } catch (error) { @@ -151,10 +196,14 @@ export async function processPriceTableInternal( /** * 上传并更新模型价格表(Web UI 入口,包含权限检查) + * + * 支持格式: + * - JSON:PriceTableJson(内部入库格式) + * - TOML:云端价格表格式(会提取 models 表后再入库) * @param overwriteManual - 可选,要覆盖的手动添加模型名称列表 */ export async function uploadPriceTable( - jsonContent: string, + content: string, overwriteManual?: string[] ): Promise> { // 权限检查:只有管理员可以上传价格表 @@ -163,7 +212,18 @@ export async function uploadPriceTable( return { ok: false, error: "无权限执行此操作" }; } - // 调用核心逻辑 + // 先尝试 JSON;失败则按 TOML 解析(用于云端价格表文件直接上传) + let jsonContent = content; + try { + JSON.parse(content); + } catch { + const parseResult = parseCloudPriceTableToml(content); + if (!parseResult.ok) { + return { ok: false, error: parseResult.error }; + } + jsonContent = JSON.stringify(parseResult.data.models); + } + return processPriceTableInternal(jsonContent, overwriteManual); } @@ -284,23 +344,22 @@ export async function checkLiteLLMSyncConflicts(): Promise(initialSourceFilter); + const [litellmProviderFilter, setLitellmProviderFilter] = useState(initialLitellmProviderFilter); const [prices, setPrices] = useState(initialPrices); const [total, setTotal] = useState(initialTotal); const [page, setPage] = useState(initialPage); @@ -67,51 +86,79 @@ export function PriceList({ // 使用防抖,避免频繁请求 const debouncedSearchTerm = useDebounce(searchTerm, 500); + const lastDebouncedSearchTerm = useRef(debouncedSearchTerm); // 计算总页数 const totalPages = Math.ceil(total / pageSize); - // 从 URL 搜索参数中读取初始状态(仅在挂载时执行一次) - useEffect(() => { - const urlParams = new URLSearchParams(window.location.search); - const searchParam = urlParams.get("search"); - const pageParam = urlParams.get("page"); - const sizeParam = urlParams.get("size"); + // 更新 URL 搜索参数 + const updateURL = useCallback( + ( + newSearchTerm: string, + newPage: number, + newPageSize: number, + newSourceFilter: ModelPriceSource | "", + newLitellmProviderFilter: string + ) => { + const url = new URL(window.location.href); + if (newSearchTerm) { + url.searchParams.set("search", newSearchTerm); + } else { + url.searchParams.delete("search"); + } + if (newPage > 1) { + url.searchParams.set("page", newPage.toString()); + } else { + url.searchParams.delete("page"); + } + if (newPageSize !== 50) { + url.searchParams.set("pageSize", newPageSize.toString()); + url.searchParams.delete("size"); + } else { + url.searchParams.delete("pageSize"); + url.searchParams.delete("size"); + } - if (searchParam) setSearchTerm(searchParam); - if (pageParam) setPage(parseInt(pageParam, 10)); - if (sizeParam) setPageSize(parseInt(sizeParam, 10)); - }, []); // 空依赖数组,仅在挂载时执行一次 + if (newSourceFilter) { + url.searchParams.set("source", newSourceFilter); + } else { + url.searchParams.delete("source"); + } - // 更新 URL 搜索参数 - const updateURL = useCallback((newSearchTerm: string, newPage: number, newPageSize: number) => { - const url = new URL(window.location.href); - if (newSearchTerm) { - url.searchParams.set("search", newSearchTerm); - } else { - url.searchParams.delete("search"); - } - if (newPage > 1) { - url.searchParams.set("page", newPage.toString()); - } else { - url.searchParams.delete("page"); - } - if (newPageSize !== 50) { - url.searchParams.set("size", newPageSize.toString()); - } else { - url.searchParams.delete("size"); - } - window.history.replaceState({}, "", url.toString()); - }, []); + if (newLitellmProviderFilter) { + url.searchParams.set("litellmProvider", newLitellmProviderFilter); + } else { + url.searchParams.delete("litellmProvider"); + } + window.history.replaceState({}, "", url.toString()); + }, + [] + ); // 获取价格数据 const fetchPrices = useCallback( - async (newPage: number, newPageSize: number, newSearchTerm: string) => { + async ( + newPage: number, + newPageSize: number, + newSearchTerm: string, + newSourceFilter: ModelPriceSource | "", + newLitellmProviderFilter: string + ) => { setIsLoading(true); try { - const response = await fetch( - `/api/prices?page=${newPage}&pageSize=${newPageSize}&search=${encodeURIComponent(newSearchTerm)}` - ); + const url = new URL("/api/prices", window.location.origin); + url.searchParams.set("page", newPage.toString()); + url.searchParams.set("pageSize", newPageSize.toString()); + url.searchParams.set("search", newSearchTerm); + + if (newSourceFilter) { + url.searchParams.set("source", newSourceFilter); + } + if (newLitellmProviderFilter) { + url.searchParams.set("litellmProvider", newLitellmProviderFilter); + } + + const response = await fetch(url.toString()); const result = await response.json(); if (result.ok) { @@ -132,24 +179,25 @@ export function PriceList({ // 监听价格数据变化事件(由其他组件触发) useEffect(() => { const handlePriceUpdate = () => { - fetchPrices(page, pageSize, debouncedSearchTerm); + fetchPrices(page, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); }; window.addEventListener("price-data-updated", handlePriceUpdate); return () => window.removeEventListener("price-data-updated", handlePriceUpdate); - }, [page, pageSize, debouncedSearchTerm, fetchPrices]); + }, [page, pageSize, debouncedSearchTerm, fetchPrices, sourceFilter, litellmProviderFilter]); // 当防抖后的搜索词变化时,触发搜索(重置到第一页) useEffect(() => { - // 跳过初始渲染(当 debouncedSearchTerm 等于初始 searchTerm 时) - if (debouncedSearchTerm !== searchTerm) return; + if (debouncedSearchTerm === lastDebouncedSearchTerm.current) { + return; + } + lastDebouncedSearchTerm.current = debouncedSearchTerm; const newPage = 1; // 搜索时重置到第一页 setPage(newPage); - updateURL(debouncedSearchTerm, newPage, pageSize); - fetchPrices(newPage, pageSize, debouncedSearchTerm); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedSearchTerm, fetchPrices, pageSize, searchTerm, updateURL]); // 仅依赖 debouncedSearchTerm + updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, litellmProviderFilter); + fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); + }, [debouncedSearchTerm, fetchPrices, litellmProviderFilter, pageSize, sourceFilter, updateURL]); // 搜索输入处理(只更新状态,不触发请求) const handleSearchChange = (value: string) => { @@ -161,16 +209,16 @@ export function PriceList({ const newPage = Math.max(1, Math.min(page, Math.ceil(total / newPageSize))); setPageSize(newPageSize); setPage(newPage); - updateURL(debouncedSearchTerm, newPage, newPageSize); - fetchPrices(newPage, newPageSize, debouncedSearchTerm); + updateURL(debouncedSearchTerm, newPage, newPageSize, sourceFilter, litellmProviderFilter); + fetchPrices(newPage, newPageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); }; // 页面跳转处理 const handlePageChange = (newPage: number) => { if (newPage < 1 || newPage > totalPages) return; setPage(newPage); - updateURL(debouncedSearchTerm, newPage, pageSize); - fetchPrices(newPage, pageSize, debouncedSearchTerm); + updateURL(debouncedSearchTerm, newPage, pageSize, sourceFilter, litellmProviderFilter); + fetchPrices(newPage, pageSize, debouncedSearchTerm, sourceFilter, litellmProviderFilter); }; // 移除客户端过滤逻辑(现在由后端处理) @@ -180,7 +228,7 @@ export function PriceList({ * 格式化价格显示为每百万token的价格 */ const formatPrice = (value?: number): string => { - if (!value) return "-"; + if (value === undefined || value === null) return "-"; // 将每token的价格转换为每百万token的价格 const pricePerMillion = value * 1000000; // 格式化为合适的小数位数 @@ -198,10 +246,10 @@ export function PriceList({ /** * 获取模型类型标签 */ - const getModeLabel = (mode?: string) => { + const getModeBadge = (mode?: string) => { switch (mode) { case "chat": - return {t("table.typeChat")}; + return null; case "image_generation": return {t("table.typeImage")}; case "completion": @@ -211,8 +259,121 @@ export function PriceList({ } }; + const capabilityItems: Array<{ + key: + | "supports_assistant_prefill" + | "supports_computer_use" + | "supports_function_calling" + | "supports_pdf_input" + | "supports_prompt_caching" + | "supports_reasoning" + | "supports_response_schema" + | "supports_tool_choice" + | "supports_vision"; + icon: React.ComponentType<{ className?: string }>; + label: string; + }> = [ + { key: "supports_function_calling", icon: Code2, label: t("capabilities.functionCalling") }, + { key: "supports_tool_choice", icon: Terminal, label: t("capabilities.toolChoice") }, + { key: "supports_response_schema", icon: Braces, label: t("capabilities.responseSchema") }, + { key: "supports_prompt_caching", icon: Database, label: t("capabilities.promptCaching") }, + { key: "supports_vision", icon: Eye, label: t("capabilities.vision") }, + { key: "supports_pdf_input", icon: FileText, label: t("capabilities.pdfInput") }, + { key: "supports_reasoning", icon: Sparkles, label: t("capabilities.reasoning") }, + { key: "supports_computer_use", icon: Monitor, label: t("capabilities.computerUse") }, + { key: "supports_assistant_prefill", icon: Pencil, label: t("capabilities.assistantPrefill") }, + ]; + + const applyFilters = useCallback( + (next: { source: ModelPriceSource | ""; litellmProvider: string }) => { + setSourceFilter(next.source); + setLitellmProviderFilter(next.litellmProvider); + + const newPage = 1; + setPage(newPage); + updateURL(debouncedSearchTerm, newPage, pageSize, next.source, next.litellmProvider); + fetchPrices(newPage, pageSize, debouncedSearchTerm, next.source, next.litellmProvider); + }, + [debouncedSearchTerm, fetchPrices, pageSize, updateURL] + ); + return (
+ {/* 快捷筛选 */} +
+ + + + + + + + + +
+ {/* 搜索和页面大小控制 */}
@@ -225,9 +386,7 @@ export function PriceList({ />
- - {t("pagination.perPage", { size: "" }).replace(/\d+/, "")} - + {t("pagination.perPageLabel")} @@ -237,7 +239,8 @@ export function UploadPriceDialog({
{result.added.slice(0, 3).join(", ")} - {result.added.length > 3 && ` (+${result.added.length - 3})`} + {result.added.length > 3 && + t("dialog.results.more", { count: result.added.length - 3 })}
)} @@ -252,7 +255,8 @@ export function UploadPriceDialog({
{result.updated.slice(0, 3).join(", ")} - {result.updated.length > 3 && ` (+${result.updated.length - 3})`} + {result.updated.length > 3 && + t("dialog.results.more", { count: result.updated.length - 3 })}
)} @@ -277,7 +281,8 @@ export function UploadPriceDialog({
{result.failed.slice(0, 3).join(", ")} - {result.failed.length > 3 && ` (+${result.failed.length - 3})`} + {result.failed.length > 3 && + t("dialog.results.more", { count: result.failed.length - 3 })}
)} diff --git a/src/app/[locale]/settings/prices/page.tsx b/src/app/[locale]/settings/prices/page.tsx index f3b21cc51..0f14528ca 100644 --- a/src/app/[locale]/settings/prices/page.tsx +++ b/src/app/[locale]/settings/prices/page.tsx @@ -18,6 +18,8 @@ interface SettingsPricesPageProps { pageSize?: string; size?: string; search?: string; + source?: string; + litellmProvider?: string; }>; } @@ -41,9 +43,19 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) // 解析分页参数 const page = parseInt(params.page || "1", 10); const pageSize = parseInt(params.pageSize || params.size || "50", 10); + const search = params.search?.trim() || undefined; + const source = + params.source === "manual" || params.source === "litellm" ? params.source : undefined; + const litellmProvider = params.litellmProvider?.trim() || undefined; - // 获取分页数据(搜索在客户端处理) - const pricesResult = await getModelPricesPaginated({ page, pageSize }); + // 获取分页数据(搜索与过滤在 SQL 层面执行) + const pricesResult = await getModelPricesPaginated({ + page, + pageSize, + search, + source, + litellmProvider, + }); const isRequired = params.required === "true"; // 如果获取分页数据失败,降级到获取所有数据 @@ -85,6 +97,9 @@ async function SettingsPricesContent({ searchParams }: SettingsPricesPageProps) initialTotal={initialTotal} initialPage={initialPage} initialPageSize={initialPageSize} + initialSearchTerm={search ?? ""} + initialSourceFilter={source ?? ""} + initialLitellmProviderFilter={litellmProvider ?? ""} /> ); diff --git a/src/app/api/prices/route.ts b/src/app/api/prices/route.ts index 274f59214..304d22e28 100644 --- a/src/app/api/prices/route.ts +++ b/src/app/api/prices/route.ts @@ -10,6 +10,8 @@ import type { PaginationParams } from "@/repository/model-price"; * - page: 页码 (默认: 1) * - pageSize: 每页大小 (默认: 50) * - search: 搜索关键词 (可选) + * - source: 价格来源过滤 (可选: manual|litellm) + * - litellmProvider: 云端提供商过滤 (可选,如 anthropic/openai/vertex_ai-language-models) */ export async function GET(request: NextRequest) { try { @@ -22,24 +24,35 @@ export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); // 解析查询参数 - const page = parseInt(searchParams.get("page") || "1", 10); - const pageSize = parseInt(searchParams.get("pageSize") || searchParams.get("size") || "50", 10); + const page = Number.parseInt(searchParams.get("page") || "1", 10); + const pageSize = Number.parseInt( + searchParams.get("pageSize") || searchParams.get("size") || "50", + 10 + ); const search = searchParams.get("search") || ""; + const source = searchParams.get("source") || ""; + const litellmProvider = searchParams.get("litellmProvider") || ""; // 参数验证 - if (page < 1) { + if (!Number.isFinite(page) || page < 1) { return NextResponse.json({ ok: false, error: "页码必须大于0" }, { status: 400 }); } - if (pageSize < 1 || pageSize > 200) { + if (!Number.isFinite(pageSize) || pageSize < 1 || pageSize > 200) { return NextResponse.json({ ok: false, error: "每页大小必须在1-200之间" }, { status: 400 }); } + if (source && source !== "manual" && source !== "litellm") { + return NextResponse.json({ ok: false, error: "source 参数无效" }, { status: 400 }); + } + // 构建分页参数 const paginationParams: PaginationParams = { page, pageSize, search: search || undefined, // 传递搜索关键词给后端 + source: source ? (source as PaginationParams["source"]) : undefined, + litellmProvider: litellmProvider || undefined, }; // 获取分页数据(搜索在 SQL 层面执行) diff --git a/src/app/v1/_lib/proxy/response-handler.ts b/src/app/v1/_lib/proxy/response-handler.ts index 32a42328c..0af019603 100644 --- a/src/app/v1/_lib/proxy/response-handler.ts +++ b/src/app/v1/_lib/proxy/response-handler.ts @@ -2,11 +2,13 @@ import { ResponseFixer } from "@/app/v1/_lib/proxy/response-fixer"; import { AsyncTaskManager } from "@/lib/async-task-manager"; import { getEnvConfig } from "@/lib/config/env.schema"; import { logger } from "@/lib/logger"; +import { requestCloudPriceTableSync } from "@/lib/price-sync/cloud-price-updater"; import { ProxyStatusTracker } from "@/lib/proxy-status-tracker"; import { RateLimitService } from "@/lib/rate-limit"; import { SessionManager } from "@/lib/session-manager"; import { SessionTracker } from "@/lib/session-tracker"; import { calculateRequestCost } from "@/lib/utils/cost-calculation"; +import { hasValidPriceData } from "@/lib/utils/price-data"; import { parseSSEData } from "@/lib/utils/sse"; import { updateMessageRequestCost, @@ -360,19 +362,25 @@ export class ProxyResponseHandler { if (session.sessionId && usageMetrics) { // 计算成本(复用相同逻辑) let costUsdStr: string | undefined; - if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { - const cost = calculateRequestCost( - usageMetrics, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.gt(0)) { - costUsdStr = cost.toString(); + try { + if (session.request.model) { + const priceData = await session.getCachedPriceDataByBillingSource(); + if (priceData) { + const cost = calculateRequestCost( + usageMetrics, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.gt(0)) { + costUsdStr = cost.toString(); + } } } + } catch (error) { + logger.error("[ResponseHandler] Failed to calculate session cost, skipping", { + error: error instanceof Error ? error.message : String(error), + }); } void SessionManager.updateSessionUsage(session.sessionId, { @@ -922,19 +930,25 @@ export class ProxyResponseHandler { // 更新 session 使用量到 Redis(用于实时监控) if (session.sessionId && usageForCost) { let costUsdStr: string | undefined; - if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { - const cost = calculateRequestCost( - usageForCost, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.gt(0)) { - costUsdStr = cost.toString(); + try { + if (session.request.model) { + const priceData = await session.getCachedPriceDataByBillingSource(); + if (priceData) { + const cost = calculateRequestCost( + usageForCost, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.gt(0)) { + costUsdStr = cost.toString(); + } } } + } catch (error) { + logger.error("[ResponseHandler] Failed to calculate session cost (stream), skipping", { + error: error instanceof Error ? error.message : String(error), + }); } void SessionManager.updateSessionUsage(session.sessionId, { @@ -1640,97 +1654,116 @@ async function updateRequestCostFromUsage( return; } - // 获取系统设置中的计费模型来源配置 - 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; - } + 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; + } - logger.debug("[CostCalculation] Billing model source config", { - messageId, - billingModelSource, - primaryModel, - fallbackModel, - }); + logger.debug("[CostCalculation] Billing model source config", { + messageId, + billingModelSource, + primaryModel, + fallbackModel, + }); - // Fallback 逻辑:优先主要模型,找不到则用备选模型 - let priceData = null; - let usedModelForPricing = null; + // Fallback 逻辑:优先主要模型,找不到则用备选模型 + let priceData = null; + let usedModelForPricing = null; - // Step 1: 尝试主要模型 - if (primaryModel) { - priceData = await findLatestPriceByModel(primaryModel); - if (priceData?.priceData) { - usedModelForPricing = primaryModel; - logger.debug("[CostCalculation] Using primary model for pricing", { - messageId, - model: primaryModel, - billingModelSource, - }); + 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, + }); + } } - } - // Step 2: Fallback 到备选模型 - if (!priceData && fallbackModel && fallbackModel !== primaryModel) { - priceData = await findLatestPriceByModel(fallbackModel); - if (priceData?.priceData) { - usedModelForPricing = fallbackModel; - logger.warn("[CostCalculation] Primary model price not found, using fallback model", { + // Step 3: 完全失败(无价格或价格表暂不可用):不计费放行,并异步触发一次同步 + if (!priceData?.priceData) { + logger.warn("[CostCalculation] No price data found, skipping billing", { messageId, - primaryModel, - fallbackModel, + originalModel, + redirectedModel, billingModelSource, }); + + requestCloudPriceTableSync({ reason: "missing-model" }); + return; } - } - // Step 3: 完全失败 - if (!priceData?.priceData) { - logger.error("[CostCalculation] No price data found for any model", { + // 计算费用 + const cost = calculateRequestCost(usage, priceData.priceData, costMultiplier, context1mApplied); + + logger.info("[CostCalculation] Cost calculated successfully", { messageId, - originalModel, - redirectedModel, + usedModelForPricing, billingModelSource, - note: "Cost will be $0. Please check price table or model name.", + costUsd: cost.toString(), + costMultiplier, + usage, }); - return; - } - - // 计算费用 - const cost = calculateRequestCost(usage, priceData.priceData, costMultiplier, context1mApplied); - logger.info("[CostCalculation] Cost calculated successfully", { - messageId, - usedModelForPricing, - billingModelSource, - costUsd: cost.toString(), - costMultiplier, - usage, - }); - - if (cost.gt(0)) { - await updateMessageRequestCost(messageId, cost); - } else { - logger.warn("[CostCalculation] Calculated cost is zero or negative", { + if (cost.gt(0)) { + await updateMessageRequestCost(messageId, cost); + } else { + logger.warn("[CostCalculation] Calculated cost is zero or negative", { + messageId, + usedModelForPricing, + costUsd: cost.toString(), + priceData: { + inputCost: priceData.priceData.input_cost_per_token, + outputCost: priceData.priceData.output_cost_per_token, + }, + }); + } + } catch (error) { + logger.error("[CostCalculation] Failed to update request cost, skipping billing", { messageId, - usedModelForPricing, - costUsd: cost.toString(), - priceData: { - inputCost: priceData.priceData.input_cost_per_token, - outputCost: priceData.priceData.output_cost_per_token, - }, + error: error instanceof Error ? error.message : String(error), }); } } @@ -1739,7 +1772,7 @@ async function updateRequestCostFromUsage( * 统一的请求统计处理方法 * 用于消除 Gemini 透传、普通非流式、普通流式之间的重复统计逻辑 */ -async function finalizeRequestStats( +export async function finalizeRequestStats( session: ProxySession, responseText: string, statusCode: number, @@ -1806,19 +1839,25 @@ async function finalizeRequestStats( // 6. 更新 session usage if (session.sessionId) { let costUsdStr: string | undefined; - if (session.request.model) { - const priceData = await session.getCachedPriceDataByBillingSource(); - if (priceData) { - const cost = calculateRequestCost( - normalizedUsage, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.gt(0)) { - costUsdStr = cost.toString(); + try { + if (session.request.model) { + const priceData = await session.getCachedPriceDataByBillingSource(); + if (priceData) { + const cost = calculateRequestCost( + normalizedUsage, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.gt(0)) { + costUsdStr = cost.toString(); + } } } + } catch (error) { + logger.error("[ResponseHandler] Failed to calculate session cost (finalize), skipping", { + error: error instanceof Error ? error.message : String(error), + }); } void SessionManager.updateSessionUsage(session.sessionId, { @@ -1858,62 +1897,68 @@ async function finalizeRequestStats( async function trackCostToRedis(session: ProxySession, usage: UsageMetrics | null): Promise { if (!usage || !session.sessionId) return; - const messageContext = session.messageContext; - const provider = session.provider; - const key = session.authState?.key; - const user = session.authState?.user; + try { + const messageContext = session.messageContext; + const provider = session.provider; + const key = session.authState?.key; + const user = session.authState?.user; - if (!messageContext || !provider || !key || !user) return; + if (!messageContext || !provider || !key || !user) return; - const modelName = session.request.model; - if (!modelName) return; + const modelName = session.request.model; + if (!modelName) return; - // 计算成本(应用倍率)- 使用 session 缓存避免重复查询 - const priceData = await session.getCachedPriceDataByBillingSource(); - if (!priceData) return; + // 计算成本(应用倍率)- 使用 session 缓存避免重复查询 + const priceData = await session.getCachedPriceDataByBillingSource(); + if (!priceData) return; - const cost = calculateRequestCost( - usage, - priceData, - provider.costMultiplier, - session.getContext1mApplied() - ); - if (cost.lte(0)) return; - - const costFloat = parseFloat(cost.toString()); - - // 追踪到 Redis(使用 session.sessionId) - await RateLimitService.trackCost( - key.id, - provider.id, - session.sessionId, // 直接使用 session.sessionId - costFloat, - { - keyResetTime: key.dailyResetTime, - keyResetMode: key.dailyResetMode, - providerResetTime: provider.dailyResetTime, - providerResetMode: provider.dailyResetMode, - requestId: messageContext.id, - createdAtMs: messageContext.createdAt.getTime(), - } - ); + const cost = calculateRequestCost( + usage, + priceData, + provider.costMultiplier, + session.getContext1mApplied() + ); + if (cost.lte(0)) return; - // 新增:追踪用户层每日消费 - await RateLimitService.trackUserDailyCost( - user.id, - costFloat, - user.dailyResetTime, - user.dailyResetMode, - { - requestId: messageContext.id, - createdAtMs: messageContext.createdAt.getTime(), - } - ); + const costFloat = parseFloat(cost.toString()); - // 刷新 session 时间戳(滑动窗口) - void SessionTracker.refreshSession(session.sessionId, key.id, provider.id).catch((error) => { - logger.error("[ResponseHandler] Failed to refresh session tracker:", error); - }); + // 追踪到 Redis(使用 session.sessionId) + await RateLimitService.trackCost( + key.id, + provider.id, + session.sessionId, // 直接使用 session.sessionId + costFloat, + { + keyResetTime: key.dailyResetTime, + keyResetMode: key.dailyResetMode, + providerResetTime: provider.dailyResetTime, + providerResetMode: provider.dailyResetMode, + requestId: messageContext.id, + createdAtMs: messageContext.createdAt.getTime(), + } + ); + + // 新增:追踪用户层每日消费 + await RateLimitService.trackUserDailyCost( + user.id, + costFloat, + user.dailyResetTime, + user.dailyResetMode, + { + requestId: messageContext.id, + createdAtMs: messageContext.createdAt.getTime(), + } + ); + + // 刷新 session 时间戳(滑动窗口) + void SessionTracker.refreshSession(session.sessionId, key.id, provider.id).catch((error) => { + logger.error("[ResponseHandler] Failed to refresh session tracker:", error); + }); + } catch (error) { + logger.error("[ResponseHandler] Failed to track cost to Redis, skipping", { + error: error instanceof Error ? error.message : String(error), + }); + } } /** diff --git a/src/app/v1/_lib/proxy/session.ts b/src/app/v1/_lib/proxy/session.ts index 35bb67267..9f9366d62 100644 --- a/src/app/v1/_lib/proxy/session.ts +++ b/src/app/v1/_lib/proxy/session.ts @@ -2,6 +2,7 @@ import crypto from "node:crypto"; 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 { findLatestPriceByModel } from "@/repository/model-price"; import { findAllProviders } from "@/repository/provider"; import type { CacheTtlResolved } from "@/types/cache"; @@ -770,45 +771,6 @@ export class ProxySession { } } -/** - * 判断价格数据是否包含至少一个可用于计费的价格字段。 - * 避免把数据库中的 `{}` 或仅包含元信息的记录当成有效价格。 - */ -function hasValidPriceData(priceData: ModelPriceData): boolean { - const numericCosts = [ - priceData.input_cost_per_token, - priceData.output_cost_per_token, - priceData.cache_creation_input_token_cost, - priceData.cache_creation_input_token_cost_above_1hr, - priceData.cache_read_input_token_cost, - priceData.input_cost_per_token_above_200k_tokens, - 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.output_cost_per_image, - ]; - - if ( - numericCosts.some((value) => typeof value === "number" && Number.isFinite(value) && value >= 0) - ) { - return true; - } - - const searchCosts = priceData.search_context_cost_per_query; - if (searchCosts) { - const searchCostFields = [ - searchCosts.search_context_size_high, - searchCosts.search_context_size_low, - searchCosts.search_context_size_medium, - ]; - return searchCostFields.some( - (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 - ); - } - - return false; -} - function formatHeadersForLog(headers: Headers): string { const collected: string[] = []; headers.forEach((value, key) => { diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 535bca4d3..572307ace 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -13,6 +13,8 @@ const instrumentationState = globalThis as unknown as { __CCH_CACHE_CLEANUP_STARTED__?: boolean; __CCH_SHUTDOWN_HOOKS_REGISTERED__?: boolean; __CCH_SHUTDOWN_IN_PROGRESS__?: boolean; + __CCH_CLOUD_PRICE_SYNC_STARTED__?: boolean; + __CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__?: ReturnType; }; /** @@ -40,6 +42,46 @@ async function syncErrorRulesAndInitializeDetector(): Promise { logger.info("Error rule detector cache loaded successfully"); } +/** + * 启动云端价格表定时同步(每 30 分钟一次)。 + * + * 约束: + * - 使用 globalThis 状态去重,避免开发环境热重载重复注册 + * - 失败不阻塞启动,仅记录日志 + */ +async function startCloudPriceSyncScheduler(): Promise { + if (instrumentationState.__CCH_CLOUD_PRICE_SYNC_STARTED__) { + return; + } + + try { + const { requestCloudPriceTableSync } = await import("@/lib/price-sync/cloud-price-updater"); + const intervalMs = 30 * 60 * 1000; + + // 启动后立即触发一次(避免首次 30 分钟空窗期) + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + + instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__ = setInterval(() => { + try { + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + } catch (error) { + logger.warn("[Instrumentation] Cloud price sync scheduler tick failed", { + error: error instanceof Error ? error.message : String(error), + }); + } + }, intervalMs); + + instrumentationState.__CCH_CLOUD_PRICE_SYNC_STARTED__ = true; + logger.info("[Instrumentation] Cloud price sync scheduler started", { + intervalSeconds: intervalMs / 1000, + }); + } catch (error) { + logger.warn("[Instrumentation] Cloud price sync scheduler init failed", { + error: error instanceof Error ? error.message : String(error), + }); + } +} + export async function register() { // 仅在服务器端执行 if (process.env.NEXT_RUNTIME === "nodejs") { @@ -99,6 +141,18 @@ export async function register() { error: error instanceof Error ? error.message : String(error), }); } + + try { + if (instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__) { + clearInterval(instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__); + instrumentationState.__CCH_CLOUD_PRICE_SYNC_INTERVAL_ID__ = undefined; + instrumentationState.__CCH_CLOUD_PRICE_SYNC_STARTED__ = false; + } + } catch (error) { + logger.warn("[Instrumentation] Failed to stop cloud price sync scheduler", { + error: error instanceof Error ? error.message : String(error), + }); + } }; process.once("SIGTERM", () => { @@ -130,6 +184,9 @@ export async function register() { const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer"); await ensurePriceTable(); + // 启动云端价格表定时同步 + await startCloudPriceSyncScheduler(); + // 同步错误规则并初始化检测器(非关键功能,允许优雅降级) try { await syncErrorRulesAndInitializeDetector(); @@ -177,6 +234,11 @@ export async function register() { const { ensurePriceTable } = await import("@/lib/price-sync/seed-initializer"); await ensurePriceTable(); + // 启动云端价格表定时同步(仅在数据库可用时启用,避免本地无 DB 时反复报错) + if (isConnected) { + await startCloudPriceSyncScheduler(); + } + // 同步错误规则并初始化检测器(非关键功能,允许优雅降级) try { await syncErrorRulesAndInitializeDetector(); diff --git a/src/lib/price-sync.ts b/src/lib/price-sync.ts deleted file mode 100644 index 002933ab7..000000000 --- a/src/lib/price-sync.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * LiteLLM 价格表自动同步服务 - * - * 核心功能: - * 1. 从 CDN 获取 LiteLLM 价格表 - * 2. 失败时使用本地缓存降级 - * 3. 成功后更新数据库并刷新缓存 - */ - -import fs from "node:fs/promises"; -import path from "node:path"; -import { isClientAbortError } from "@/app/v1/_lib/proxy/errors"; -import { logger } from "@/lib/logger"; - -const LITELLM_PRICE_URL = - "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"; -const CACHE_FILE_PATH = path.join(process.cwd(), "public", "cache", "litellm-prices.json"); -const FETCH_TIMEOUT_MS = 10000; // 10 秒超时 - -/** - * 确保缓存目录存在 - */ -async function ensureCacheDirectory(): Promise { - const cacheDir = path.dirname(CACHE_FILE_PATH); - try { - await fs.access(cacheDir); - } catch { - await fs.mkdir(cacheDir, { recursive: true }); - } -} - -/** - * 从 CDN 获取 LiteLLM 价格表 JSON 字符串 - * @returns JSON 字符串或 null(失败时) - */ -export async function fetchLiteLLMPrices(): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - const response = await fetch(LITELLM_PRICE_URL, { - signal: controller.signal, - headers: { - Accept: "application/json", - }, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.error("❌ Failed to fetch LiteLLM prices: HTTP ${response.status}"); - return null; - } - - const jsonText = await response.text(); - - // 验证 JSON 格式 - JSON.parse(jsonText); - - logger.info("Successfully fetched LiteLLM prices from CDN"); - return jsonText; - } catch (error) { - if (error instanceof Error) { - if (isClientAbortError(error)) { - logger.error("❌ Fetch LiteLLM prices timeout after 10s"); - } else { - logger.error("❌ Failed to fetch LiteLLM prices:", { context: error.message }); - } - } - return null; - } -} - -/** - * 从本地缓存读取价格表 - * @returns JSON 字符串或 null(缓存不存在或损坏) - */ -export async function readCachedPrices(): Promise { - try { - const cached = await fs.readFile(CACHE_FILE_PATH, "utf-8"); - - // 验证 JSON 格式 - JSON.parse(cached); - - logger.info("📦 Using cached LiteLLM prices"); - return cached; - } catch (error) { - if (error instanceof Error && "code" in error && error.code === "ENOENT") { - logger.info("ℹ️ No cached prices found"); - } else { - logger.error("❌ Failed to read cached prices:", error); - } - return null; - } -} - -/** - * 将价格表保存到本地缓存 - * @param jsonText - JSON 字符串 - */ -export async function saveCachedPrices(jsonText: string): Promise { - try { - await ensureCacheDirectory(); - await fs.writeFile(CACHE_FILE_PATH, jsonText, "utf-8"); - logger.info("💾 Saved prices to cache"); - } catch (error) { - logger.error("❌ Failed to save prices to cache:", error); - } -} - -/** - * 获取价格表 JSON(优先 CDN,降级缓存) - * @returns JSON 字符串或 null - */ -export async function getPriceTableJson(): Promise { - // 优先从 CDN 获取 - const jsonText = await fetchLiteLLMPrices(); - - if (jsonText) { - // 成功后更新缓存 - await saveCachedPrices(jsonText); - return jsonText; - } - - // 失败时降级使用缓存 - logger.info("⚠️ CDN fetch failed, trying cache..."); - return await readCachedPrices(); -} diff --git a/src/lib/price-sync/cloud-price-table.ts b/src/lib/price-sync/cloud-price-table.ts new file mode 100644 index 000000000..e24747726 --- /dev/null +++ b/src/lib/price-sync/cloud-price-table.ts @@ -0,0 +1,107 @@ +import TOML from "@iarna/toml"; +import type { ModelPriceData } from "@/types/model-price"; + +export const CLOUD_PRICE_TABLE_URL = "https://claude-code-hub.app/config/prices-base.toml"; +const FETCH_TIMEOUT_MS = 10000; + +export type CloudPriceTable = { + metadata?: Record; + models: Record; +}; + +export type CloudPriceTableResult = { ok: true; data: T } | { ok: false; error: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function parseCloudPriceTableToml(tomlText: string): CloudPriceTableResult { + try { + const parsed = TOML.parse(tomlText) as unknown; + if (!isRecord(parsed)) { + return { ok: false, error: "价格表格式无效:根节点不是对象" }; + } + + const modelsValue = parsed.models; + if (!isRecord(modelsValue)) { + return { ok: false, error: "价格表格式无效:缺少 models 表" }; + } + + const models: Record = Object.create(null); + for (const [modelName, value] of Object.entries(modelsValue)) { + if (modelName === "__proto__" || modelName === "constructor" || modelName === "prototype") { + continue; + } + if (!isRecord(value)) continue; + models[modelName] = value as unknown as ModelPriceData; + } + + if (Object.keys(models).length === 0) { + return { ok: false, error: "价格表格式无效:models 为空" }; + } + + const metadataValue = parsed.metadata; + const metadata = isRecord(metadataValue) ? metadataValue : undefined; + + return { ok: true, data: { metadata, models } }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `价格表 TOML 解析失败: ${message}` }; + } +} + +export async function fetchCloudPriceTableToml( + url: string = CLOUD_PRICE_TABLE_URL +): Promise> { + const expectedUrl = (() => { + try { + return new URL(url); + } catch { + return null; + } + })(); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + try { + const response = await fetch(url, { + signal: controller.signal, + headers: { + Accept: "text/plain", + }, + cache: "no-store", + }); + + if (expectedUrl && typeof response.url === "string" && response.url) { + try { + const finalUrl = new URL(response.url); + if ( + finalUrl.protocol !== expectedUrl.protocol || + finalUrl.host !== expectedUrl.host || + finalUrl.pathname !== expectedUrl.pathname + ) { + return { ok: false, error: "云端价格表拉取失败:重定向到非预期地址" }; + } + } catch { + // response.url 无法解析时不阻断(仅作安全硬化),继续按原路径处理 + } + } + + if (!response.ok) { + return { ok: false, error: `云端价格表拉取失败:HTTP ${response.status}` }; + } + + const tomlText = await response.text(); + if (!tomlText.trim()) { + return { ok: false, error: "云端价格表拉取失败:内容为空" }; + } + + return { ok: true, data: tomlText }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `云端价格表拉取失败:${message}` }; + } finally { + clearTimeout(timeoutId); + } +} diff --git a/src/lib/price-sync/cloud-price-updater.ts b/src/lib/price-sync/cloud-price-updater.ts new file mode 100644 index 000000000..a64b28164 --- /dev/null +++ b/src/lib/price-sync/cloud-price-updater.ts @@ -0,0 +1,104 @@ +import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { logger } from "@/lib/logger"; +import type { PriceUpdateResult } from "@/types/model-price"; +import { + type CloudPriceTableResult, + fetchCloudPriceTableToml, + parseCloudPriceTableToml, +} from "./cloud-price-table"; + +/** + * 拉取云端 TOML 价格表并写入数据库(不覆盖 manual,本地优先)。 + * + * 说明: + * - 这里复用现有的批处理入库逻辑(processPriceTableInternal),以保持行为一致 + * - 任何失败都以 ok=false 返回,不抛出异常,避免影响调用方主流程 + */ +export async function syncCloudPriceTableToDatabase( + overwriteManual?: string[] +): Promise> { + const tomlResult = await fetchCloudPriceTableToml(); + if (!tomlResult.ok) { + return tomlResult; + } + + const parseResult = parseCloudPriceTableToml(tomlResult.data); + if (!parseResult.ok) { + return { ok: false, error: parseResult.error }; + } + + try { + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const jsonContent = JSON.stringify(parseResult.data.models); + const result = await processPriceTableInternal(jsonContent, overwriteManual); + + if (!result.ok) { + return { ok: false, error: result.error ?? "云端价格表写入失败" }; + } + if (!result.data) { + return { ok: false, error: "云端价格表写入失败:返回结果为空" }; + } + + return { ok: true, data: result.data }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ok: false, error: `云端价格表写入失败:${message}` }; + } +} + +const DEFAULT_THROTTLE_MS = 5 * 60 * 1000; + +/** + * 请求一次云端价格表同步(异步执行,自动去重与节流)。 + * + * 适用场景: + * - 请求命中“未知模型/无价格”时触发异步同步,保证后续请求可命中价格 + */ +export function requestCloudPriceTableSync(options: { + reason: "missing-model" | "scheduled" | "manual"; + throttleMs?: number; +}): void { + const throttleMs = options.throttleMs ?? DEFAULT_THROTTLE_MS; + const taskId = "cloud-price-table-sync"; + + // 去重:已有任务在跑则不重复触发 + const active = AsyncTaskManager.getActiveTasks(); + if (active.some((t) => t.taskId === taskId)) { + return; + } + + // 节流:避免短时间内频繁拉取云端价格表 + const g = globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }; + const lastAt = g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ ?? 0; + const now = Date.now(); + if (now - lastAt < throttleMs) { + return; + } + + AsyncTaskManager.register( + taskId, + (async () => { + try { + const result = await syncCloudPriceTableToDatabase(); + if (!result.ok) { + logger.warn("[PriceSync] Cloud price sync task failed", { + reason: options.reason, + error: result.error, + }); + return; + } + + logger.info("[PriceSync] Cloud price sync task completed", { + reason: options.reason, + added: result.data.added.length, + updated: result.data.updated.length, + skippedConflicts: result.data.skippedConflicts?.length ?? 0, + total: result.data.total, + }); + } finally { + g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + } + })(), + "cloud_price_table_sync" + ); +} diff --git a/src/lib/utils/price-data.ts b/src/lib/utils/price-data.ts new file mode 100644 index 000000000..d8770fae0 --- /dev/null +++ b/src/lib/utils/price-data.ts @@ -0,0 +1,40 @@ +import type { ModelPriceData } from "@/types/model-price"; + +/** + * 判断价格数据是否包含至少一个可用于计费的价格字段。 + * 避免把数据库中的 `{}` 或仅包含元信息的记录当成有效价格。 + */ +export function hasValidPriceData(priceData: ModelPriceData): boolean { + const numericCosts = [ + priceData.input_cost_per_token, + priceData.output_cost_per_token, + priceData.cache_creation_input_token_cost, + priceData.cache_creation_input_token_cost_above_1hr, + priceData.cache_read_input_token_cost, + priceData.input_cost_per_token_above_200k_tokens, + 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.output_cost_per_image, + ]; + + if ( + numericCosts.some((value) => typeof value === "number" && Number.isFinite(value) && value >= 0) + ) { + return true; + } + + const searchCosts = priceData.search_context_cost_per_query; + if (searchCosts) { + const searchCostFields = [ + searchCosts.search_context_size_high, + searchCosts.search_context_size_low, + searchCosts.search_context_size_medium, + ]; + return searchCostFields.some( + (value) => typeof value === "number" && Number.isFinite(value) && value >= 0 + ); + } + + return false; +} diff --git a/src/repository/model-price.ts b/src/repository/model-price.ts index 0d5ab8e0e..764d0e442 100644 --- a/src/repository/model-price.ts +++ b/src/repository/model-price.ts @@ -3,6 +3,7 @@ import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/drizzle/db"; import { modelPrices } from "@/drizzle/schema"; +import { logger } from "@/lib/logger"; import type { ModelPrice, ModelPriceData, ModelPriceSource } from "@/types/model-price"; import { toModelPrice } from "./_shared/transformers"; @@ -14,6 +15,7 @@ export interface PaginationParams { pageSize: number; search?: string; // 可选的搜索关键词 source?: ModelPriceSource; // 可选的来源过滤 + litellmProvider?: string; // 可选的云端提供商过滤(price_data.litellm_provider) } /** @@ -31,61 +33,58 @@ export interface PaginatedResult { * 获取指定模型的最新价格 */ export async function findLatestPriceByModel(modelName: string): Promise { - const [price] = await db - .select({ + try { + const selection = { id: modelPrices.id, modelName: modelPrices.modelName, priceData: modelPrices.priceData, source: modelPrices.source, createdAt: modelPrices.createdAt, updatedAt: modelPrices.updatedAt, - }) - .from(modelPrices) - .where(eq(modelPrices.modelName, modelName)) - .orderBy(desc(modelPrices.createdAt)) - .limit(1); + }; - if (!price) return null; - return toModelPrice(price); + const [price] = await db + .select(selection) + .from(modelPrices) + .where(eq(modelPrices.modelName, modelName)) + .orderBy( + // 本地手动配置优先(哪怕云端数据更新得更晚) + sql`(${modelPrices.source} = 'manual') DESC`, + 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", { + modelName, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } } /** * 获取所有模型的最新价格(非分页版本,保持向后兼容) - * 注意:使用原生SQL,因为涉及到ROW_NUMBER()窗口函数 + * 注意:使用原生 SQL(DISTINCT ON),并确保 manual 来源优先 */ export async function findAllLatestPrices(): Promise { const query = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - mp.model_name, - mp.price_data, - mp.source, - mp.created_at, - mp.updated_at, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT + SELECT DISTINCT ON (model_name) id, model_name as "modelName", price_data as "priceData", source, created_at as "createdAt", updated_at as "updatedAt" - FROM latest_records - WHERE rn = 1 - ORDER BY model_name + FROM model_prices + ORDER BY + model_name, + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC `; const result = await db.execute(query); @@ -94,12 +93,12 @@ export async function findAllLatestPrices(): Promise { /** * 分页获取所有模型的最新价格 - * 注意:使用原生SQL,因为涉及到ROW_NUMBER()窗口函数 + * 注意:使用原生 SQL(DISTINCT ON),并确保 manual 来源优先 */ export async function findAllLatestPricesPaginated( params: PaginationParams ): Promise> { - const { page, pageSize, search, source } = params; + const { page, pageSize, search, source, litellmProvider } = params; const offset = (page - 1) * pageSize; // 构建 WHERE 条件 @@ -111,6 +110,9 @@ export async function findAllLatestPricesPaginated( if (source) { conditions.push(sql`source = ${source}`); } + if (litellmProvider?.trim()) { + conditions.push(sql`price_data->>'litellm_provider' = ${litellmProvider.trim()}`); + } if (conditions.length === 0) return sql``; if (conditions.length === 1) return sql`WHERE ${conditions[0]}`; return sql`WHERE ${sql.join(conditions, sql` AND `)}`; @@ -120,26 +122,9 @@ export async function findAllLatestPricesPaginated( // 先获取总数 const countQuery = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - ${whereCondition} - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT COUNT(*) as total - FROM latest_records - WHERE rn = 1 + SELECT COUNT(DISTINCT model_name) as total + FROM model_prices + ${whereCondition} `; const [countResult] = await db.execute(countQuery); @@ -147,38 +132,20 @@ export async function findAllLatestPricesPaginated( // 获取分页数据 const dataQuery = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - ${whereCondition} - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - mp.model_name, - mp.price_data, - mp.source, - mp.created_at, - mp.updated_at, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT + SELECT DISTINCT ON (model_name) id, model_name as "modelName", price_data as "priceData", source, created_at as "createdAt", updated_at as "updatedAt" - FROM latest_records - WHERE rn = 1 - ORDER BY model_name + FROM model_prices + ${whereCondition} + ORDER BY + model_name, + (source = 'manual') DESC, + created_at DESC NULLS LAST, + id DESC LIMIT ${pageSize} OFFSET ${offset} `; @@ -270,37 +237,19 @@ export async function deleteModelPriceByName(modelName: string): Promise { */ export async function findAllManualPrices(): Promise> { const query = sql` - WITH latest_prices AS ( - SELECT - model_name, - MAX(created_at) as max_created_at - FROM model_prices - WHERE source = 'manual' - GROUP BY model_name - ), - latest_records AS ( - SELECT - mp.id, - mp.model_name, - mp.price_data, - mp.source, - mp.created_at, - mp.updated_at, - ROW_NUMBER() OVER (PARTITION BY mp.model_name ORDER BY mp.id DESC) as rn - FROM model_prices mp - INNER JOIN latest_prices lp - ON mp.model_name = lp.model_name - AND mp.created_at = lp.max_created_at - ) - SELECT + SELECT DISTINCT ON (model_name) id, model_name as "modelName", price_data as "priceData", source, created_at as "createdAt", updated_at as "updatedAt" - FROM latest_records - WHERE rn = 1 + FROM model_prices + WHERE source = 'manual' + ORDER BY + model_name, + created_at DESC NULLS LAST, + id DESC `; const result = await db.execute(query); diff --git a/src/types/model-price.ts b/src/types/model-price.ts index b370719ad..75ae4e81b 100644 --- a/src/types/model-price.ts +++ b/src/types/model-price.ts @@ -28,7 +28,9 @@ export interface ModelPriceData { }; // 模型能力信息 + display_name?: string; litellm_provider?: string; + providers?: string[]; max_input_tokens?: number; max_output_tokens?: number; max_tokens?: number; diff --git a/tests/integration/billing-model-source.test.ts b/tests/integration/billing-model-source.test.ts index e7182237d..ae3ea8a41 100644 --- a/tests/integration/billing-model-source.test.ts +++ b/tests/integration/billing-model-source.test.ts @@ -1,8 +1,9 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelPrice, ModelPriceData } from "@/types/model-price"; import type { SystemSettings } from "@/types/system-config"; const asyncTasks: Promise[] = []; +const cloudPriceSyncRequests: Array<{ reason: string }> = []; vi.mock("@/lib/async-task-manager", () => ({ AsyncTaskManager: { @@ -25,6 +26,12 @@ vi.mock("@/lib/logger", () => ({ }, })); +vi.mock("@/lib/price-sync/cloud-price-updater", () => ({ + requestCloudPriceTableSync: (payload: { reason: string }) => { + cloudPriceSyncRequests.push(payload); + }, +})); + vi.mock("@/repository/model-price", () => ({ findLatestPriceByModel: vi.fn(), })); @@ -82,6 +89,10 @@ import { import { findLatestPriceByModel } from "@/repository/model-price"; import { getSystemSettings } from "@/repository/system-config"; +beforeEach(() => { + cloudPriceSyncRequests.splice(0, cloudPriceSyncRequests.length); +}); + function makeSystemSettings( billingModelSource: SystemSettings["billingModelSource"] ): SystemSettings { @@ -358,3 +369,77 @@ describe("Billing model source - Redis session cost vs DB cost", () => { expect(original.sessionCostUsd).not.toBe(redirected.sessionCostUsd); }); }); + +describe("价格表缺失/查询失败:不计费放行", () => { + async function runNoPriceScenario(options: { + billingModelSource: SystemSettings["billingModelSource"]; + isStream: boolean; + priceLookup: "none" | "throws"; + }): Promise<{ dbCostCalls: number; rateLimitCalls: number }> { + const usage = { input_tokens: 2, output_tokens: 3 }; + const originalModel = "original-model"; + const redirectedModel = "redirected-model"; + + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings(options.billingModelSource)); + if (options.priceLookup === "none") { + vi.mocked(findLatestPriceByModel).mockResolvedValue(null); + } else { + vi.mocked(findLatestPriceByModel).mockImplementation(async () => { + throw new Error("db query failed"); + }); + } + + 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(updateMessageRequestCost).mockResolvedValue(undefined); + vi.mocked(RateLimitService.trackCost).mockResolvedValue(undefined); + vi.mocked(SessionManager.updateSessionUsage).mockResolvedValue(undefined); + + const session = createSession({ + originalModel, + redirectedModel, + sessionId: `sess-no-price-${options.billingModelSource}-${options.isStream ? "s" : "n"}`, + messageId: options.isStream ? 3001 : 3000, + }); + + const response = options.isStream + ? createStreamResponse(usage) + : createNonStreamResponse(usage); + const clientResponse = await ProxyResponseHandler.dispatch(session, response); + await clientResponse.text(); + + await drainAsyncTasks(); + + return { + dbCostCalls: vi.mocked(updateMessageRequestCost).mock.calls.length, + rateLimitCalls: vi.mocked(RateLimitService.trackCost).mock.calls.length, + }; + } + + it("无价格:不写入 DB cost,不追踪限流 cost,并触发一次异步同步", async () => { + const result = await runNoPriceScenario({ + billingModelSource: "redirected", + isStream: false, + priceLookup: "none", + }); + + expect(result.dbCostCalls).toBe(0); + expect(result.rateLimitCalls).toBe(0); + expect(cloudPriceSyncRequests).toEqual([{ reason: "missing-model" }]); + }); + + it("价格查询抛错:不应影响响应,不写入 DB cost,不追踪限流 cost", async () => { + const result = await runNoPriceScenario({ + billingModelSource: "original", + isStream: true, + priceLookup: "throws", + }); + + expect(result.dbCostCalls).toBe(0); + expect(result.rateLimitCalls).toBe(0); + }); +}); diff --git a/tests/unit/actions/model-prices.test.ts b/tests/unit/actions/model-prices.test.ts index e6e7a94ad..a464440fa 100644 --- a/tests/unit/actions/model-prices.test.ts +++ b/tests/unit/actions/model-prices.test.ts @@ -7,13 +7,14 @@ const revalidatePathMock = vi.fn(); // Repository mocks const findLatestPriceByModelMock = vi.fn(); +const findAllLatestPricesMock = vi.fn(); const createModelPriceMock = vi.fn(); const upsertModelPriceMock = vi.fn(); const deleteModelPriceByNameMock = vi.fn(); const findAllManualPricesMock = vi.fn(); // Price sync mock -const getPriceTableJsonMock = vi.fn(); +const fetchCloudPriceTableTomlMock = vi.fn(); vi.mock("@/lib/auth", () => ({ getSession: () => getSessionMock(), @@ -39,7 +40,7 @@ vi.mock("@/repository/model-price", () => ({ upsertModelPrice: (...args: unknown[]) => upsertModelPriceMock(...args), deleteModelPriceByName: (...args: unknown[]) => deleteModelPriceByNameMock(...args), findAllManualPrices: () => findAllManualPricesMock(), - findAllLatestPrices: vi.fn(async () => []), + findAllLatestPrices: () => findAllLatestPricesMock(), findAllLatestPricesPaginated: vi.fn(async () => ({ data: [], total: 0, @@ -50,9 +51,13 @@ vi.mock("@/repository/model-price", () => ({ hasAnyPriceRecords: vi.fn(async () => false), })); -vi.mock("@/lib/price-sync", () => ({ - getPriceTableJson: () => getPriceTableJsonMock(), -})); +vi.mock("@/lib/price-sync/cloud-price-table", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchCloudPriceTableToml: (...args: unknown[]) => fetchCloudPriceTableTomlMock(...args), + }; +}); // Helper to create mock ModelPrice function makeMockPrice( @@ -81,6 +86,7 @@ describe("Model Price Actions", () => { vi.clearAllMocks(); // Default: admin session getSessionMock.mockResolvedValue({ user: { id: 1, role: "admin" } }); + findAllLatestPricesMock.mockResolvedValue([]); }); describe("upsertSingleModelPrice", () => { @@ -224,11 +230,12 @@ describe("Model Price Actions", () => { describe("checkLiteLLMSyncConflicts", () => { it("should return no conflicts when no manual prices exist", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - getPriceTableJsonMock.mockResolvedValue( - JSON.stringify({ - "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, - }) - ); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join( + "\n" + ), + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -247,15 +254,15 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["claude-3-opus", manualPrice]])); - getPriceTableJsonMock.mockResolvedValue( - JSON.stringify({ - "claude-3-opus": { - mode: "chat", - input_cost_per_token: 0.000015, - output_cost_per_token: 0.00006, - }, - }) - ); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: [ + '[models."claude-3-opus"]', + 'mode = "chat"', + "input_cost_per_token = 0.000015", + "output_cost_per_token = 0.00006", + ].join("\n"), + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -274,11 +281,12 @@ describe("Model Price Actions", () => { findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); - getPriceTableJsonMock.mockResolvedValue( - JSON.stringify({ - "claude-3-opus": { mode: "chat", input_cost_per_token: 0.000015 }, - }) - ); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: ['[models."claude-3-opus"]', 'mode = "chat"', "input_cost_per_token = 0.000015"].join( + "\n" + ), + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); @@ -300,24 +308,30 @@ describe("Model Price Actions", () => { it("should handle network errors gracefully", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - getPriceTableJsonMock.mockResolvedValue(null); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: false, + error: "云端价格表拉取失败:mock", + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); expect(result.ok).toBe(false); - expect(result.error).toContain("CDN"); + expect(result.error).toContain("云端"); }); - it("should handle invalid JSON gracefully", async () => { + it("should handle invalid TOML gracefully", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - getPriceTableJsonMock.mockResolvedValue("invalid json {"); + fetchCloudPriceTableTomlMock.mockResolvedValue({ + ok: true, + data: "[models\ninvalid = true", + }); const { checkLiteLLMSyncConflicts } = await import("@/actions/model-prices"); const result = await checkLiteLLMSyncConflicts(); expect(result.ok).toBe(false); - expect(result.error).toContain("JSON"); + expect(result.error).toContain("TOML"); }); }); @@ -329,7 +343,7 @@ describe("Model Price Actions", () => { }); findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); - findLatestPriceByModelMock.mockResolvedValue(manualPrice); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); const { processPriceTableInternal } = await import("@/actions/model-prices"); const result = await processPriceTableInternal( @@ -354,7 +368,7 @@ describe("Model Price Actions", () => { }); findAllManualPricesMock.mockResolvedValue(new Map([["custom-model", manualPrice]])); - findLatestPriceByModelMock.mockResolvedValue(manualPrice); + findAllLatestPricesMock.mockResolvedValue([manualPrice]); deleteModelPriceByNameMock.mockResolvedValue(undefined); createModelPriceMock.mockResolvedValue( makeMockPrice( @@ -386,7 +400,7 @@ describe("Model Price Actions", () => { it("should add new models with litellm source", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - findLatestPriceByModelMock.mockResolvedValue(null); + findAllLatestPricesMock.mockResolvedValue([]); createModelPriceMock.mockResolvedValue( makeMockPrice( "new-model", @@ -414,7 +428,7 @@ describe("Model Price Actions", () => { it("should skip metadata fields like sample_spec", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - findLatestPriceByModelMock.mockResolvedValue(null); + findAllLatestPricesMock.mockResolvedValue([]); const { processPriceTableInternal } = await import("@/actions/model-prices"); const result = await processPriceTableInternal( @@ -431,7 +445,7 @@ describe("Model Price Actions", () => { it("should skip entries without mode field", async () => { findAllManualPricesMock.mockResolvedValue(new Map()); - findLatestPriceByModelMock.mockResolvedValue(null); + findAllLatestPricesMock.mockResolvedValue([]); const { processPriceTableInternal } = await import("@/actions/model-prices"); const result = await processPriceTableInternal( @@ -444,5 +458,36 @@ describe("Model Price Actions", () => { expect(result.ok).toBe(true); expect(result.data?.failed).toContain("invalid-model"); }); + + it("should ignore dangerous keys when comparing price data", async () => { + const existing = makeMockPrice( + "safe-model", + { + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + }, + "litellm" + ); + + findAllManualPricesMock.mockResolvedValue(new Map()); + findAllLatestPricesMock.mockResolvedValue([existing]); + + const { processPriceTableInternal } = await import("@/actions/model-prices"); + const result = await processPriceTableInternal( + JSON.stringify({ + "safe-model": { + mode: "chat", + input_cost_per_token: 0.000001, + output_cost_per_token: 0.000002, + constructor: { prototype: { polluted: true } }, + }, + }) + ); + + expect(result.ok).toBe(true); + expect(result.data?.unchanged).toContain("safe-model"); + expect(createModelPriceMock).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/api/prices-route.test.ts b/tests/unit/api/prices-route.test.ts new file mode 100644 index 000000000..9f25f6356 --- /dev/null +++ b/tests/unit/api/prices-route.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getModelPricesPaginated: vi.fn(), +})); + +vi.mock("@/lib/auth", () => ({ + getSession: mocks.getSession, +})); + +vi.mock("@/actions/model-prices", () => ({ + getModelPricesPaginated: mocks.getModelPricesPaginated, +})); + +describe("GET /api/prices", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 403 when session is missing", async () => { + mocks.getSession.mockResolvedValue(null); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices" } as any); + expect(response.status).toBe(403); + }); + + it("returns 403 when user is not admin", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "user" } }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices" } as any); + expect(response.status).toBe(403); + }); + + it("returns 400 when page is NaN", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "admin" } }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices?page=abc&pageSize=50" } as any); + expect(response.status).toBe(400); + }); + + it("returns 400 when pageSize is NaN", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "admin" } }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices?page=1&pageSize=abc" } as any); + expect(response.status).toBe(400); + }); + + it("returns ok=true when params are valid", async () => { + mocks.getSession.mockResolvedValue({ user: { role: "admin" } }); + mocks.getModelPricesPaginated.mockResolvedValue({ + ok: true, + data: { + data: [], + total: 0, + page: 1, + pageSize: 50, + totalPages: 0, + }, + }); + + const { GET } = await import("@/app/api/prices/route"); + const response = await GET({ url: "http://localhost/api/prices?page=1&pageSize=50" } as any); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.ok).toBe(true); + expect(mocks.getModelPricesPaginated).toHaveBeenCalledWith( + expect.objectContaining({ page: 1, pageSize: 50 }) + ); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-table.test.ts b/tests/unit/price-sync/cloud-price-table.test.ts new file mode 100644 index 000000000..7acc1ff27 --- /dev/null +++ b/tests/unit/price-sync/cloud-price-table.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + fetchCloudPriceTableToml, + parseCloudPriceTableToml, +} from "@/lib/price-sync/cloud-price-table"; + +describe("parseCloudPriceTableToml", () => { + it('parses [models."..."] tables into a model map', () => { + const toml = [ + "[metadata]", + 'version = "test"', + "", + '[models."m1"]', + 'display_name = "Model One"', + 'mode = "chat"', + 'litellm_provider = "anthropic"', + "input_cost_per_token = 0.000001", + "supports_vision = true", + "", + '[models."m1".pricing."anthropic"]', + "input_cost_per_token = 0.000001", + "", + '[models."m2"]', + 'mode = "image_generation"', + 'litellm_provider = "openai"', + "output_cost_per_image = 0.02", + "", + ].join("\n"); + + const result = parseCloudPriceTableToml(toml); + + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(Object.keys(result.data.models).sort()).toEqual(["m1", "m2"]); + expect(result.data.metadata?.version).toBe("test"); + + expect(result.data.models.m1.display_name).toBe("Model One"); + expect(result.data.models.m1.mode).toBe("chat"); + expect(result.data.models.m1.litellm_provider).toBe("anthropic"); + expect(result.data.models.m1.supports_vision).toBe(true); + + const pricing = result.data.models.m1.pricing as { + anthropic?: { input_cost_per_token?: number }; + }; + expect(pricing.anthropic?.input_cost_per_token).toBe(0.000001); + }); + + it("returns an error when models table is missing", () => { + const toml = ["[metadata]", 'version = "test"'].join("\n"); + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(false); + }); + + it("returns an error when TOML is invalid", () => { + const toml = "[models\ninvalid = true"; + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(false); + }); + + it("returns an error when models table is empty", () => { + const toml = ["[models]"].join("\n"); + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(false); + }); + + it("ignores reserved keys in models table", () => { + const toml = [ + '[models."__proto__"]', + 'mode = "chat"', + "input_cost_per_token = 0.000001", + "", + '[models."safe-model"]', + 'mode = "chat"', + "input_cost_per_token = 0.000001", + "", + ].join("\n"); + + const result = parseCloudPriceTableToml(toml); + expect(result.ok).toBe(true); + if (!result.ok) return; + + expect(Object.keys(result.data.models)).toEqual(["safe-model"]); + }); + + it("returns an error when root is not an object (defensive)", async () => { + vi.resetModules(); + vi.doMock("@iarna/toml", () => ({ + default: { + parse: () => 123, + }, + })); + + const mod = await import("@/lib/price-sync/cloud-price-table"); + const result = mod.parseCloudPriceTableToml("[models]"); + expect(result.ok).toBe(false); + + vi.doUnmock("@iarna/toml"); + }); +}); + +describe("fetchCloudPriceTableToml", () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("returns ok=true when response is ok and body is non-empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => "toml content", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(true); + }); + + it("returns ok=false when response is not ok", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 404, + text: async () => "not found", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when response url redirects to unexpected host", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + url: "https://evil.test/prices.toml", + text: async () => "toml content", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when response url redirects to unexpected pathname", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + url: "https://example.test/evil.toml", + text: async () => "toml content", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when url is invalid and fetch throws", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("Invalid URL"); + }) + ); + + const result = await fetchCloudPriceTableToml("not-a-url"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when response body is empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => " ", + })) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when request times out and aborts", async () => { + vi.useFakeTimers(); + + vi.stubGlobal( + "fetch", + vi.fn( + async (_url: string, init?: { signal?: AbortSignal }) => + await new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new Error("AbortError")); + }); + }) + ) + ); + + const promise = fetchCloudPriceTableToml("https://example.test/prices.toml"); + await vi.advanceTimersByTimeAsync(10000); + + const result = await promise; + expect(result.ok).toBe(false); + }); + + it("returns ok=false when fetch throws a non-Error value", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw "boom"; + }) + ); + + const result = await fetchCloudPriceTableToml("https://example.test/prices.toml"); + expect(result.ok).toBe(false); + }); +}); diff --git a/tests/unit/price-sync/cloud-price-updater.test.ts b/tests/unit/price-sync/cloud-price-updater.test.ts new file mode 100644 index 000000000..6b2b69582 --- /dev/null +++ b/tests/unit/price-sync/cloud-price-updater.test.ts @@ -0,0 +1,249 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CloudPriceTableResult } from "@/lib/price-sync/cloud-price-table"; +import { logger } from "@/lib/logger"; +import { + syncCloudPriceTableToDatabase, + requestCloudPriceTableSync, +} from "@/lib/price-sync/cloud-price-updater"; +import { AsyncTaskManager } from "@/lib/async-task-manager"; +import { processPriceTableInternal } from "@/actions/model-prices"; + +const asyncTasks: Promise[] = []; + +vi.mock("@/lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/lib/async-task-manager", () => ({ + AsyncTaskManager: { + getActiveTasks: vi.fn(() => []), + register: vi.fn((_taskId: string, promise: Promise) => { + asyncTasks.push(promise); + return new AbortController(); + }), + }, +})); + +vi.mock("@/actions/model-prices", () => ({ + processPriceTableInternal: vi.fn(async () => ({ + ok: true, + data: { + added: [], + updated: [], + unchanged: [], + failed: [], + total: 0, + }, + })), +})); + +describe("syncCloudPriceTableToDatabase", () => { + beforeEach(() => { + vi.clearAllMocks(); + asyncTasks.splice(0, asyncTasks.length); + vi.unstubAllGlobals(); + }); + + it("returns ok=false when cloud fetch fails with HTTP error", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 500, + text: async () => "server error", + })) + ); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when cloud fetch returns empty body", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => " ", + })) + ); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when TOML is missing models table", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ["[metadata]", 'version = "test"'].join("\n"), + })) + ); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when processPriceTableInternal returns ok=false", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: false, + error: "write failed", + } as unknown as CloudPriceTableResult); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=false when processPriceTableInternal returns ok=true but data is empty", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + })) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: undefined, + } as unknown as CloudPriceTableResult); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(false); + }); + + it("returns ok=true when TOML parses and write succeeds", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + status: 200, + text: async () => + ['[models."m1"]', 'display_name = "Model One"', "input_cost_per_token = 0.000001"].join( + "\n" + ), + })) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: { + added: ["m1"], + updated: [], + unchanged: [], + failed: [], + total: 1, + }, + } as any); + + const result = await syncCloudPriceTableToDatabase(); + expect(result.ok).toBe(true); + expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + }); +}); + +describe("requestCloudPriceTableSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + asyncTasks.splice(0, asyncTasks.length); + vi.unstubAllGlobals(); + delete (globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }) + .__CCH_CLOUD_PRICE_SYNC_LAST_AT__; + }); + + it("does nothing when same task is already active", () => { + vi.mocked(AsyncTaskManager.getActiveTasks).mockReturnValue([ + { taskId: "cloud-price-table-sync" }, + ] as any); + + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + + expect(AsyncTaskManager.register).not.toHaveBeenCalled(); + }); + + it("throttles when called within throttle window", () => { + ( + globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number } + ).__CCH_CLOUD_PRICE_SYNC_LAST_AT__ = Date.now(); + + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 60_000 }); + + expect(AsyncTaskManager.register).not.toHaveBeenCalled(); + }); + + it("registers a task and updates throttle timestamp after completion", async () => { + let resolveFetch: (value: unknown) => void; + const fetchPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => await fetchPromise) + ); + + vi.mocked(processPriceTableInternal).mockResolvedValue({ + ok: true, + data: { + added: ["m1"], + updated: [], + unchanged: [], + failed: [], + total: 1, + }, + } as any); + + requestCloudPriceTableSync({ reason: "missing-model", throttleMs: 0 }); + + expect(AsyncTaskManager.register).toHaveBeenCalledTimes(1); + + const g = globalThis as unknown as { __CCH_CLOUD_PRICE_SYNC_LAST_AT__?: number }; + expect(g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__).toBeUndefined(); + + resolveFetch!({ + ok: true, + status: 200, + text: async () => ['[models."m1"]', "input_cost_per_token = 0.000001"].join("\n"), + }); + + await Promise.all(asyncTasks.splice(0, asyncTasks.length)); + + expect(processPriceTableInternal).toHaveBeenCalledTimes(1); + expect(vi.mocked(logger.info)).toHaveBeenCalled(); + expect(typeof g.__CCH_CLOUD_PRICE_SYNC_LAST_AT__).toBe("number"); + }); + + it("logs warn when sync task fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 500, + text: async () => "server error", + })) + ); + + requestCloudPriceTableSync({ reason: "scheduled", throttleMs: 0 }); + await Promise.all(asyncTasks.splice(0, asyncTasks.length)); + + expect(vi.mocked(logger.warn)).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/proxy/pricing-no-price.test.ts b/tests/unit/proxy/pricing-no-price.test.ts new file mode 100644 index 000000000..46f8d247a --- /dev/null +++ b/tests/unit/proxy/pricing-no-price.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SystemSettings } from "@/types/system-config"; + +const { cloudSyncRequests, requestCloudPriceTableSyncMock } = vi.hoisted(() => { + const cloudSyncRequests: Array<{ reason: string }> = []; + const requestCloudPriceTableSyncMock = vi.fn((payload: { reason: string }) => { + cloudSyncRequests.push(payload); + }); + return { cloudSyncRequests, requestCloudPriceTableSyncMock }; +}); + +vi.mock("@/lib/price-sync/cloud-price-updater", () => ({ + requestCloudPriceTableSync: requestCloudPriceTableSyncMock, +})); + +vi.mock("@/lib/logger", () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + trace: vi.fn(), + }, +})); + +vi.mock("@/repository/model-price", () => ({ + findLatestPriceByModel: vi.fn(), +})); + +vi.mock("@/repository/system-config", () => ({ + getSystemSettings: vi.fn(), +})); + +vi.mock("@/repository/message", () => ({ + updateMessageRequestCost: vi.fn(), + updateMessageRequestDetails: vi.fn(), + updateMessageRequestDuration: vi.fn(), +})); + +vi.mock("@/lib/session-manager", () => ({ + SessionManager: { + updateSessionUsage: vi.fn(async () => {}), + storeSessionResponse: vi.fn(async () => {}), + extractCodexPromptCacheKey: vi.fn(), + updateSessionWithCodexCacheKey: vi.fn(async () => {}), + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + RateLimitService: { + trackCost: vi.fn(), + trackUserDailyCost: vi.fn(), + }, +})); + +vi.mock("@/lib/proxy-status-tracker", () => ({ + ProxyStatusTracker: { + getInstance: () => ({ + endRequest: vi.fn(), + }), + }, +})); + +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 { findLatestPriceByModel } from "@/repository/model-price"; +import { getSystemSettings } from "@/repository/system-config"; + +function makeSystemSettings( + billingModelSource: SystemSettings["billingModelSource"] +): SystemSettings { + const now = new Date(); + return { + id: 1, + siteTitle: "test", + allowGlobalUsageView: false, + currencyDisplay: "USD", + billingModelSource, + enableAutoCleanup: false, + cleanupRetentionDays: 30, + cleanupSchedule: "0 2 * * *", + cleanupBatchSize: 10000, + enableClientVersionCheck: false, + verboseProviderError: false, + enableHttp2: false, + interceptAnthropicWarmupRequests: false, + enableResponseFixer: true, + responseFixerConfig: { + fixTruncatedJson: true, + fixSseFormat: true, + fixEncoding: true, + maxJsonDepth: 200, + maxFixSize: 1024 * 1024, + }, + createdAt: now, + updatedAt: now, + }; +} + +function createSession({ + originalModel, + redirectedModel, +}: { + originalModel: string; + redirectedModel: string; +}): ProxySession { + const session = new ( + ProxySession as unknown as { + new (init: { + startTime: number; + method: string; + requestUrl: URL; + headers: Headers; + headerLog: string; + request: { message: Record; log: string; model: string | null }; + userAgent: string | null; + context: unknown; + clientAbortSignal: AbortSignal | null; + }): ProxySession; + } + )({ + startTime: Date.now(), + method: "POST", + requestUrl: new URL("http://localhost/v1/messages"), + headers: new Headers(), + headerLog: "", + request: { message: {}, log: "(test)", model: redirectedModel }, + userAgent: null, + context: {}, + clientAbortSignal: null, + }); + + session.setOriginalModel(originalModel); + session.setSessionId("sess-test"); + + const provider = { + id: 99, + name: "test-provider", + providerType: "claude", + costMultiplier: 1.0, + streamingIdleTimeoutMs: 0, + } as any; + + const user = { + id: 123, + name: "test-user", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + } as any; + + const key = { + id: 456, + name: "test-key", + dailyResetTime: "00:00", + dailyResetMode: "fixed", + } as any; + + session.setProvider(provider); + session.setAuthState({ + user, + key, + apiKey: "sk-test", + success: true, + }); + session.setMessageContext({ + id: 2000, + createdAt: new Date(), + user, + key, + apiKey: "sk-test", + }); + + return session; +} + +describe("价格表缺失/查询失败:请求不计费且不报错", () => { + beforeEach(() => { + cloudSyncRequests.splice(0, cloudSyncRequests.length); + vi.clearAllMocks(); + }); + + it("无价格:应跳过 DB cost 更新与限流 cost 追踪,并触发异步同步", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(findLatestPriceByModel).mockResolvedValue(null); + + const session = createSession({ originalModel: "m1", redirectedModel: "m2" }); + const responseText = JSON.stringify({ + type: "message", + usage: { input_tokens: 2, output_tokens: 3 }, + }); + await finalizeRequestStats(session, responseText, 200, 5); + + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + expect(RateLimitService.trackCost).not.toHaveBeenCalled(); + expect(findLatestPriceByModel).toHaveBeenCalled(); + expect(cloudSyncRequests).toEqual([{ reason: "missing-model" }]); + }); + + it("价格数据为空对象:应视为无价格并触发异步同步", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("redirected")); + vi.mocked(findLatestPriceByModel).mockImplementation(async (modelName: string) => { + if (modelName === "m2") { + return { + id: 1, + modelName: "m2", + priceData: {}, + source: "litellm", + createdAt: new Date(), + updatedAt: new Date(), + } as any; + } + return null; + }); + + const session = createSession({ originalModel: "m1", redirectedModel: "m2" }); + const responseText = JSON.stringify({ + type: "message", + usage: { input_tokens: 2, output_tokens: 3 }, + }); + await finalizeRequestStats(session, responseText, 200, 5); + + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + expect(RateLimitService.trackCost).not.toHaveBeenCalled(); + expect(cloudSyncRequests).toEqual([{ reason: "missing-model" }]); + }); + + it("价格查询抛错:应跳过计费且不影响响应", async () => { + vi.mocked(getSystemSettings).mockResolvedValue(makeSystemSettings("original")); + vi.mocked(findLatestPriceByModel).mockImplementation(async () => { + throw new Error("db query failed"); + }); + + const session = createSession({ originalModel: "m1", redirectedModel: "m2" }); + const responseText = JSON.stringify({ + type: "message", + usage: { input_tokens: 2, output_tokens: 3 }, + }); + await finalizeRequestStats(session, responseText, 200, 5); + + expect(updateMessageRequestCost).not.toHaveBeenCalled(); + expect(RateLimitService.trackCost).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/settings/prices/price-list-zero-price-ui.test.tsx b/tests/unit/settings/prices/price-list-zero-price-ui.test.tsx new file mode 100644 index 000000000..cd56a8968 --- /dev/null +++ b/tests/unit/settings/prices/price-list-zero-price-ui.test.tsx @@ -0,0 +1,85 @@ +/** + * @vitest-environment happy-dom + */ + +import fs from "node:fs"; +import path from "node:path"; +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"; + +function loadMessages() { + const base = path.join(process.cwd(), "messages/en"); + const read = (name: string) => JSON.parse(fs.readFileSync(path.join(base, name), "utf8")); + + return { + common: read("common.json"), + errors: read("errors.json"), + ui: read("ui.json"), + forms: read("forms.json"), + settings: read("settings.json"), + }; +} + +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: formatPrice 应正确处理 0", () => { + test("input/output 为 0 时应显示 0 而非占位符", () => { + const messages = loadMessages(); + const now = new Date("2026-01-01T00:00:00.000Z"); + + const prices: ModelPrice[] = [ + { + id: 1, + modelName: "zero-model", + priceData: { + mode: "chat", + display_name: "Zero Model", + input_cost_per_token: 0, + output_cost_per_token: 0, + }, + source: "litellm", + createdAt: now, + updatedAt: now, + }, + ]; + + const { unmount } = render( + + + + ); + + expect(document.body.textContent).toContain("$0.0000/M"); + expect(document.body.textContent).not.toContain("$-/M"); + + unmount(); + }); +});