Просмотр исходного кода

refactor(prices): TOML cloud price table + billing fail-open (#580)

* refactor(prices): sync TOML cloud price table and harden billing

* fix(prices): address PR review feedback
Ding 1 месяц назад
Родитель
Сommit
373220d3e1
30 измененных файлов с 2287 добавлено и 641 удалено
  1. 16 9
      drizzle/meta/0052_snapshot.json
  2. 2 2
      drizzle/meta/_journal.json
  3. 35 7
      messages/en/settings.json
  4. 35 7
      messages/ja/settings.json
  5. 35 7
      messages/ru/settings.json
  6. 35 7
      messages/zh-CN/settings.json
  7. 35 7
      messages/zh-TW/settings.json
  8. 1 0
      package.json
  9. 117 35
      src/actions/model-prices.ts
  10. 295 69
      src/app/[locale]/settings/prices/_components/price-list.tsx
  11. 12 8
      src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx
  12. 12 7
      src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx
  13. 17 2
      src/app/[locale]/settings/prices/page.tsx
  14. 17 4
      src/app/api/prices/route.ts
  15. 205 160
      src/app/v1/_lib/proxy/response-handler.ts
  16. 1 39
      src/app/v1/_lib/proxy/session.ts
  17. 62 0
      src/instrumentation.ts
  18. 0 128
      src/lib/price-sync.ts
  19. 107 0
      src/lib/price-sync/cloud-price-table.ts
  20. 104 0
      src/lib/price-sync/cloud-price-updater.ts
  21. 40 0
      src/lib/utils/price-data.ts
  22. 57 108
      src/repository/model-price.ts
  23. 2 0
      src/types/model-price.ts
  24. 86 1
      tests/integration/billing-model-source.test.ts
  25. 79 34
      tests/unit/actions/model-prices.test.ts
  26. 76 0
      tests/unit/api/prices-route.test.ts
  27. 225 0
      tests/unit/price-sync/cloud-price-table.test.ts
  28. 249 0
      tests/unit/price-sync/cloud-price-updater.test.ts
  29. 245 0
      tests/unit/proxy/pricing-no-price.test.ts
  30. 85 0
      tests/unit/settings/prices/price-list-zero-price-ui.test.tsx

+ 16 - 9
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": {}
   }
-}
+}

+ 2 - 2
drizzle/meta/_journal.json

@@ -369,9 +369,9 @@
     {
       "idx": 52,
       "version": "7",
-      "when": 1767924921400,
+      "when": 1768052041185,
       "tag": "0052_model_price_source",
       "breakpoints": true
     }
   ]
-}
+}

+ 35 - 7
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": {

+ 35 - 7
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": {

+ 35 - 7
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": {

+ 35 - 7
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": {

+ 35 - 7
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": {

+ 1 - 0
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",

+ 117 - 35
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<object>();
+
+    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<string, unknown>;
+      const result: Record<string, unknown> = 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<string, ModelPrice>();
+    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<ActionResult<PriceUpdateResult>> {
   // 权限检查:只有管理员可以上传价格表
@@ -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<ActionResult<SyncConf
       return { ok: false, error: "无权限执行此操作" };
     }
 
-    // 获取价格表 JSON
-    const jsonContent = await getPriceTableJson();
-    if (!jsonContent) {
+    // 拉取并解析云端 TOML 价格表
+    const tomlResult = await fetchCloudPriceTableToml();
+    if (!tomlResult.ok) {
       return {
         ok: false,
-        error: "无法从 CDN 或缓存获取价格表,请检查网络连接或稍后重试",
+        error: tomlResult.error,
       };
     }
 
-    // 解析 JSON
-    let priceTable: PriceTableJson;
-    try {
-      priceTable = JSON.parse(jsonContent);
-    } catch {
-      return { ok: false, error: "JSON格式不正确" };
+    const parseResult = parseCloudPriceTableToml(tomlResult.data);
+    if (!parseResult.ok) {
+      return { ok: false, error: parseResult.error };
     }
 
+    const priceTable: PriceTableJson = parseResult.data.models;
+
     // 获取数据库中所有 manual 价格
     const manualPrices = await findAllManualPrices();
     logger.info(`[Conflict Check] Found ${manualPrices.size} manual prices in database`);
@@ -349,31 +408,40 @@ export async function syncLiteLLMPrices(
       return { ok: false, error: "无权限执行此操作" };
     }
 
-    logger.info("🔄 Starting LiteLLM price sync...");
+    logger.info("[PriceSync] Starting cloud price sync...");
 
-    // 获取价格表 JSON(优先 CDN,降级缓存)
-    const jsonContent = await getPriceTableJson();
+    // 拉取并解析云端 TOML 价格表
+    const tomlResult = await fetchCloudPriceTableToml();
+    if (!tomlResult.ok) {
+      logger.error("[PriceSync] Failed to fetch cloud price table", { error: tomlResult.error });
+      return { ok: false, error: tomlResult.error };
+    }
 
-    if (!jsonContent) {
-      logger.error("❌ Failed to get price table from both CDN and cache");
-      return {
-        ok: false,
-        error: "无法从 CDN 或缓存获取价格表,请检查网络连接或稍后重试",
-      };
+    const parseResult = parseCloudPriceTableToml(tomlResult.data);
+    if (!parseResult.ok) {
+      logger.error("[PriceSync] Failed to parse cloud price table", { error: parseResult.error });
+      return { ok: false, error: parseResult.error };
     }
 
-    // 调用现有的上传逻辑(已包含权限检查,但这里直接处理以避免重复检查)
-    const result = await uploadPriceTable(jsonContent, overwriteManual);
+    const jsonContent = JSON.stringify(parseResult.data.models);
+    const result = await processPriceTableInternal(jsonContent, overwriteManual);
 
     if (result.ok) {
-      logger.info("LiteLLM price sync completed", { result: result.data });
+      logger.info("[PriceSync] Cloud price sync completed", {
+        added: result.data.added.length,
+        updated: result.data.updated.length,
+        unchanged: result.data.unchanged.length,
+        failed: result.data.failed.length,
+        skippedConflicts: result.data.skippedConflicts?.length ?? 0,
+        total: result.data.total,
+      });
     } else {
-      logger.error("❌ LiteLLM price sync failed:", { context: result.error });
+      logger.error("[PriceSync] Cloud price sync failed", { error: result.error });
     }
 
     return result;
   } catch (error) {
-    logger.error("❌ Sync LiteLLM prices failed:", error);
+    logger.error("[PriceSync] Cloud price sync failed", error);
     const message = error instanceof Error ? error.message : "同步失败,请稍后重试";
     return { ok: false, error: message };
   }
@@ -442,7 +510,14 @@ export async function upsertSingleModelPrice(
     const result = await upsertModelPrice(input.modelName.trim(), priceData);
 
     // 刷新页面数据
-    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) {
@@ -472,7 +547,14 @@ export async function deleteSingleModelPrice(modelName: string): Promise<ActionR
     await deleteModelPriceByName(modelName.trim());
 
     // 刷新页面数据
-    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: undefined };
   } catch (error) {

+ 295 - 69
src/app/[locale]/settings/prices/_components/price-list.tsx

@@ -1,17 +1,26 @@
 "use client";
 
+import { Claude, Gemini, OpenAI } from "@lobehub/icons";
 import {
+  Braces,
   ChevronLeft,
   ChevronRight,
+  Code2,
+  Database,
   DollarSign,
+  Eye,
+  FileText,
+  Monitor,
   MoreHorizontal,
   Package,
   Pencil,
   Search,
+  Sparkles,
+  Terminal,
   Trash2,
 } from "lucide-react";
-import { useTranslations } from "next-intl";
-import { useCallback, useEffect, useState } from "react";
+import { useLocale, useTranslations } from "next-intl";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { Badge } from "@/components/ui/badge";
 import { Button } from "@/components/ui/button";
 import {
@@ -36,8 +45,9 @@ import {
   TableHeader,
   TableRow,
 } from "@/components/ui/table";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
 import { useDebounce } from "@/lib/hooks/use-debounce";
-import type { ModelPrice } from "@/types/model-price";
+import type { ModelPrice, ModelPriceSource } from "@/types/model-price";
 import { DeleteModelDialog } from "./delete-model-dialog";
 import { ModelPriceDialog } from "./model-price-dialog";
 
@@ -46,6 +56,9 @@ interface PriceListProps {
   initialTotal: number;
   initialPage: number;
   initialPageSize: number;
+  initialSearchTerm: string;
+  initialSourceFilter: ModelPriceSource | "";
+  initialLitellmProviderFilter: string;
 }
 
 /**
@@ -56,9 +69,15 @@ export function PriceList({
   initialTotal,
   initialPage,
   initialPageSize,
+  initialSearchTerm,
+  initialSourceFilter,
+  initialLitellmProviderFilter,
 }: PriceListProps) {
   const t = useTranslations("settings.prices");
-  const [searchTerm, setSearchTerm] = useState("");
+  const locale = useLocale();
+  const [searchTerm, setSearchTerm] = useState(initialSearchTerm);
+  const [sourceFilter, setSourceFilter] = useState<ModelPriceSource | "">(initialSourceFilter);
+  const [litellmProviderFilter, setLitellmProviderFilter] = useState(initialLitellmProviderFilter);
   const [prices, setPrices] = useState<ModelPrice[]>(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 <Badge variant="default">{t("table.typeChat")}</Badge>;
+        return null;
       case "image_generation":
         return <Badge variant="secondary">{t("table.typeImage")}</Badge>;
       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 (
     <div className="space-y-4">
+      {/* 快捷筛选 */}
+      <div className="flex flex-wrap items-center gap-2">
+        <Button
+          type="button"
+          variant={!sourceFilter && !litellmProviderFilter ? "default" : "outline"}
+          size="sm"
+          onClick={() => applyFilters({ source: "", litellmProvider: "" })}
+        >
+          {t("filters.all")}
+        </Button>
+
+        <Button
+          type="button"
+          variant={sourceFilter === "manual" ? "default" : "outline"}
+          size="sm"
+          onClick={() =>
+            applyFilters({
+              source: sourceFilter === "manual" ? "" : "manual",
+              litellmProvider: "",
+            })
+          }
+        >
+          <Package className="h-4 w-4 mr-2" />
+          {t("filters.local")}
+        </Button>
+
+        <Button
+          type="button"
+          variant={litellmProviderFilter === "anthropic" ? "default" : "outline"}
+          size="sm"
+          onClick={() =>
+            applyFilters({
+              source: "",
+              litellmProvider: litellmProviderFilter === "anthropic" ? "" : "anthropic",
+            })
+          }
+        >
+          <Claude.Color className="h-4 w-4 mr-2" />
+          {t("filters.anthropic")}
+        </Button>
+
+        <Button
+          type="button"
+          variant={litellmProviderFilter === "openai" ? "default" : "outline"}
+          size="sm"
+          onClick={() =>
+            applyFilters({
+              source: "",
+              litellmProvider: litellmProviderFilter === "openai" ? "" : "openai",
+            })
+          }
+        >
+          <OpenAI className="h-4 w-4 mr-2" />
+          {t("filters.openai")}
+        </Button>
+
+        <Button
+          type="button"
+          variant={litellmProviderFilter === "vertex_ai-language-models" ? "default" : "outline"}
+          size="sm"
+          onClick={() =>
+            applyFilters({
+              source: "",
+              litellmProvider:
+                litellmProviderFilter === "vertex_ai-language-models"
+                  ? ""
+                  : "vertex_ai-language-models",
+            })
+          }
+        >
+          <Gemini.Color className="h-4 w-4 mr-2" />
+          {t("filters.vertex")}
+        </Button>
+      </div>
+
       {/* 搜索和页面大小控制 */}
       <div className="flex items-center gap-4">
         <div className="relative flex-1">
@@ -225,9 +386,7 @@ export function PriceList({
           />
         </div>
         <div className="flex items-center gap-2">
-          <span className="text-sm text-muted-foreground">
-            {t("pagination.perPage", { size: "" }).replace(/\d+/, "")}
-          </span>
+          <span className="text-sm text-muted-foreground">{t("pagination.perPageLabel")}</span>
           <Select
             value={pageSize.toString()}
             onValueChange={(value) => handlePageSizeChange(parseInt(value, 10))}
@@ -250,9 +409,9 @@ export function PriceList({
         <Table className="table-fixed">
           <TableHeader>
             <TableRow>
-              <TableHead className="w-48 whitespace-normal">{t("table.modelName")}</TableHead>
-              <TableHead className="w-24">{t("table.type")}</TableHead>
-              <TableHead className="w-32 whitespace-normal">{t("table.provider")}</TableHead>
+              <TableHead className="w-72 whitespace-normal">{t("table.modelName")}</TableHead>
+              <TableHead className="w-40 whitespace-normal">{t("table.provider")}</TableHead>
+              <TableHead className="w-40 whitespace-normal">{t("table.capabilities")}</TableHead>
               <TableHead className="w-32 text-right">{t("table.inputPrice")}</TableHead>
               <TableHead className="w-32 text-right">{t("table.outputPrice")}</TableHead>
               <TableHead className="w-32">{t("table.updatedAt")}</TableHead>
@@ -272,12 +431,58 @@ export function PriceList({
             ) : filteredPrices.length > 0 ? (
               filteredPrices.map((price) => (
                 <TableRow key={price.id}>
-                  <TableCell className="font-mono text-sm whitespace-normal break-words">
-                    {price.modelName}
+                  <TableCell className="whitespace-normal break-words">
+                    <div className="flex flex-wrap items-center gap-2">
+                      <span className="font-medium">
+                        {price.priceData.display_name?.trim() || price.modelName}
+                      </span>
+                      {price.source === "manual" && (
+                        <Badge variant="outline">{t("badges.local")}</Badge>
+                      )}
+                      {price.priceData.mode ? getModeBadge(price.priceData.mode) : null}
+                    </div>
+                    {price.priceData.display_name?.trim() &&
+                    price.priceData.display_name.trim() !== price.modelName ? (
+                      <div className="mt-1 font-mono text-xs text-muted-foreground">
+                        {price.modelName}
+                      </div>
+                    ) : null}
                   </TableCell>
-                  <TableCell>{getModeLabel(price.priceData.mode)}</TableCell>
                   <TableCell className="whitespace-normal break-words">
-                    {price.priceData.litellm_provider || "-"}
+                    {price.priceData.litellm_provider ? (
+                      <span className="font-mono text-xs">{price.priceData.litellm_provider}</span>
+                    ) : (
+                      <span className="text-muted-foreground">-</span>
+                    )}
+                  </TableCell>
+                  <TableCell>
+                    <div className="flex flex-wrap gap-1">
+                      {capabilityItems.map(({ key, icon: Icon, label }) => {
+                        const enabled = price.priceData[key] === true;
+                        const status = enabled
+                          ? t("capabilities.statusSupported")
+                          : t("capabilities.statusUnsupported");
+                        const tooltipText = t("capabilities.tooltip", { label, status });
+                        return (
+                          <Tooltip key={key}>
+                            <TooltipTrigger asChild>
+                              <button
+                                type="button"
+                                aria-label={tooltipText}
+                                className={`inline-flex h-7 w-7 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
+                                  enabled
+                                    ? "bg-primary/10 text-primary border-primary/20"
+                                    : "bg-muted/30 text-muted-foreground/40 border-transparent"
+                                }`}
+                              >
+                                <Icon className="h-4 w-4" aria-hidden="true" />
+                              </button>
+                            </TooltipTrigger>
+                            <TooltipContent sideOffset={4}>{tooltipText}</TooltipContent>
+                          </Tooltip>
+                        );
+                      })}
+                    </div>
                   </TableCell>
                   <TableCell className="font-mono text-sm text-right">
                     {price.priceData.mode === "image_generation" ? (
@@ -300,12 +505,17 @@ export function PriceList({
                     )}
                   </TableCell>
                   <TableCell className="text-sm text-muted-foreground">
-                    {new Date(price.createdAt).toLocaleDateString("zh-CN")}
+                    {new Date(price.updatedAt ?? price.createdAt).toLocaleDateString(locale)}
                   </TableCell>
                   <TableCell>
                     <DropdownMenu>
                       <DropdownMenuTrigger asChild>
-                        <Button variant="ghost" size="icon" className="h-8 w-8">
+                        <Button
+                          variant="ghost"
+                          size="icon"
+                          className="h-8 w-8"
+                          aria-label={t("actions.more")}
+                        >
                           <MoreHorizontal className="h-4 w-4" />
                         </Button>
                       </DropdownMenuTrigger>
@@ -313,7 +523,15 @@ export function PriceList({
                         <ModelPriceDialog
                           mode="edit"
                           initialData={price}
-                          onSuccess={() => fetchPrices(page, pageSize, debouncedSearchTerm)}
+                          onSuccess={() =>
+                            fetchPrices(
+                              page,
+                              pageSize,
+                              debouncedSearchTerm,
+                              sourceFilter,
+                              litellmProviderFilter
+                            )
+                          }
                           trigger={
                             <DropdownMenuItem onSelect={(e) => e.preventDefault()}>
                               <Pencil className="h-4 w-4 mr-2" />
@@ -323,7 +541,15 @@ export function PriceList({
                         />
                         <DeleteModelDialog
                           modelName={price.modelName}
-                          onSuccess={() => fetchPrices(page, pageSize, debouncedSearchTerm)}
+                          onSuccess={() =>
+                            fetchPrices(
+                              page,
+                              pageSize,
+                              debouncedSearchTerm,
+                              sourceFilter,
+                              litellmProviderFilter
+                            )
+                          }
                           trigger={
                             <DropdownMenuItem
                               onSelect={(e) => e.preventDefault()}
@@ -442,8 +668,8 @@ export function PriceList({
             time:
               prices.length > 0
                 ? new Date(
-                    Math.max(...prices.map((p) => new Date(p.createdAt).getTime()))
-                  ).toLocaleDateString()
+                    Math.max(...prices.map((p) => new Date(p.updatedAt ?? p.createdAt).getTime()))
+                  ).toLocaleDateString(locale)
                 : "-",
           })}
         </div>

+ 12 - 8
src/app/[locale]/settings/prices/_components/sync-litellm-button.tsx

@@ -11,7 +11,7 @@ import type { SyncConflict } from "@/types/model-price";
 import { SyncConflictDialog } from "./sync-conflict-dialog";
 
 /**
- * LiteLLM 价格同步按钮组件
+ * 云端价格表同步按钮组件
  */
 export function SyncLiteLLMButton() {
   const t = useTranslations("settings");
@@ -33,7 +33,8 @@ export function SyncLiteLLMButton() {
       const response = await syncLiteLLMPrices(overwriteManual);
 
       if (!response.ok) {
-        toast.error(response.error || t("prices.sync.failed"));
+        console.error("云端价格表同步失败:", response.error);
+        toast.error(t("prices.sync.failed"));
         return;
       }
 
@@ -48,7 +49,9 @@ export function SyncLiteLLMButton() {
       if (failed.length > 0) {
         toast.error(
           t("prices.sync.partialFailure", { failed: failed.length }) +
-            (failed.length <= 5 ? `\n失败模型: ${failed.join(", ")}` : ""),
+            (failed.length <= 5
+              ? `\n${t("prices.sync.failedModels", { models: failed.join(", ") })}`
+              : ""),
           {
             duration: 5000, // 失败消息显示更长时间
           }
@@ -77,8 +80,8 @@ export function SyncLiteLLMButton() {
       router.refresh();
       window.dispatchEvent(new Event("price-data-updated"));
     } catch (error) {
-      console.error("同步失败:", error);
-      toast.error(t("prices.sync.failedError"));
+      console.error("云端价格表同步失败:", error);
+      toast.error(t("prices.sync.failed"));
     } finally {
       setSyncing(false);
     }
@@ -95,7 +98,8 @@ export function SyncLiteLLMButton() {
       const checkResult = await checkLiteLLMSyncConflicts();
 
       if (!checkResult.ok) {
-        toast.error(checkResult.error || t("prices.sync.failed"));
+        console.error("云端价格表冲突检查失败:", checkResult.error);
+        toast.error(t("prices.sync.failed"));
         return;
       }
 
@@ -108,8 +112,8 @@ export function SyncLiteLLMButton() {
         await doSync();
       }
     } catch (error) {
-      console.error("检查冲突失败:", error);
-      toast.error(t("prices.sync.failedError"));
+      console.error("云端价格表冲突检查失败:", error);
+      toast.error(t("prices.sync.failed"));
     } finally {
       setChecking(false);
     }

+ 12 - 7
src/app/[locale]/settings/prices/_components/upload-price-dialog.tsx

@@ -84,7 +84,8 @@ export function UploadPriceDialog({
     if (!file) return;
 
     // 验证文件类型
-    if (!file.name.endsWith(".json")) {
+    const lowerName = file.name.toLowerCase();
+    if (!lowerName.endsWith(".json") && !lowerName.endsWith(".toml")) {
       toast.error(t("dialog.invalidFileType"));
       return;
     }
@@ -106,7 +107,8 @@ export function UploadPriceDialog({
       const response = await uploadPriceTable(text);
 
       if (!response.ok) {
-        toast.error(response.error);
+        console.error("价格表上传失败:", response.error);
+        toast.error(t("dialog.updateFailed"));
         return;
       }
 
@@ -194,7 +196,7 @@ export function UploadPriceDialog({
                   <input
                     id="price-file-input"
                     type="file"
-                    accept=".json"
+                    accept=".json,.toml"
                     className="hidden"
                     onChange={handleFileSelect}
                     disabled={uploading}
@@ -208,7 +210,7 @@ export function UploadPriceDialog({
                   • {t("dialog.manualDownload")}{" "}
                   <a
                     className="text-blue-500 underline"
-                    href="https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
+                    href="https://claude-code-hub.app/config/prices-base.toml"
                     target="_blank"
                     rel="noopener noreferrer"
                   >
@@ -237,7 +239,8 @@ export function UploadPriceDialog({
                     </div>
                     <div className="text-xs text-muted-foreground ml-6">
                       {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 })}
                     </div>
                   </div>
                 )}
@@ -252,7 +255,8 @@ export function UploadPriceDialog({
                     </div>
                     <div className="text-xs text-muted-foreground ml-6">
                       {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 })}
                     </div>
                   </div>
                 )}
@@ -277,7 +281,8 @@ export function UploadPriceDialog({
                     </div>
                     <div className="text-xs text-muted-foreground ml-6">
                       {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 })}
                     </div>
                   </div>
                 )}

+ 17 - 2
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 ?? ""}
       />
     </Section>
   );

+ 17 - 4
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 层面执行)

+ 205 - 160
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<void> {
   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),
+    });
+  }
 }
 
 /**

+ 1 - 39
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) => {

+ 62 - 0
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<typeof setInterval>;
 };
 
 /**
@@ -40,6 +42,46 @@ async function syncErrorRulesAndInitializeDetector(): Promise<void> {
   logger.info("Error rule detector cache loaded successfully");
 }
 
+/**
+ * 启动云端价格表定时同步(每 30 分钟一次)。
+ *
+ * 约束:
+ * - 使用 globalThis 状态去重,避免开发环境热重载重复注册
+ * - 失败不阻塞启动,仅记录日志
+ */
+async function startCloudPriceSyncScheduler(): Promise<void> {
+  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();

+ 0 - 128
src/lib/price-sync.ts

@@ -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<void> {
-  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<string | null> {
-  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<string | null> {
-  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<void> {
-  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<string | null> {
-  // 优先从 CDN 获取
-  const jsonText = await fetchLiteLLMPrices();
-
-  if (jsonText) {
-    // 成功后更新缓存
-    await saveCachedPrices(jsonText);
-    return jsonText;
-  }
-
-  // 失败时降级使用缓存
-  logger.info("⚠️  CDN fetch failed, trying cache...");
-  return await readCachedPrices();
-}

+ 107 - 0
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<string, unknown>;
+  models: Record<string, ModelPriceData>;
+};
+
+export type CloudPriceTableResult<T> = { ok: true; data: T } | { ok: false; error: string };
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+export function parseCloudPriceTableToml(tomlText: string): CloudPriceTableResult<CloudPriceTable> {
+  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<string, ModelPriceData> = 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<CloudPriceTableResult<string>> {
+  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);
+  }
+}

+ 104 - 0
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<CloudPriceTableResult<PriceUpdateResult>> {
+  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"
+  );
+}

+ 40 - 0
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;
+}

+ 57 - 108
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<T> {
  * 获取指定模型的最新价格
  */
 export async function findLatestPriceByModel(modelName: string): Promise<ModelPrice | null> {
-  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<ModelPrice[]> {
   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<ModelPrice[]> {
 
 /**
  * 分页获取所有模型的最新价格
- * 注意:使用原生SQL,因为涉及到ROW_NUMBER()窗口函数
+ * 注意:使用原生 SQL(DISTINCT ON),并确保 manual 来源优先
  */
 export async function findAllLatestPricesPaginated(
   params: PaginationParams
 ): Promise<PaginatedResult<ModelPrice>> {
-  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<void> {
  */
 export async function findAllManualPrices(): Promise<Map<string, ModelPrice>> {
   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);

+ 2 - 0
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;

+ 86 - 1
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<void>[] = [];
+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);
+  });
+});

+ 79 - 34
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<typeof import("@/lib/price-sync/cloud-price-table")>();
+  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();
+    });
   });
 });

+ 76 - 0
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 })
+    );
+  });
+});

+ 225 - 0
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);
+  });
+});

+ 249 - 0
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<void>[] = [];
+
+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<void>) => {
+      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<unknown>);
+
+    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<unknown>);
+
+    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();
+  });
+});

+ 245 - 0
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<string, unknown>; 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();
+  });
+});

+ 85 - 0
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(
+      <NextIntlClientProvider locale="en" messages={messages}>
+        <PriceList
+          initialPrices={prices}
+          initialTotal={prices.length}
+          initialPage={1}
+          initialPageSize={50}
+          initialSearchTerm=""
+          initialSourceFilter=""
+          initialLitellmProviderFilter=""
+        />
+      </NextIntlClientProvider>
+    );
+
+    expect(document.body.textContent).toContain("$0.0000/M");
+    expect(document.body.textContent).not.toContain("$-/M");
+
+    unmount();
+  });
+});