瀏覽代碼

fix: 修复模型重定向导致的费用计算为 0 的问题

问题根因:
- 模型重定向后,费用计算使用重定向后的模型名查询价格表
- 当价格表中没有重定向后的模型时,费用计算失败,显示为 $0

解决方案:
1. 实现 Fallback 计费机制
   - 优先按原始模型(用户请求的)计费
   - 找不到时自动 fallback 到重定向模型
   - 完全失败时记录详细 ERROR 日志

2. 增强日志追踪
   - 所有费用计算路径都有 DEBUG/INFO/WARN/ERROR 日志
   - 包含模型名称、价格数据、计算结果等完整诊断信息

3. UI 改进
   - 使用日志页面显示供应商倍率 Badge
   - 加价显示红色,折扣显示绿色

技术改动:
- ProxySession: 新增原始模型追踪方法
- ModelRedirector: 重定向前保存原始模型
- ResponseHandler: 实现 Fallback 逻辑 + 完整日志
- Schema: messageRequest 表新增 cost_multiplier 字段
- UI: 使用日志表格显示倍率 Badge

数据库迁移:
- 新增 cost_multiplier 字段(numeric(10, 4))
- 需要执行 `pnpm db:migrate` 或设置 `AUTO_MIGRATE=true`

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
ding113 3 月之前
父節點
當前提交
4ec03dfb89

+ 1 - 0
drizzle/0004_dazzling_starbolt.sql

@@ -0,0 +1 @@
+ALTER TABLE "message_request" ADD COLUMN "cost_multiplier" numeric(10, 4);

+ 1065 - 0
drizzle/meta/0004_snapshot.json

@@ -0,0 +1,1065 @@
+{
+  "id": "e9b1fd00-2bda-4ba5-87be-dce086c41172",
+  "prevId": "706203d5-4bfa-4a78-85ae-ad6c45e3a8b9",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.keys": {
+      "name": "keys",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "expires_at": {
+          "name": "expires_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_keys_user_id": {
+          "name": "idx_keys_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_created_at": {
+          "name": "idx_keys_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_keys_deleted_at": {
+          "name": "idx_keys_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.message_request": {
+      "name": "message_request",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "provider_id": {
+          "name": "provider_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "duration_ms": {
+          "name": "duration_ms",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_usd": {
+          "name": "cost_usd",
+          "type": "numeric(21, 15)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0'"
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "session_id": {
+          "name": "session_id",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_chain": {
+          "name": "provider_chain",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "status_code": {
+          "name": "status_code",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "api_type": {
+          "name": "api_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_creation_input_tokens": {
+          "name": "cache_creation_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cache_read_input_tokens": {
+          "name": "cache_read_input_tokens",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_by": {
+          "name": "blocked_by",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "blocked_reason": {
+          "name": "blocked_reason",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_message_request_user_date_cost": {
+          "name": "idx_message_request_user_date_cost",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "cost_usd",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_query": {
+          "name": "idx_message_request_user_query",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_session_id": {
+          "name": "idx_message_request_session_id",
+          "columns": [
+            {
+              "expression": "session_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"message_request\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_provider_id": {
+          "name": "idx_message_request_provider_id",
+          "columns": [
+            {
+              "expression": "provider_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_user_id": {
+          "name": "idx_message_request_user_id",
+          "columns": [
+            {
+              "expression": "user_id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_key": {
+          "name": "idx_message_request_key",
+          "columns": [
+            {
+              "expression": "key",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_created_at": {
+          "name": "idx_message_request_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_message_request_deleted_at": {
+          "name": "idx_message_request_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.model_prices": {
+      "name": "model_prices",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "model_name": {
+          "name": "model_name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "price_data": {
+          "name": "price_data",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_model_prices_latest": {
+          "name": "idx_model_prices_latest",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_model_name": {
+          "name": "idx_model_prices_model_name",
+          "columns": [
+            {
+              "expression": "model_name",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_model_prices_created_at": {
+          "name": "idx_model_prices_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": false,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.providers": {
+      "name": "providers",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "url": {
+          "name": "url",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "weight": {
+          "name": "weight",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 1
+        },
+        "priority": {
+          "name": "priority",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": true,
+          "default": 0
+        },
+        "cost_multiplier": {
+          "name": "cost_multiplier",
+          "type": "numeric(10, 4)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'1.0'"
+        },
+        "group_tag": {
+          "name": "group_tag",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "provider_type": {
+          "name": "provider_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'claude'"
+        },
+        "model_redirects": {
+          "name": "model_redirects",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_5h_usd": {
+          "name": "limit_5h_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_weekly_usd": {
+          "name": "limit_weekly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_monthly_usd": {
+          "name": "limit_monthly_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "limit_concurrent_sessions": {
+          "name": "limit_concurrent_sessions",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "tpm": {
+          "name": "tpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpm": {
+          "name": "rpm",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "rpd": {
+          "name": "rpd",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "cc": {
+          "name": "cc",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 0
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_providers_enabled_priority": {
+          "name": "idx_providers_enabled_priority",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "priority",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "weight",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_group": {
+          "name": "idx_providers_group",
+          "columns": [
+            {
+              "expression": "group_tag",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"providers\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_created_at": {
+          "name": "idx_providers_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_providers_deleted_at": {
+          "name": "idx_providers_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.sensitive_words": {
+      "name": "sensitive_words",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "word": {
+          "name": "word",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "match_type": {
+          "name": "match_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'contains'"
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "is_enabled": {
+          "name": "is_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {
+        "idx_sensitive_words_enabled": {
+          "name": "idx_sensitive_words_enabled",
+          "columns": [
+            {
+              "expression": "is_enabled",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "match_type",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_sensitive_words_created_at": {
+          "name": "idx_sensitive_words_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.system_settings": {
+      "name": "system_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "site_title": {
+          "name": "site_title",
+          "type": "varchar(128)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'Claude Code Hub'"
+        },
+        "allow_global_usage_view": {
+          "name": "allow_global_usage_view",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role": {
+          "name": "role",
+          "type": "varchar",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'user'"
+        },
+        "rpm_limit": {
+          "name": "rpm_limit",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "daily_limit_usd": {
+          "name": "daily_limit_usd",
+          "type": "numeric(10, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'100.00'"
+        },
+        "provider_group": {
+          "name": "provider_group",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "now()"
+        },
+        "deleted_at": {
+          "name": "deleted_at",
+          "type": "timestamp with time zone",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {
+        "idx_users_active_role_sort": {
+          "name": "idx_users_active_role_sort",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "role",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            },
+            {
+              "expression": "id",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "where": "\"users\".\"deleted_at\" IS NULL",
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_created_at": {
+          "name": "idx_users_created_at",
+          "columns": [
+            {
+              "expression": "created_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        },
+        "idx_users_deleted_at": {
+          "name": "idx_users_deleted_at",
+          "columns": [
+            {
+              "expression": "deleted_at",
+              "isExpression": false,
+              "asc": true,
+              "nulls": "last"
+            }
+          ],
+          "isUnique": false,
+          "concurrently": false,
+          "method": "btree",
+          "with": {}
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -29,6 +29,13 @@
       "when": 1761197983090,
       "tag": "0003_outstanding_centennial",
       "breakpoints": true
+    },
+    {
+      "idx": 4,
+      "version": "7",
+      "when": 1761310251899,
+      "tag": "0004_dazzling_starbolt",
+      "breakpoints": true
     }
   ]
 }

+ 28 - 6
src/app/dashboard/logs/_components/usage-logs-table.tsx

@@ -9,6 +9,7 @@ import {
   TableRow,
 } from "@/components/ui/table";
 import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
 import type { UsageLogRow } from "@/repository/usage-logs";
 import { formatDistanceToNow } from "date-fns";
 import { zhCN } from "date-fns/locale";
@@ -121,13 +122,34 @@ export function UsageLogsTable({
                         <span className="h-1.5 w-1.5 rounded-full bg-orange-600 dark:bg-orange-400" />
                         被拦截
                       </span>
-                    ) : log.providerChain && log.providerChain.length > 0 ? (
-                      <ProviderChainPopover
-                        chain={log.providerChain}
-                        finalProvider={log.providerName || "未知"}
-                      />
                     ) : (
-                      log.providerName || "-"
+                      <div className="flex items-center gap-2">
+                        {log.providerChain && log.providerChain.length > 0 ? (
+                          <ProviderChainPopover
+                            chain={log.providerChain}
+                            finalProvider={log.providerName || "未知"}
+                          />
+                        ) : (
+                          <span>{log.providerName || "-"}</span>
+                        )}
+                        {/* 显示供应商倍率 Badge(不为 1.0 时) */}
+                        {log.costMultiplier && parseFloat(log.costMultiplier) !== 1.0 && (
+                          <Badge
+                            variant={
+                              parseFloat(log.costMultiplier) > 1.0
+                                ? "destructive" // 加价,红色
+                                : "secondary" // 折扣,灰色
+                            }
+                            className={
+                              parseFloat(log.costMultiplier) < 1.0
+                                ? "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-800" // 折扣用绿色
+                                : undefined
+                            }
+                          >
+                            ×{parseFloat(log.costMultiplier).toFixed(2)}
+                          </Badge>
+                        )}
+                      </div>
                     )}
                   </TableCell>
                   <TableCell className="font-mono text-xs">{log.model || "-"}</TableCell>

+ 1 - 0
src/app/v1/_lib/proxy/message-service.ts

@@ -24,6 +24,7 @@ export class ProxyMessageService {
       key: authState.apiKey,
       model: session.request.model ?? undefined,
       session_id: session.sessionId ?? undefined, // 新增:传入 session_id
+      cost_multiplier: provider.costMultiplier, // 新增:传入 cost_multiplier
     });
 
     session.setMessageContext({

+ 3 - 0
src/app/v1/_lib/proxy/model-redirector.ts

@@ -43,6 +43,9 @@ export class ModelRedirector {
       `[ModelRedirector] Redirecting model: "${originalModel}" → "${redirectedModel}" (provider ${provider.id})`
     );
 
+    // 保存原始模型(用于计费,必须在修改 request.model 之前)
+    session.setOriginalModel(originalModel);
+
     // 修改 message 对象中的模型
     session.request.message.model = redirectedModel;
 

+ 75 - 9
src/app/v1/_lib/proxy/response-handler.ts

@@ -99,7 +99,8 @@ export class ProxyResponseHandler {
         if (usageRecord && usageMetrics && messageContext) {
           await updateRequestCostFromUsage(
             messageContext.id,
-            session.request.model,
+            session.getOriginalModel(),
+            session.getCurrentModel(),
             usageMetrics,
             provider.costMultiplier
           );
@@ -291,7 +292,8 @@ export class ProxyResponseHandler {
 
         await updateRequestCostFromUsage(
           messageContext.id,
-          session.request.model,
+          session.getOriginalModel(),
+          session.getCurrentModel(),
           usageForCost,
           provider.costMultiplier
         );
@@ -388,21 +390,85 @@ function extractUsageMetrics(value: unknown): UsageMetrics | null {
 
 async function updateRequestCostFromUsage(
   messageId: number,
-  modelName: string | null,
+  originalModel: string | null,
+  redirectedModel: string | null,
   usage: UsageMetrics | null,
   costMultiplier: number = 1.0
 ): Promise<void> {
-  if (!modelName || !usage) {
+  if (!usage) {
+    logger.warn("[CostCalculation] No usage data, skipping cost update", { messageId });
     return;
   }
 
-  const priceData = await findLatestPriceByModel(modelName);
-  if (priceData?.priceData) {
-    const cost = calculateRequestCost(usage, priceData.priceData, costMultiplier);
-    if (cost.gt(0)) {
-      await updateMessageRequestCost(messageId, cost);
+  if (!originalModel && !redirectedModel) {
+    logger.warn("[CostCalculation] No model name available", { messageId });
+    return;
+  }
+
+  // Fallback 逻辑:优先原始模型,找不到则用重定向模型
+  let priceData = null;
+  let usedModelForPricing = null;
+
+  // Step 1: 尝试原始模型
+  if (originalModel) {
+    priceData = await findLatestPriceByModel(originalModel);
+    if (priceData?.priceData) {
+      usedModelForPricing = originalModel;
+      logger.debug("[CostCalculation] Using original model for pricing", {
+        messageId,
+        model: originalModel,
+      });
     }
   }
+
+  // Step 2: Fallback 到重定向模型
+  if (!priceData && redirectedModel && redirectedModel !== originalModel) {
+    priceData = await findLatestPriceByModel(redirectedModel);
+    if (priceData?.priceData) {
+      usedModelForPricing = redirectedModel;
+      logger.warn("[CostCalculation] Original model price not found, using redirected model", {
+        messageId,
+        originalModel,
+        redirectedModel,
+      });
+    }
+  }
+
+  // Step 3: 完全失败
+  if (!priceData?.priceData) {
+    logger.error("[CostCalculation] No price data found for any model", {
+      messageId,
+      originalModel,
+      redirectedModel,
+      note: "Cost will be $0. Please check price table or model name.",
+    });
+    return;
+  }
+
+  // 计算费用
+  const cost = calculateRequestCost(usage, priceData.priceData, costMultiplier);
+
+  logger.info("[CostCalculation] Cost calculated successfully", {
+    messageId,
+    usedModelForPricing,
+    costUsd: cost.toString(),
+    costMultiplier,
+    usage,
+  });
+
+  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,
+      },
+    });
+  }
 }
 
 /**

+ 37 - 0
src/app/v1/_lib/proxy/session.ts

@@ -53,6 +53,9 @@ export class ProxySession {
   originalFormat: "response" | "openai" | "claude" = "claude";
   providerType: "claude" | "codex" | null = null;
 
+  // 模型重定向追踪:保存原始模型名(重定向前)
+  private originalModelName: string | null = null;
+
   // 上游决策链(记录尝试的供应商列表)
   private providerChain: ProviderChainItem[];
 
@@ -203,6 +206,40 @@ export class ProxySession {
   getProviderChain(): ProviderChainItem[] {
     return this.providerChain;
   }
+
+  /**
+   * 获取原始模型(用户请求的,用于计费)
+   * 如果没有发生重定向,返回当前模型
+   */
+  getOriginalModel(): string | null {
+    return this.originalModelName ?? this.request.model;
+  }
+
+  /**
+   * 获取当前模型(可能已重定向,用于转发)
+   */
+  getCurrentModel(): string | null {
+    return this.request.model;
+  }
+
+  /**
+   * 设置原始模型(在重定向前调用)
+   * 只能设置一次,避免多次重定向覆盖
+   */
+  setOriginalModel(model: string | null): void {
+    if (this.originalModelName === null) {
+      this.originalModelName = model;
+    }
+  }
+
+  /**
+   * 检查是否发生了模型重定向
+   */
+  isModelRedirected(): boolean {
+    return (
+      this.originalModelName !== null && this.originalModelName !== this.request.model
+    );
+  }
 }
 
 function formatHeadersForLog(headers: Headers): string {

+ 3 - 0
src/drizzle/schema.ts

@@ -112,6 +112,9 @@ export const messageRequest = pgTable('message_request', {
   durationMs: integer('duration_ms'),
   costUsd: numeric('cost_usd', { precision: 21, scale: 15 }).default('0'),
 
+  // 供应商倍率(用于日志展示,记录该请求使用的 cost_multiplier)
+  costMultiplier: numeric('cost_multiplier', { precision: 10, scale: 4 }),
+
   // Session ID(用于会话粘性和日志追踪)
   sessionId: varchar('session_id', { length: 64 }),
 

+ 2 - 0
src/repository/message.ts

@@ -22,6 +22,7 @@ export async function createMessageRequest(
     model: data.model,
     durationMs: data.duration_ms,
     costUsd: formattedCost ?? undefined,
+    costMultiplier: data.cost_multiplier?.toString() ?? undefined, // 新增:供应商倍率(转为字符串)
     sessionId: data.session_id, // 新增:Session ID
   };
 
@@ -33,6 +34,7 @@ export async function createMessageRequest(
     model: messageRequest.model,
     durationMs: messageRequest.durationMs,
     costUsd: messageRequest.costUsd,
+    costMultiplier: messageRequest.costMultiplier, // 新增
     sessionId: messageRequest.sessionId, // 新增
     createdAt: messageRequest.createdAt,
     updatedAt: messageRequest.updatedAt,

+ 2 - 0
src/repository/usage-logs.ts

@@ -33,6 +33,7 @@ export interface UsageLogRow {
   cacheReadInputTokens: number | null;
   totalTokens: number;
   costUsd: string | null;
+  costMultiplier: string | null; // 新增:供应商倍率
   durationMs: number | null;
   errorMessage: string | null;
   providerChain: ProviderChainItem[] | null;
@@ -190,6 +191,7 @@ export async function findUsageLogsWithDetails(filters: UsageLogFilters): Promis
       cacheCreationInputTokens: messageRequest.cacheCreationInputTokens,
       cacheReadInputTokens: messageRequest.cacheReadInputTokens,
       costUsd: messageRequest.costUsd,
+      costMultiplier: messageRequest.costMultiplier, // 新增:供应商倍率
       durationMs: messageRequest.durationMs,
       errorMessage: messageRequest.errorMessage,
       providerChain: messageRequest.providerChain,

+ 6 - 0
src/types/message.ts

@@ -42,6 +42,9 @@ export interface MessageRequest {
   durationMs?: number;
   costUsd?: string; // 单次请求费用(美元),保持高精度字符串表示
 
+  // 供应商倍率(记录该请求使用的 cost_multiplier)
+  costMultiplier?: number;
+
   // Session ID(用于会话粘性和日志追踪)
   sessionId?: string;
 
@@ -76,6 +79,9 @@ export interface CreateMessageRequestData {
   duration_ms?: number;
   cost_usd?: Numeric; // 单次请求费用(美元),支持高精度
 
+  // 供应商倍率(记录该请求使用的 cost_multiplier)
+  cost_multiplier?: number;
+
   // Session ID(用于会话粘性和日志追踪)
   session_id?: string;