Procházet zdrojové kódy

feat: add client version check and codex instructions strategy

- Introduced CLIENT_VERSION_GA_THRESHOLD in .env.example to manage client version checks.
- Updated system settings to include enableClientVersionCheck for optional client version control.
- Added codexInstructionsStrategy to provider schema, allowing for flexible handling of Codex instructions.
- Enhanced proxy handling to support automatic retries with official instructions based on the new strategy.
- Updated relevant documentation and schemas to reflect these changes.

This update improves the management of client versions and Codex instruction handling, enhancing overall system robustness.
ding113 před 3 měsíci
rodič
revize
5f31365389

+ 7 - 0
.env.example

@@ -62,3 +62,10 @@ ENABLE_MULTI_PROVIDER_TYPES=false
 # - 启用:某些 Codex 供应商可能要求必须包含官方 instructions,如遇代理失败可尝试启用
 # 注意:即使关闭此选项,仍会删除 Codex 不支持的参数(temperature, max_tokens 等)
 ENABLE_CODEX_INSTRUCTIONS_INJECTION=false
+
+# 客户端版本检查配置
+# 功能说明:控制客户端版本检查和 GA 版本检测的行为
+# - GA 版本定义:当某个版本有至少 N 个用户使用时,该版本被视为 GA(General Availability)版本
+# - 版本检查:在管理后台启用后,旧版本客户端会被拦截并提示升级
+# 注意:版本检查功能默认关闭,需要在管理后台【设置 → 客户端版本】中手动启用
+CLIENT_VERSION_GA_THRESHOLD=2           # GA 版本检测阈值(用户数,默认:2)

+ 4 - 4
.github/workflows/claude-assistant.yml

@@ -75,8 +75,8 @@ jobs:
             你是一个全能的 GitHub 助手。根据当前事件类型,执行相应的操作:
 
             ## 铁律(违反即失败):
-            1. **永远不要直接提交代码到 main 分支** - main 是生产分支,任何直接提交都是严重违规
-            2. **永远不要创建指向 main 的 PR** - 所有 PR 必须指向 dev 分支
+            1. **永远不要直接提交代码到 main 分支** - main 是生产分支,任何直接提交都是严重违规(除非 PR 的发起分支是 dev)
+            2. **永远不要创建指向 main 的 PR** - 所有 PR 必须指向 dev 分支(除非 PR 的发起分支是 dev)
             3. **永远不要绕过 dev 分支** - 代码必须先进入 dev,经过测试后才能合并到 main
             4. **所有代码变更必须遵循标准工作流**:创建新分支 → 提交 → 推送 → PR 到 dev
 
@@ -176,13 +176,13 @@ jobs:
             - 查看详情:`gh issue view <number>` 或 `gh pr view <number>`
 
             ## 重要原则:
-            - **分支管理铁律**:所有代码变更必须通过新分支 → PR 到 dev,禁止直接操作 main
+            - **分支管理铁律**:所有代码变更必须通过新分支 → PR 到 dev,禁止直接操作 main(除非 PR 的发起分支是 dev)
             - 始终保持专业和友好的语气
             - 提供可操作的建议
             - 如果不确定,说明不确定的原因
             - 自动化操作(标签、分类)不需要评论说明,静默执行
             - 只在需要与用户交互时才发表评论
-            - **所有 PR 必须指向 dev 分支**,使用 `gh pr create --base dev`
+            - **所有 PR 必须指向 dev 分支**(除非 PR 的发起分支是 dev),使用 `gh pr create --base dev`
 
           # Claude 配置参数
           claude_args: |

+ 1 - 0
CLAUDE.md

@@ -661,3 +661,4 @@ SELECT ... LIMIT 50 OFFSET 0;
 - [Drizzle ORM 文档](https://orm.drizzle.team/)
 - [Shadcn UI 文档](https://ui.shadcn.com/)
 - [LiteLLM 价格表](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json)
+- 请使用 production 环境构建.

+ 2 - 0
drizzle/0015_narrow_gunslinger.sql

@@ -0,0 +1,2 @@
+ALTER TABLE "providers" ADD COLUMN "codex_instructions_strategy" varchar(20) DEFAULT 'auto';--> statement-breakpoint
+ALTER TABLE "system_settings" ADD COLUMN "enable_client_version_check" boolean DEFAULT false NOT NULL;

+ 1294 - 0
drizzle/meta/0015_snapshot.json

@@ -0,0 +1,1294 @@
+{
+  "id": "d466c36c-deaa-487f-a5ec-46a804d72dde",
+  "prevId": "02b711d7-5f2f-4a1d-b68c-dd517bb0bbf9",
+  "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
+        },
+        "can_login_web_ui": {
+          "name": "can_login_web_ui",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": true
+        },
+        "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
+        },
+        "original_model": {
+          "name": "original_model",
+          "type": "varchar(128)",
+          "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
+        },
+        "user_agent": {
+          "name": "user_agent",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "messages_count": {
+          "name": "messages_count",
+          "type": "integer",
+          "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.notification_settings": {
+      "name": "notification_settings",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "serial",
+          "primaryKey": true,
+          "notNull": true
+        },
+        "enabled": {
+          "name": "enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_enabled": {
+          "name": "circuit_breaker_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "circuit_breaker_webhook": {
+          "name": "circuit_breaker_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_enabled": {
+          "name": "daily_leaderboard_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "daily_leaderboard_webhook": {
+          "name": "daily_leaderboard_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "daily_leaderboard_time": {
+          "name": "daily_leaderboard_time",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'09:00'"
+        },
+        "daily_leaderboard_top_n": {
+          "name": "daily_leaderboard_top_n",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "cost_alert_enabled": {
+          "name": "cost_alert_enabled",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": true,
+          "default": false
+        },
+        "cost_alert_webhook": {
+          "name": "cost_alert_webhook",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "cost_alert_threshold": {
+          "name": "cost_alert_threshold",
+          "type": "numeric(5, 2)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0.80'"
+        },
+        "cost_alert_check_interval": {
+          "name": "cost_alert_check_interval",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 60
+        },
+        "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.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
+        },
+        "allowed_models": {
+          "name": "allowed_models",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'null'::jsonb"
+        },
+        "join_claude_pool": {
+          "name": "join_claude_pool",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "codex_instructions_strategy": {
+          "name": "codex_instructions_strategy",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'auto'"
+        },
+        "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
+        },
+        "circuit_breaker_failure_threshold": {
+          "name": "circuit_breaker_failure_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 5
+        },
+        "circuit_breaker_open_duration": {
+          "name": "circuit_breaker_open_duration",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 1800000
+        },
+        "circuit_breaker_half_open_success_threshold": {
+          "name": "circuit_breaker_half_open_success_threshold",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 2
+        },
+        "proxy_url": {
+          "name": "proxy_url",
+          "type": "varchar(512)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "proxy_fallback_to_direct": {
+          "name": "proxy_fallback_to_direct",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "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
+        },
+        "currency_display": {
+          "name": "currency_display",
+          "type": "varchar(10)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'USD'"
+        },
+        "enable_auto_cleanup": {
+          "name": "enable_auto_cleanup",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "default": false
+        },
+        "cleanup_retention_days": {
+          "name": "cleanup_retention_days",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 30
+        },
+        "cleanup_schedule": {
+          "name": "cleanup_schedule",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": false,
+          "default": "'0 2 * * *'"
+        },
+        "cleanup_batch_size": {
+          "name": "cleanup_batch_size",
+          "type": "integer",
+          "primaryKey": false,
+          "notNull": false,
+          "default": 10000
+        },
+        "enable_client_version_check": {
+          "name": "enable_client_version_check",
+          "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

@@ -106,6 +106,13 @@
       "when": 1761977258224,
       "tag": "0014_overconfident_mongu",
       "breakpoints": true
+    },
+    {
+      "idx": 15,
+      "version": "7",
+      "when": 1762333515926,
+      "tag": "0015_narrow_gunslinger",
+      "breakpoints": true
     }
   ]
 }

+ 29 - 0
src/actions/client-versions.ts

@@ -0,0 +1,29 @@
+"use server";
+
+import { logger } from "@/lib/logger";
+import { ClientVersionChecker, type ClientVersionStats } from "@/lib/client-version-checker";
+import { getSession } from "@/lib/auth";
+import type { ActionResult } from "./types";
+
+/**
+ * 获取所有客户端的版本统计信息
+ *
+ * 权限要求:管理员
+ *
+ * @returns 客户端版本统计数据
+ */
+export async function fetchClientVersionStats(): Promise<ActionResult<ClientVersionStats[]>> {
+  try {
+    const session = await getSession();
+    if (!session || session.user.role !== "admin") {
+      return { ok: false, error: "无权限访问客户端版本统计" };
+    }
+
+    const stats = await ClientVersionChecker.getAllClientStats();
+    return { ok: true, data: stats };
+  } catch (error) {
+    logger.error({ error }, "获取客户端版本统计失败");
+    const message = error instanceof Error ? error.message : "获取客户端版本统计失败";
+    return { ok: false, error: message };
+  }
+}

+ 1 - 0
src/actions/providers.ts

@@ -118,6 +118,7 @@ export async function getProviders(): Promise<ProviderDisplay[]> {
         modelRedirects: provider.modelRedirects,
         allowedModels: provider.allowedModels,
         joinClaudePool: provider.joinClaudePool,
+        codexInstructionsStrategy: provider.codexInstructionsStrategy,
         limit5hUsd: provider.limit5hUsd,
         limitWeeklyUsd: provider.limitWeeklyUsd,
         limitMonthlyUsd: provider.limitMonthlyUsd,

+ 6 - 3
src/actions/system-config.ts

@@ -24,13 +24,15 @@ export async function fetchSystemSettings(): Promise<ActionResult<SystemSettings
 }
 
 export async function saveSystemSettings(formData: {
-  siteTitle: string;
-  allowGlobalUsageView: boolean;
+  // 所有字段均为可选,支持部分更新
+  siteTitle?: string;
+  allowGlobalUsageView?: boolean;
   currencyDisplay?: string;
   enableAutoCleanup?: boolean;
   cleanupRetentionDays?: number;
   cleanupSchedule?: string;
   cleanupBatchSize?: number;
+  enableClientVersionCheck?: boolean;
 }): Promise<ActionResult<SystemSettings>> {
   try {
     const session = await getSession();
@@ -40,13 +42,14 @@ export async function saveSystemSettings(formData: {
 
     const validated = UpdateSystemSettingsSchema.parse(formData);
     const updated = await updateSystemSettings({
-      siteTitle: validated.siteTitle.trim(),
+      siteTitle: validated.siteTitle?.trim(),
       allowGlobalUsageView: validated.allowGlobalUsageView,
       currencyDisplay: validated.currencyDisplay,
       enableAutoCleanup: validated.enableAutoCleanup,
       cleanupRetentionDays: validated.cleanupRetentionDays,
       cleanupSchedule: validated.cleanupSchedule,
       cleanupBatchSize: validated.cleanupBatchSize,
+      enableClientVersionCheck: validated.enableClientVersionCheck,
     });
 
     revalidatePath("/settings/config");

+ 1 - 1
src/app/api/admin/system-config/route.ts

@@ -56,7 +56,7 @@ export async function POST(req: Request) {
 
     // 更新系统设置
     const updated = await updateSystemSettings({
-      siteTitle: validated.siteTitle.trim(),
+      siteTitle: validated.siteTitle?.trim(),
       allowGlobalUsageView: validated.allowGlobalUsageView,
       currencyDisplay: validated.currencyDisplay,
       enableAutoCleanup: validated.enableAutoCleanup,

+ 1 - 0
src/app/settings/_lib/nav-items.ts

@@ -8,6 +8,7 @@ export const SETTINGS_NAV_ITEMS: SettingsNavItem[] = [
   { href: "/settings/prices", label: "价格表" },
   { href: "/settings/providers", label: "供应商" },
   { href: "/settings/sensitive-words", label: "敏感词" },
+  { href: "/settings/client-versions", label: "客户端升级提醒" },
   { href: "/settings/data", label: "数据管理" },
   { href: "/settings/logs", label: "日志" },
   { href: "/settings/notifications", label: "消息推送" },

+ 91 - 0
src/app/settings/client-versions/_components/client-version-stats-table.tsx

@@ -0,0 +1,91 @@
+"use client";
+
+import type { ClientVersionStats } from "@/lib/client-version-checker";
+import { Badge } from "@/components/ui/badge";
+import {
+  Table,
+  TableBody,
+  TableCell,
+  TableHead,
+  TableHeader,
+  TableRow,
+} from "@/components/ui/table";
+import { formatDistanceToNow } from "date-fns";
+import { zhCN } from "date-fns/locale";
+
+interface ClientVersionStatsTableProps {
+  data: ClientVersionStats[];
+}
+
+export function ClientVersionStatsTable({ data }: ClientVersionStatsTableProps) {
+  return (
+    <div className="space-y-8">
+      {data.map((clientStats) => (
+        <div key={clientStats.clientType} className="space-y-3">
+          {/* 客户端类型标题 */}
+          <div className="flex items-center justify-between">
+            <div>
+              <h3 className="text-lg font-semibold">{clientStats.clientType}</h3>
+              <p className="text-sm text-muted-foreground">
+                当前 GA 版本:
+                <Badge variant="outline" className="ml-2">
+                  {clientStats.gaVersion || "无(用户数不足 2)"}
+                </Badge>
+              </p>
+            </div>
+            <Badge variant="secondary">{clientStats.totalUsers} 位用户</Badge>
+          </div>
+
+          {/* 用户版本列表 */}
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>用户</TableHead>
+                  <TableHead>当前版本</TableHead>
+                  <TableHead>最后活跃时间</TableHead>
+                  <TableHead>状态</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {clientStats.users.length === 0 ? (
+                  <TableRow>
+                    <TableCell colSpan={4} className="text-center text-muted-foreground">
+                      暂无用户数据
+                    </TableCell>
+                  </TableRow>
+                ) : (
+                  clientStats.users.map((user) => (
+                    <TableRow key={`${user.userId}-${user.version}`}>
+                      <TableCell className="font-medium">{user.username}</TableCell>
+                      <TableCell>
+                        <code className="rounded bg-muted px-2 py-1 text-sm">{user.version}</code>
+                      </TableCell>
+                      <TableCell className="text-sm text-muted-foreground">
+                        {formatDistanceToNow(new Date(user.lastSeen), {
+                          addSuffix: true,
+                          locale: zhCN,
+                        })}
+                      </TableCell>
+                      <TableCell>
+                        {user.isLatest ? (
+                          <Badge variant="default" className="bg-green-500 hover:bg-green-600">
+                            ✅ 最新
+                          </Badge>
+                        ) : user.needsUpgrade ? (
+                          <Badge variant="destructive">⚠️ 需升级</Badge>
+                        ) : (
+                          <Badge variant="outline">未知</Badge>
+                        )}
+                      </TableCell>
+                    </TableRow>
+                  ))
+                )}
+              </TableBody>
+            </Table>
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+}

+ 80 - 0
src/app/settings/client-versions/_components/client-version-toggle.tsx

@@ -0,0 +1,80 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { AlertCircle } from "lucide-react";
+import { saveSystemSettings } from "@/actions/system-config";
+import { toast } from "sonner";
+
+interface ClientVersionToggleProps {
+  enabled: boolean;
+}
+
+export function ClientVersionToggle({ enabled }: ClientVersionToggleProps) {
+  const [isEnabled, setIsEnabled] = useState(enabled);
+  const [isPending, startTransition] = useTransition();
+
+  async function handleToggle(checked: boolean) {
+    startTransition(async () => {
+      const result = await saveSystemSettings({
+        enableClientVersionCheck: checked,
+      });
+
+      if (result.ok) {
+        setIsEnabled(checked);
+        toast.success(checked ? "已启用客户端版本检查" : "已关闭客户端版本检查");
+      } else {
+        toast.error(result.error || "更新失败");
+      }
+    });
+  }
+
+  return (
+    <div className="space-y-4">
+      {/* 开关 */}
+      <div className="flex items-center justify-between">
+        <div className="space-y-1">
+          <Label htmlFor="enable-version-check">启用升级提醒</Label>
+          <p className="text-sm text-muted-foreground">启用后,系统将拦截使用旧版本客户端的请求</p>
+        </div>
+        <Switch
+          id="enable-version-check"
+          checked={isEnabled}
+          onCheckedChange={handleToggle}
+          disabled={isPending}
+        />
+      </div>
+
+      {/* 详细说明 */}
+      <Alert variant={isEnabled ? "destructive" : "default"}>
+        <AlertCircle className="h-4 w-4" />
+        <AlertTitle>功能说明</AlertTitle>
+        <AlertDescription className="space-y-3">
+          <div>
+            <strong>启用后会发生什么:</strong>
+          </div>
+          <ul className="list-inside list-disc space-y-1">
+            <li>系统会自动检测每种客户端的最新稳定版本(GA 版本)</li>
+            <li>
+              <strong>判定规则:</strong>当某个版本被 2 个以上用户使用时,视为 GA 版本
+            </li>
+            <li>
+              <strong>活跃窗口:</strong>仅统计过去 7 天内有请求的用户
+            </li>
+            <li className={isEnabled ? "text-destructive font-semibold" : ""}>
+              使用旧版本的用户将收到 HTTP 400 错误,无法继续使用服务
+            </li>
+            <li>错误提示中包含当前版本和需要升级的版本号</li>
+          </ul>
+
+          <div className="mt-3 pt-3 border-t">
+            <strong>推荐做法:</strong>
+            <span className="ml-2">先观察下方的版本分布,确认新版本稳定后再启用。</span>
+          </div>
+        </AlertDescription>
+      </Alert>
+    </div>
+  );
+}

+ 66 - 0
src/app/settings/client-versions/page.tsx

@@ -0,0 +1,66 @@
+import { redirect } from "next/navigation";
+import { getSession } from "@/lib/auth";
+import { fetchClientVersionStats } from "@/actions/client-versions";
+import { fetchSystemSettings } from "@/actions/system-config";
+import { SettingsPageHeader } from "../_components/settings-page-header";
+import { ClientVersionToggle } from "./_components/client-version-toggle";
+import { ClientVersionStatsTable } from "./_components/client-version-stats-table";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+
+export default async function ClientVersionsPage() {
+  const session = await getSession();
+
+  if (!session || session.user.role !== "admin") {
+    redirect("/login");
+  }
+
+  const [statsResult, settingsResult] = await Promise.all([
+    fetchClientVersionStats(),
+    fetchSystemSettings(),
+  ]);
+
+  const stats = statsResult.ok ? statsResult.data : [];
+  const enableClientVersionCheck = settingsResult.ok
+    ? settingsResult.data.enableClientVersionCheck
+    : false;
+
+  return (
+    <div className="space-y-6">
+      <SettingsPageHeader
+        title="客户端升级提醒"
+        description="管理客户端版本要求,确保用户使用最新的稳定版本"
+      />
+
+      {/* 功能开关和说明 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>升级提醒设置</CardTitle>
+          <CardDescription>启用后,系统将自动检测客户端版本并拦截旧版本用户的请求</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <ClientVersionToggle enabled={enableClientVersionCheck} />
+        </CardContent>
+      </Card>
+
+      {/* 版本统计表格 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>客户端版本分布</CardTitle>
+          <CardDescription>显示过去 7 天内活跃用户的客户端版本信息</CardDescription>
+        </CardHeader>
+        <CardContent>
+          {stats && stats.length > 0 ? (
+            <ClientVersionStatsTable data={stats} />
+          ) : (
+            <div className="flex flex-col items-center justify-center py-12 text-center">
+              <p className="text-muted-foreground">暂无客户端数据</p>
+              <p className="mt-2 text-sm text-muted-foreground">
+                过去 7 天内没有活跃用户使用可识别的客户端
+              </p>
+            </div>
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  );
+}

+ 28 - 9
src/app/v1/_lib/codex/utils/request-sanitizer.ts

@@ -59,7 +59,7 @@ export function isOfficialCodexClient(userAgent: string | null): boolean {
  * 清洗 Codex 请求(即使格式相同也需要执行)
  *
  * 清洗内容:
- * 1. 强制替换 instructions 为官方完整 prompt
+ * 1. 根据策略处理 instructions(auto/force_official/keep_original)
  * 2. 删除不支持的参数:max_tokens, temperature, top_p 等
  * 3. 确保必需字段:stream, store, parallel_tool_calls
  *
@@ -69,34 +69,53 @@ export function isOfficialCodexClient(userAgent: string | null): boolean {
  *
  * @param request - 原始请求体
  * @param model - 模型名称(用于选择 instructions)
+ * @param strategy - Codex Instructions 策略(供应商级别配置,可选)
  * @returns 清洗后的请求体
  */
 export function sanitizeCodexRequest(
   request: Record<string, unknown>,
-  model: string
+  model: string,
+  strategy?: "auto" | "force_official" | "keep_original"
 ): Record<string, unknown> {
   const output = { ...request };
 
-  // 步骤 1: 根据开关决定是否替换 instructions
-  if (ENABLE_CODEX_INSTRUCTIONS_INJECTION) {
-    // 开关启用:强制替换 instructions 为官方完整 prompt
-    // 某些 Codex 供应商可能要求必须有完整 instructions(约 4000+ 字)
+  // 优先使用供应商级别策略,否则使用全局环境变量
+  const effectiveStrategy =
+    strategy || (ENABLE_CODEX_INSTRUCTIONS_INJECTION ? "force_official" : "auto");
+
+  // 步骤 1: 根据策略决定是否替换 instructions
+  if (effectiveStrategy === "force_official") {
+    // 策略 1: 强制使用官方 instructions
     const officialInstructions = getDefaultInstructions(model);
     output.instructions = officialInstructions;
 
-    logger.info("[CodexSanitizer] Instructions injection enabled, replaced with official prompt", {
+    logger.info("[CodexSanitizer] Using 'force_official' strategy, replaced with official prompt", {
       model,
+      strategy: effectiveStrategy,
       instructionsLength: officialInstructions.length,
       instructionsPreview: officialInstructions.substring(0, 100) + "...",
     });
+  } else if (effectiveStrategy === "keep_original") {
+    // 策略 2: 始终透传,不添加重试标记
+    logger.info("[CodexSanitizer] Using 'keep_original' strategy, keeping original instructions", {
+      model,
+      strategy: effectiveStrategy,
+      hasInstructions: !!output.instructions,
+      originalInstructionsLength:
+        typeof output.instructions === "string" ? output.instructions.length : 0,
+    });
   } else {
-    // 开关关闭(默认):保持原样透传
-    logger.info("[CodexSanitizer] Instructions injection disabled, keeping original instructions", {
+    // 策略 3 (默认): auto - 透传 + 添加重试标记
+    logger.info("[CodexSanitizer] Using 'auto' strategy, keeping original with retry marker", {
       model,
+      strategy: effectiveStrategy,
       hasInstructions: !!output.instructions,
       originalInstructionsLength:
         typeof output.instructions === "string" ? output.instructions.length : 0,
     });
+
+    // ⭐ Phase 1: 添加重试标记,用于 400 错误自动重试
+    output._canRetryWithOfficialInstructions = true;
   }
 
   // 步骤 2: 删除 Codex 不支持的参数

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

@@ -2,6 +2,7 @@ import type { Context } from "hono";
 import { logger } from "@/lib/logger";
 import { ProxySession } from "./proxy/session";
 import { ProxyAuthenticator } from "./proxy/auth-guard";
+import { ProxyVersionGuard } from "./proxy/version-guard";
 import { ProxySessionGuard } from "./proxy/session-guard";
 import { ProxySensitiveWordGuard } from "./proxy/sensitive-word-guard";
 import { ProxyRateLimitGuard } from "./proxy/rate-limit-guard";
@@ -23,7 +24,13 @@ export async function handleProxyRequest(c: Context): Promise<Response> {
       return unauthorized;
     }
 
-    // 2. 探测请求拦截:立即返回,不执行任何后续逻辑
+    // 2. 版本检查(在认证后、Session 分配前)
+    const upgradeRequired = await ProxyVersionGuard.ensure(session);
+    if (upgradeRequired) {
+      return upgradeRequired;
+    }
+
+    // 3. 探测请求拦截:立即返回,不执行任何后续逻辑
     if (session.isProbeRequest()) {
       logger.debug("[ProxyHandler] Probe request detected, returning mock success", {
         messagesCount: session.getMessagesLength(),
@@ -34,36 +41,36 @@ export async function handleProxyRequest(c: Context): Promise<Response> {
       });
     }
 
-    // 3. Session 分配
+    // 4. Session 分配
     await ProxySessionGuard.ensure(session);
 
-    // 4. 敏感词检查(在计费之前)
+    // 5. 敏感词检查(在计费之前)
     const blockedBySensitiveWord = await ProxySensitiveWordGuard.ensure(session);
     if (blockedBySensitiveWord) {
       return blockedBySensitiveWord;
     }
 
-    // 5. 限流检查
+    // 6. 限流检查
     const rateLimited = await ProxyRateLimitGuard.ensure(session);
     if (rateLimited) {
       return rateLimited;
     }
 
-    // 6. 供应商选择
+    // 7. 供应商选择
     const providerUnavailable = await ProxyProviderResolver.ensure(session);
     if (providerUnavailable) {
       return providerUnavailable;
     }
 
-    // 7. 创建消息上下文(正常请求才写入数据库)
+    // 8. 创建消息上下文(正常请求才写入数据库)
     await ProxyMessageService.ensureContext(session);
 
-    // 8. 增加并发计数(在所有检查通过后,请求开始前)
+    // 9. 增加并发计数(在所有检查通过后,请求开始前)
     if (session.sessionId) {
       await SessionTracker.incrementConcurrentCount(session.sessionId);
     }
 
-    // 9. 记录请求开始
+    // 10. 记录请求开始
     if (session.messageContext && session.provider) {
       const tracker = ProxyStatusTracker.getInstance();
       tracker.startRequest({
@@ -83,7 +90,7 @@ export async function handleProxyRequest(c: Context): Promise<Response> {
     logger.error("Proxy handler error:", error);
     return await ProxyErrorHandler.handle(session, error);
   } finally {
-    // 10. 减少并发计数(确保无论成功失败都执行)
+    // 11. 减少并发计数(确保无论成功失败都执行)
     if (session.sessionId) {
       await SessionTracker.decrementConcurrentCount(session.sessionId);
     }

+ 56 - 1
src/app/v1/_lib/proxy/forwarder.ts

@@ -16,6 +16,7 @@ import { defaultRegistry } from "../converters";
 import type { Format } from "../converters/types";
 import { mapClientFormatToTransformer, mapProviderTypeToTransformer } from "./format-mapper";
 import { isOfficialCodexClient, sanitizeCodexRequest } from "../codex/utils/request-sanitizer";
+import { getDefaultInstructions } from "../codex/constants/codex-instructions";
 import { createProxyAgentForProvider } from "@/lib/proxy-agent";
 import type { Dispatcher } from "undici";
 import { getEnvConfig } from "@/lib/config/env.schema";
@@ -249,6 +250,58 @@ export class ProxyForwarder {
               totalProvidersAttempted,
             });
 
+            // 🆕 特殊处理:400 + "Instructions are not valid" 错误自动重试
+            // 针对部分严格的 Codex 中转站(如 88code、foxcode),会验证 instructions 字段
+            // 如果检测到该错误且请求带有重试标记,自动替换为官方 instructions 并重试
+            if (
+              statusCode === 400 &&
+              errorMessage.includes("Instructions are not valid") &&
+              (session.request.message as Record<string, unknown>)._canRetryWithOfficialInstructions
+            ) {
+              logger.warn(
+                "ProxyForwarder: Detected 'Instructions are not valid' error, retrying with official instructions",
+                {
+                  providerId: currentProvider.id,
+                  providerName: currentProvider.name,
+                  attemptNumber: attemptCount,
+                  totalProvidersAttempted,
+                }
+              );
+
+              // 替换 instructions 为官方 prompt
+              const officialInstructions = getDefaultInstructions(
+                session.request.model || "gpt-5-codex"
+              );
+              (session.request.message as Record<string, unknown>).instructions =
+                officialInstructions;
+
+              // 删除重试标记(避免无限循环)
+              delete (session.request.message as Record<string, unknown>)
+                ._canRetryWithOfficialInstructions;
+
+              // 记录到决策链(标记为 instructions 重试)
+              session.addProviderToChain(currentProvider, {
+                reason: "retry_with_official_instructions",
+                circuitState: getCircuitState(currentProvider.id),
+                attemptNumber: attemptCount,
+                errorMessage: errorMessage,
+                statusCode: statusCode,
+                errorDetails: {
+                  provider: {
+                    id: currentProvider.id,
+                    name: currentProvider.name,
+                    statusCode: statusCode,
+                    statusText: proxyError.message,
+                    upstreamBody: proxyError.upstreamError?.body,
+                    upstreamParsed: proxyError.upstreamError?.parsed,
+                  },
+                },
+              });
+
+              // 继续内层循环(重试当前供应商,不切换)
+              continue;
+            }
+
             // 记录到失败列表(避免重新选择)
             failedProviderIds.push(currentProvider.id);
 
@@ -399,12 +452,14 @@ export class ProxyForwarder {
         providerId: provider.id,
         providerName: provider.name,
         officialClient: isOfficialClient,
+        codexStrategy: provider.codexInstructionsStrategy,
       });
 
       try {
         const sanitized = sanitizeCodexRequest(
           session.request.message as Record<string, unknown>,
-          session.request.model || "gpt-5-codex"
+          session.request.model || "gpt-5-codex",
+          provider.codexInstructionsStrategy // ⭐ Phase 2: 传递供应商级别策略
         );
 
         const instructionsLength =

+ 2 - 1
src/app/v1/_lib/proxy/session.ts

@@ -219,7 +219,8 @@ export class ProxySession {
         | "request_success" // 修复:添加 request_success
         | "retry_success"
         | "retry_failed" // 供应商错误(已计入熔断器)
-        | "system_error"; // 系统/网络错误(不计入熔断器)
+        | "system_error" // 系统/网络错误(不计入熔断器)
+        | "retry_with_official_instructions"; // Codex instructions 自动重试
       selectionMethod?:
         | "session_reuse"
         | "weighted_random"

+ 120 - 0
src/app/v1/_lib/proxy/version-guard.ts

@@ -0,0 +1,120 @@
+import { logger } from "@/lib/logger";
+import { getSystemSettings } from "@/repository/system-config";
+import { parseUserAgent } from "@/lib/ua-parser";
+import { ClientVersionChecker } from "@/lib/client-version-checker";
+import type { ProxySession } from "./session";
+
+/**
+ * 代理版本检查守卫
+ *
+ * 职责:
+ * 1. 检查是否启用客户端版本检查
+ * 2. 解析客户端 UA 并提取版本信息
+ * 3. 检查用户版本是否需要升级
+ * 4. 异步更新用户版本追踪
+ * 5. 拦截旧版本用户或放行
+ *
+ * 特点:
+ * - Fail Open: 任何错误都放行,不影响服务
+ * - 默认关闭: 配置关闭时跳过所有检查
+ * - 向后兼容: UA 解析失败时放行
+ */
+export class ProxyVersionGuard {
+  /**
+   * 检查客户端版本,必要时拦截请求
+   *
+   * @param session - 代理会话
+   * @returns Response: 需要拦截时返回 HTTP 400, null: 放行
+   */
+  static async ensure(session: ProxySession): Promise<Response | null> {
+    try {
+      // 1. 检查系统配置
+      const settings = await getSystemSettings();
+      if (!settings.enableClientVersionCheck) {
+        logger.debug("[ProxyVersionGuard] 版本检查功能已关闭");
+        return null; // 功能关闭,放行
+      }
+
+      // 2. 确保已认证(authState 在认证后设置)
+      if (!session.authState || !session.authState.user) {
+        logger.debug("[ProxyVersionGuard] 未认证用户,跳过版本检查");
+        return null; // 未认证,放行(不应该发生,因为在认证后执行)
+      }
+
+      // 3. 解析 UA
+      const clientInfo = parseUserAgent(session.userAgent);
+      if (!clientInfo) {
+        logger.debug({ ua: session.userAgent }, "[ProxyVersionGuard] UA 解析失败,放行");
+        return null; // 解析失败,向后兼容
+      }
+
+      const userId = session.authState.user.id;
+
+      logger.debug(
+        {
+          userId,
+          clientType: clientInfo.clientType,
+          version: clientInfo.version,
+        },
+        "[ProxyVersionGuard] 开始版本检查"
+      );
+
+      // 4. 异步更新用户版本(不阻塞主流程)
+      ClientVersionChecker.updateUserVersion(
+        userId,
+        clientInfo.clientType,
+        clientInfo.version
+      ).catch((err) => {
+        logger.error({ err }, "[ProxyVersionGuard] 更新用户版本失败");
+      });
+
+      // 5. 检查是否需要升级
+      const needsUpgrade = await ClientVersionChecker.shouldUpgrade(
+        clientInfo.clientType,
+        clientInfo.version
+      );
+
+      if (!needsUpgrade) {
+        logger.debug(
+          { clientType: clientInfo.clientType, version: clientInfo.version },
+          "[ProxyVersionGuard] 版本检查通过"
+        );
+        return null; // 版本符合要求,放行
+      }
+
+      // 6. 获取 GA 版本用于错误提示
+      const gaVersion = await ClientVersionChecker.detectGAVersion(clientInfo.clientType);
+
+      logger.warn(
+        {
+          userId,
+          clientType: clientInfo.clientType,
+          currentVersion: clientInfo.version,
+          requiredVersion: gaVersion,
+        },
+        "[ProxyVersionGuard] 客户端版本过旧,已拦截"
+      );
+
+      // 7. 返回 HTTP 400 + 明确的升级提示
+      return new Response(
+        JSON.stringify({
+          error: {
+            type: "client_upgrade_required",
+            message: `Your ${clientInfo.clientType} client (v${clientInfo.version}) is outdated. Please upgrade to v${gaVersion} or later to continue using this service.`,
+            current_version: clientInfo.version,
+            required_version: gaVersion,
+            client_type: clientInfo.clientType,
+          },
+        }),
+        {
+          status: 400,
+          headers: { "Content-Type": "application/json" },
+        }
+      );
+    } catch (error) {
+      // Fail Open: 任何错误都放行
+      logger.error({ error }, "[ProxyVersionGuard] 版本检查失败,放行请求");
+      return null;
+    }
+  }
+}

+ 12 - 0
src/drizzle/schema.ts

@@ -99,6 +99,15 @@ export const providers = pgTable('providers', {
   // 启用后,如果该提供商配置了重定向到 claude-* 模型,可以加入 claude 调度池
   joinClaudePool: boolean('join_claude_pool').default(false),
 
+  // Codex Instructions 策略:控制如何处理 Codex 请求的 instructions 字段
+  // - 'auto' (默认): 透传客户端 instructions,400 错误时自动重试(使用官方 instructions)
+  // - 'force_official': 始终强制使用官方 Codex CLI instructions(约 4000+ 字完整 prompt)
+  // - 'keep_original': 始终透传客户端 instructions,不自动重试(适用于宽松的中转站)
+  // 仅对 providerType = 'codex' 的供应商有效
+  codexInstructionsStrategy: varchar('codex_instructions_strategy', { length: 20 })
+    .default('auto')
+    .$type<'auto' | 'force_official' | 'keep_original'>(),
+
   // 金额限流配置
   limit5hUsd: numeric('limit_5h_usd', { precision: 10, scale: 2 }),
   limitWeeklyUsd: numeric('limit_weekly_usd', { precision: 10, scale: 2 }),
@@ -244,6 +253,9 @@ export const systemSettings = pgTable('system_settings', {
   cleanupSchedule: varchar('cleanup_schedule', { length: 50 }).default('0 2 * * *'),
   cleanupBatchSize: integer('cleanup_batch_size').default(10000),
 
+  // 客户端版本检查配置
+  enableClientVersionCheck: boolean('enable_client_version_check').notNull().default(false),
+
   createdAt: timestamp('created_at', { withTimezone: true }).defaultNow(),
   updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow(),
 });

+ 269 - 0
src/lib/client-version-checker.ts

@@ -0,0 +1,269 @@
+import { getRedisClient } from "@/lib/redis/client";
+import { parseUserAgent, type ClientInfo } from "@/lib/ua-parser";
+import { compareVersions } from "@/lib/version";
+import { getActiveUserVersions, type RawUserVersion } from "@/repository/client-versions";
+import { logger } from "@/lib/logger";
+
+/**
+ * Redis Key 前缀
+ */
+const REDIS_KEYS = {
+  /** 用户当前版本: client_version:{clientType}:{userId} */
+  userVersion: (clientType: string, userId: number) => `client_version:${clientType}:${userId}`,
+
+  /** GA 版本缓存: ga_version:{clientType} */
+  gaVersion: (clientType: string) => `ga_version:${clientType}`,
+};
+
+/**
+ * TTL 配置(秒)
+ */
+const TTL = {
+  USER_VERSION: 7 * 24 * 60 * 60, // 7 天(匹配活跃窗口)
+  GA_VERSION: 5 * 60, // 5 分钟
+};
+
+/**
+ * GA 版本检测阈值
+ */
+const GA_THRESHOLD = 2; // 2 个用户以上使用
+
+/**
+ * 客户端版本统计信息
+ */
+export interface ClientVersionStats {
+  /** 客户端类型,如 "claude-cli" */
+  clientType: string;
+  /** 最新 GA 版本,无则为 null */
+  gaVersion: string | null;
+  /** 使用该客户端的总用户数 */
+  totalUsers: number;
+  /** 用户详情列表 */
+  users: {
+    userId: number;
+    username: string;
+    version: string;
+    lastSeen: Date;
+    isLatest: boolean; // 是否是最新版本
+    needsUpgrade: boolean; // 是否需要升级
+  }[];
+}
+
+/**
+ * 客户端版本检测器
+ *
+ * 核心功能:
+ * 1. 检测每种客户端的最新 GA 版本(2 个用户以上使用)
+ * 2. 检查用户版本是否需要升级
+ * 3. 追踪用户当前使用的版本
+ */
+export class ClientVersionChecker {
+  /**
+   * 检测指定客户端的最新 GA 版本
+   *
+   * GA 版本定义:被 2 个或以上用户使用的最新版本
+   * 活跃窗口:过去 7 天内有请求的用户
+   *
+   * @param clientType - 客户端类型,如 "claude-cli"
+   * @returns GA 版本号,无则返回 null
+   */
+  static async detectGAVersion(clientType: string): Promise<string | null> {
+    try {
+      const redis = getRedisClient();
+
+      // 1. 尝试从 Redis 读取缓存
+      if (redis) {
+        const cached = await redis.get(REDIS_KEYS.gaVersion(clientType));
+        if (cached) {
+          const data = JSON.parse(cached) as { version: string; userCount: number };
+          logger.debug(
+            { clientType, gaVersion: data.version },
+            "[ClientVersionChecker] GA 版本缓存命中"
+          );
+          return data.version;
+        }
+      }
+
+      // 2. 缓存未命中,查询数据库
+      const activeUsers = await getActiveUserVersions(7);
+
+      // 3. 解析所有 UA,过滤出指定客户端类型
+      const clientUsers = activeUsers
+        .map((user) => {
+          const clientInfo = parseUserAgent(user.userAgent);
+          return clientInfo && clientInfo.clientType === clientType
+            ? { ...user, version: clientInfo.version }
+            : null;
+        })
+        .filter((item): item is RawUserVersion & { version: string } => item !== null);
+
+      if (clientUsers.length === 0) {
+        logger.debug({ clientType }, "[ClientVersionChecker] 无活跃用户");
+        return null;
+      }
+
+      // 4. 统计每个版本的用户数(去重)
+      const versionCounts = new Map<string, Set<number>>();
+      for (const user of clientUsers) {
+        if (!versionCounts.has(user.version)) {
+          versionCounts.set(user.version, new Set());
+        }
+        versionCounts.get(user.version)!.add(user.userId);
+      }
+
+      // 5. 找到用户数 >= 2 的最新版本
+      let gaVersion: string | null = null;
+      for (const [version, userIds] of versionCounts.entries()) {
+        if (userIds.size >= GA_THRESHOLD) {
+          if (!gaVersion || compareVersions(version, gaVersion) > 0) {
+            gaVersion = version;
+          }
+        }
+      }
+
+      if (!gaVersion) {
+        logger.debug({ clientType }, "[ClientVersionChecker] 无 GA 版本(用户数不足 2)");
+        return null;
+      }
+
+      // 6. 写入 Redis 缓存
+      if (redis) {
+        const cacheData = {
+          version: gaVersion,
+          userCount: versionCounts.get(gaVersion)!.size,
+          updatedAt: Date.now(),
+        };
+        await redis.setex(
+          REDIS_KEYS.gaVersion(clientType),
+          TTL.GA_VERSION,
+          JSON.stringify(cacheData)
+        );
+        logger.info(
+          { clientType, gaVersion, userCount: cacheData.userCount },
+          "[ClientVersionChecker] GA 版本已缓存"
+        );
+      }
+
+      return gaVersion;
+    } catch (error) {
+      // Fail Open: 任何错误都返回 null
+      logger.error({ error, clientType }, "[ClientVersionChecker] 检测 GA 版本失败");
+      return null;
+    }
+  }
+
+  /**
+   * 检查用户版本是否需要升级
+   *
+   * @param clientType - 客户端类型
+   * @param userVersion - 用户当前版本
+   * @returns true: 需要升级, false: 不需要升级或无 GA 版本
+   */
+  static async shouldUpgrade(clientType: string, userVersion: string): Promise<boolean> {
+    try {
+      const gaVersion = await this.detectGAVersion(clientType);
+      if (!gaVersion) {
+        return false; // 无 GA 版本,放行
+      }
+
+      // 使用 compareVersions: 返回值 < 0 表示 userVersion < gaVersion
+      return compareVersions(userVersion, gaVersion) < 0;
+    } catch (error) {
+      // Fail Open: 检查失败时放行
+      logger.error({ error, clientType, userVersion }, "[ClientVersionChecker] 版本检查失败");
+      return false;
+    }
+  }
+
+  /**
+   * 更新用户当前使用的版本(异步,不阻塞主流程)
+   *
+   * @param userId - 用户 ID
+   * @param clientType - 客户端类型
+   * @param version - 版本号
+   */
+  static async updateUserVersion(
+    userId: number,
+    clientType: string,
+    version: string
+  ): Promise<void> {
+    try {
+      const redis = getRedisClient();
+      if (!redis) {
+        return; // Redis 不可用,跳过
+      }
+
+      const data = {
+        version,
+        lastSeen: Date.now(),
+      };
+
+      await redis.setex(
+        REDIS_KEYS.userVersion(clientType, userId),
+        TTL.USER_VERSION,
+        JSON.stringify(data)
+      );
+
+      logger.debug({ userId, clientType, version }, "[ClientVersionChecker] 用户版本已更新");
+    } catch (error) {
+      // 非关键操作,仅记录日志
+      logger.error(
+        { error, userId, clientType, version },
+        "[ClientVersionChecker] 更新用户版本失败"
+      );
+    }
+  }
+
+  /**
+   * 获取所有客户端的版本统计(供前端使用)
+   *
+   * @returns 所有客户端的版本统计信息
+   */
+  static async getAllClientStats(): Promise<ClientVersionStats[]> {
+    try {
+      // 1. 查询活跃用户
+      const activeUsers = await getActiveUserVersions(7);
+
+      // 2. 解析 UA 并分组
+      const clientGroups = new Map<string, Array<RawUserVersion & { clientInfo: ClientInfo }>>();
+
+      for (const user of activeUsers) {
+        const clientInfo = parseUserAgent(user.userAgent);
+        if (!clientInfo) continue; // 解析失败,跳过
+
+        if (!clientGroups.has(clientInfo.clientType)) {
+          clientGroups.set(clientInfo.clientType, []);
+        }
+        clientGroups.get(clientInfo.clientType)!.push({ ...user, clientInfo });
+      }
+
+      // 3. 为每个客户端类型生成统计
+      const stats: ClientVersionStats[] = [];
+
+      for (const [clientType, users] of clientGroups.entries()) {
+        const gaVersion = await this.detectGAVersion(clientType);
+
+        const userStats = users.map((user) => ({
+          userId: user.userId,
+          username: user.username,
+          version: user.clientInfo.version,
+          lastSeen: user.lastSeen,
+          isLatest: gaVersion ? user.clientInfo.version === gaVersion : false,
+          needsUpgrade: gaVersion ? compareVersions(user.clientInfo.version, gaVersion) < 0 : false,
+        }));
+
+        stats.push({
+          clientType,
+          gaVersion,
+          totalUsers: userStats.length,
+          users: userStats,
+        });
+      }
+
+      return stats;
+    } catch (error) {
+      logger.error({ error }, "[ClientVersionChecker] 获取客户端统计失败");
+      return []; // Fail Open
+    }
+  }
+}

+ 146 - 0
src/lib/codex-instructions-cache.ts

@@ -0,0 +1,146 @@
+/**
+ * Codex Instructions 缓存工具类
+ *
+ * Phase 3: 自动学习和缓存上游中转站下发的 instructions
+ * - 成功响应时缓存 instructions(按供应商 + 模型)
+ * - 请求时检查缓存,不匹配则自动覆盖
+ * - TTL 24 小时
+ * - Fail Open 策略:Redis 不可用时降级
+ */
+
+import { getRedisClient } from "@/lib/redis";
+import { logger } from "@/lib/logger";
+
+export class CodexInstructionsCache {
+  private static readonly CACHE_PREFIX = "codex:instructions";
+  private static readonly TTL_SECONDS = 86400; // 24 小时
+
+  /**
+   * 生成缓存 Key
+   * 格式:codex:instructions:{providerId}:{model}
+   */
+  private static getCacheKey(providerId: number, model: string): string {
+    return `${this.CACHE_PREFIX}:${providerId}:${model}`;
+  }
+
+  /**
+   * 获取缓存的 instructions
+   *
+   * @param providerId - 供应商 ID
+   * @param model - 模型名称
+   * @returns 缓存的 instructions,未找到或失败时返回 null
+   */
+  static async get(providerId: number, model: string): Promise<string | null> {
+    const redis = getRedisClient();
+    if (!redis) {
+      logger.debug("[CodexInstructionsCache] Redis not available, skipping cache read");
+      return null;
+    }
+
+    try {
+      const key = this.getCacheKey(providerId, model);
+      const cached = await redis.get(key);
+
+      if (cached) {
+        logger.debug("[CodexInstructionsCache] Cache hit", {
+          providerId,
+          model,
+          instructionsLength: cached.length,
+        });
+        return cached;
+      }
+
+      logger.debug("[CodexInstructionsCache] Cache miss", {
+        providerId,
+        model,
+      });
+      return null;
+    } catch (error) {
+      // Fail Open: Redis 错误时降级,不影响主流程
+      logger.warn("[CodexInstructionsCache] Failed to read cache, degrading gracefully", {
+        providerId,
+        model,
+        error,
+      });
+      return null;
+    }
+  }
+
+  /**
+   * 存储 instructions 到缓存
+   *
+   * @param providerId - 供应商 ID
+   * @param model - 模型名称
+   * @param instructions - instructions 内容
+   */
+  static async set(providerId: number, model: string, instructions: string): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis) {
+      logger.debug("[CodexInstructionsCache] Redis not available, skipping cache write");
+      return;
+    }
+
+    if (!instructions || typeof instructions !== "string") {
+      logger.debug("[CodexInstructionsCache] Invalid instructions, skipping cache write", {
+        providerId,
+        model,
+        instructionsType: typeof instructions,
+      });
+      return;
+    }
+
+    try {
+      const key = this.getCacheKey(providerId, model);
+      await redis.setex(key, this.TTL_SECONDS, instructions);
+
+      logger.info("[CodexInstructionsCache] Cached instructions successfully", {
+        providerId,
+        model,
+        instructionsLength: instructions.length,
+        ttl: this.TTL_SECONDS,
+      });
+    } catch (error) {
+      // Fail Open: Redis 错误时降级,不影响主流程
+      logger.warn("[CodexInstructionsCache] Failed to write cache, degrading gracefully", {
+        providerId,
+        model,
+        error,
+      });
+    }
+  }
+
+  /**
+   * 清除特定供应商的所有缓存
+   *
+   * @param providerId - 供应商 ID
+   */
+  static async clearByProvider(providerId: number): Promise<void> {
+    const redis = getRedisClient();
+    if (!redis) {
+      logger.debug("[CodexInstructionsCache] Redis not available, skipping cache clear");
+      return;
+    }
+
+    try {
+      const pattern = `${this.CACHE_PREFIX}:${providerId}:*`;
+      const keys = await redis.keys(pattern);
+
+      if (keys.length > 0) {
+        await redis.del(...keys);
+        logger.info("[CodexInstructionsCache] Cleared provider cache", {
+          providerId,
+          keysCleared: keys.length,
+        });
+      } else {
+        logger.debug("[CodexInstructionsCache] No cache keys found for provider", {
+          providerId,
+        });
+      }
+    } catch (error) {
+      logger.error("[CodexInstructionsCache] Failed to clear provider cache", {
+        providerId,
+        error,
+      });
+    }
+  }
+}

+ 59 - 0
src/lib/ua-parser.ts

@@ -0,0 +1,59 @@
+/**
+ * 客户端信息
+ */
+export interface ClientInfo {
+  /** 客户端类型,如 "claude-cli" */
+  clientType: string;
+  /** 版本号,如 "2.0.31" */
+  version: string;
+  /** 原始 UA 字符串 */
+  raw: string;
+}
+
+/**
+ * 解析 User-Agent 字符串,提取客户端类型和版本号
+ *
+ * 支持的格式示例:
+ * - claude-cli/2.0.31 (external, claude-vscode, agent-sdk/0.1.30)
+ * - claude-cli/2.0.32 (external, cli)
+ * - anthropic-sdk-typescript/1.0.0
+ *
+ * @param ua - User-Agent 字符串
+ * @returns 解析结果,失败返回 null
+ *
+ * @example
+ * ```typescript
+ * const result = parseUserAgent("claude-cli/2.0.31 (external, claude-vscode, agent-sdk/0.1.30)");
+ * // { clientType: "claude-cli", version: "2.0.31", raw: "..." }
+ * ```
+ */
+export function parseUserAgent(ua: string | null | undefined): ClientInfo | null {
+  if (!ua || typeof ua !== "string") {
+    return null;
+  }
+
+  // 正则匹配: {clientType}/{version} ...
+  // 提取斜杠前的客户端名称和斜杠后的版本号(直到空格或字符串结束)
+  const regex = /^([a-zA-Z0-9_-]+)\/([0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?)/;
+  const match = ua.match(regex);
+
+  if (!match) {
+    return null; // 解析失败,向后兼容
+  }
+
+  return {
+    clientType: match[1], // 如 "claude-cli"
+    version: match[2], // 如 "2.0.31"
+    raw: ua,
+  };
+}
+
+/**
+ * 格式化客户端信息为显示字符串
+ *
+ * @param clientInfo - 客户端信息
+ * @returns 格式化的字符串,如 "claude-cli v2.0.31"
+ */
+export function formatClientInfo(clientInfo: ClientInfo): string {
+  return `${clientInfo.clientType} v${clientInfo.version}`;
+}

+ 11 - 2
src/lib/validation/schemas.ts

@@ -118,6 +118,11 @@ export const CreateProviderSchema = z.object({
   model_redirects: z.record(z.string(), z.string()).nullable().optional(),
   allowed_models: z.array(z.string()).nullable().optional(),
   join_claude_pool: z.boolean().optional().default(false),
+  // Codex Instructions 策略
+  codex_instructions_strategy: z
+    .enum(["auto", "force_official", "keep_original"])
+    .optional()
+    .default("auto"),
   // 金额限流配置
   limit_5h_usd: z.coerce
     .number()
@@ -203,6 +208,7 @@ export const UpdateProviderSchema = z
     model_redirects: z.record(z.string(), z.string()).nullable().optional(),
     allowed_models: z.array(z.string()).nullable().optional(),
     join_claude_pool: z.boolean().optional(),
+    codex_instructions_strategy: z.enum(["auto", "force_official", "keep_original"]).optional(),
     // 金额限流配置
     limit_5h_usd: z.coerce
       .number()
@@ -260,10 +266,11 @@ export const UpdateProviderSchema = z
 
 /**
  * 系统设置更新数据验证schema
+ * 注意:所有字段均为可选,支持部分更新
  */
 export const UpdateSystemSettingsSchema = z.object({
-  siteTitle: z.string().min(1, "站点标题不能为空").max(128, "站点标题不能超过128个字符"),
-  allowGlobalUsageView: z.boolean(),
+  siteTitle: z.string().min(1, "站点标题不能为空").max(128, "站点标题不能超过128个字符").optional(),
+  allowGlobalUsageView: z.boolean().optional(),
   currencyDisplay: z
     .enum(
       Object.keys(CURRENCY_CONFIG) as [
@@ -288,6 +295,8 @@ export const UpdateSystemSettingsSchema = z.object({
     .min(1000, "批量大小不能少于1000")
     .max(100000, "批量大小不能超过100000")
     .optional(),
+  // 客户端版本检查配置(可选)
+  enableClientVersionCheck: z.boolean().optional(),
 });
 
 // 导出类型推断

+ 2 - 0
src/repository/_shared/transformers.ts

@@ -46,6 +46,7 @@ export function toProvider(dbProvider: any): Provider {
     groupTag: dbProvider?.groupTag ?? null,
     providerType: dbProvider?.providerType ?? "claude",
     modelRedirects: dbProvider?.modelRedirects ?? null,
+    codexInstructionsStrategy: dbProvider?.codexInstructionsStrategy ?? "auto",
     limit5hUsd: dbProvider?.limit5hUsd ? parseFloat(dbProvider.limit5hUsd) : null,
     limitWeeklyUsd: dbProvider?.limitWeeklyUsd ? parseFloat(dbProvider.limitWeeklyUsd) : null,
     limitMonthlyUsd: dbProvider?.limitMonthlyUsd ? parseFloat(dbProvider.limitMonthlyUsd) : null,
@@ -98,6 +99,7 @@ export function toSystemSettings(dbSettings: any): SystemSettings {
     cleanupRetentionDays: dbSettings?.cleanupRetentionDays ?? 30,
     cleanupSchedule: dbSettings?.cleanupSchedule ?? "0 2 * * *",
     cleanupBatchSize: dbSettings?.cleanupBatchSize ?? 10000,
+    enableClientVersionCheck: dbSettings?.enableClientVersionCheck ?? false,
     createdAt: dbSettings?.createdAt ? new Date(dbSettings.createdAt) : new Date(),
     updatedAt: dbSettings?.updatedAt ? new Date(dbSettings.updatedAt) : new Date(),
   };

+ 61 - 0
src/repository/client-versions.ts

@@ -0,0 +1,61 @@
+"use server";
+
+import { db } from "@/drizzle/db";
+import { messageRequest, users } from "@/drizzle/schema";
+import { sql, and, isNull, gte } from "drizzle-orm";
+import { logger } from "@/lib/logger";
+
+/**
+ * 原始用户版本数据(从数据库查询)
+ */
+export interface RawUserVersion {
+  userId: number;
+  username: string;
+  userAgent: string;
+  lastSeen: Date;
+}
+
+/**
+ * 查询过去 N 天内活跃用户的版本分布
+ *
+ * @param days - 活跃窗口天数(默认 7 天)
+ * @returns 活跃用户的 UA 和最后活跃时间
+ *
+ * @example
+ * ```typescript
+ * const activeUsers = await getActiveUserVersions(7);
+ * // [{ userId: 1, username: "张三", userAgent: "claude-cli/2.0.31 (...)", lastSeen: Date }]
+ * ```
+ */
+export async function getActiveUserVersions(days = 7): Promise<RawUserVersion[]> {
+  const cutoffDate = new Date();
+  cutoffDate.setDate(cutoffDate.getDate() - days);
+
+  try {
+    const results = await db
+      .select({
+        userId: messageRequest.userId,
+        username: users.name,
+        userAgent: messageRequest.userAgent,
+        lastSeen: sql<Date>`MAX(${messageRequest.createdAt})`.as("last_seen"),
+      })
+      .from(messageRequest)
+      .leftJoin(users, and(sql`${messageRequest.userId} = ${users.id}`, isNull(users.deletedAt)))
+      .where(
+        and(gte(messageRequest.createdAt, cutoffDate), sql`${messageRequest.userAgent} IS NOT NULL`)
+      )
+      .groupBy(messageRequest.userId, users.name, messageRequest.userAgent)
+      .orderBy(sql`MAX(${messageRequest.createdAt}) DESC`);
+
+    return results.map((row) => ({
+      userId: row.userId,
+      username: row.username || `User ${row.userId}`,
+      userAgent: row.userAgent || "",
+      lastSeen: new Date(row.lastSeen),
+    }));
+  } catch (error) {
+    // Fail Open: 查询失败返回空数组
+    logger.error({ error }, "[ClientVersions] 查询活跃用户失败");
+    return [];
+  }
+}

+ 4 - 0
src/repository/provider.ts

@@ -23,6 +23,7 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     modelRedirects: providerData.model_redirects,
     allowedModels: providerData.allowed_models,
     joinClaudePool: providerData.join_claude_pool ?? false,
+    codexInstructionsStrategy: providerData.codex_instructions_strategy ?? "auto",
     limit5hUsd: providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null,
     limitWeeklyUsd:
       providerData.limit_weekly_usd != null ? providerData.limit_weekly_usd.toString() : null,
@@ -55,6 +56,7 @@ export async function createProvider(providerData: CreateProviderData): Promise<
     modelRedirects: providers.modelRedirects,
     allowedModels: providers.allowedModels,
     joinClaudePool: providers.joinClaudePool,
+    codexInstructionsStrategy: providers.codexInstructionsStrategy,
     limit5hUsd: providers.limit5hUsd,
     limitWeeklyUsd: providers.limitWeeklyUsd,
     limitMonthlyUsd: providers.limitMonthlyUsd,
@@ -194,6 +196,8 @@ export async function updateProvider(
   if (providerData.allowed_models !== undefined) dbData.allowedModels = providerData.allowed_models;
   if (providerData.join_claude_pool !== undefined)
     dbData.joinClaudePool = providerData.join_claude_pool;
+  if (providerData.codex_instructions_strategy !== undefined)
+    dbData.codexInstructionsStrategy = providerData.codex_instructions_strategy;
   if (providerData.limit_5h_usd !== undefined)
     dbData.limit5hUsd =
       providerData.limit_5h_usd != null ? providerData.limit_5h_usd.toString() : null;

+ 21 - 5
src/repository/system-config.ts

@@ -82,6 +82,7 @@ function createFallbackSettings(): SystemSettings {
     cleanupRetentionDays: 30,
     cleanupSchedule: "0 2 * * *",
     cleanupBatchSize: 10000,
+    enableClientVersionCheck: false,
     createdAt: now,
     updatedAt: now,
   };
@@ -102,6 +103,7 @@ export async function getSystemSettings(): Promise<SystemSettings> {
         cleanupRetentionDays: systemSettings.cleanupRetentionDays,
         cleanupSchedule: systemSettings.cleanupSchedule,
         cleanupBatchSize: systemSettings.cleanupBatchSize,
+        enableClientVersionCheck: systemSettings.enableClientVersionCheck,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       })
@@ -129,6 +131,7 @@ export async function getSystemSettings(): Promise<SystemSettings> {
         cleanupRetentionDays: systemSettings.cleanupRetentionDays,
         cleanupSchedule: systemSettings.cleanupSchedule,
         cleanupBatchSize: systemSettings.cleanupBatchSize,
+        enableClientVersionCheck: systemSettings.enableClientVersionCheck,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       });
@@ -148,6 +151,7 @@ export async function getSystemSettings(): Promise<SystemSettings> {
         cleanupRetentionDays: systemSettings.cleanupRetentionDays,
         cleanupSchedule: systemSettings.cleanupSchedule,
         cleanupBatchSize: systemSettings.cleanupBatchSize,
+        enableClientVersionCheck: systemSettings.enableClientVersionCheck,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       })
@@ -177,19 +181,25 @@ export async function updateSystemSettings(
   const current = await getSystemSettings();
 
   try {
-    // 构建更新对象,只更新提供的字段
+    // 构建更新对象,只更新提供的字段(非 undefined)
     const updates: Partial<typeof systemSettings.$inferInsert> = {
-      siteTitle: payload.siteTitle,
-      allowGlobalUsageView: payload.allowGlobalUsageView,
       updatedAt: new Date(),
     };
 
-    // 添加货币显示配置字段(如果提供)
+    // 基础配置字段(如果提供)
+    if (payload.siteTitle !== undefined) {
+      updates.siteTitle = payload.siteTitle;
+    }
+    if (payload.allowGlobalUsageView !== undefined) {
+      updates.allowGlobalUsageView = payload.allowGlobalUsageView;
+    }
+
+    // 货币显示配置字段(如果提供)
     if (payload.currencyDisplay !== undefined) {
       updates.currencyDisplay = payload.currencyDisplay;
     }
 
-    // 添加日志清理配置字段(如果提供)
+    // 日志清理配置字段(如果提供)
     if (payload.enableAutoCleanup !== undefined) {
       updates.enableAutoCleanup = payload.enableAutoCleanup;
     }
@@ -203,6 +213,11 @@ export async function updateSystemSettings(
       updates.cleanupBatchSize = payload.cleanupBatchSize;
     }
 
+    // 客户端版本检查配置字段(如果提供)
+    if (payload.enableClientVersionCheck !== undefined) {
+      updates.enableClientVersionCheck = payload.enableClientVersionCheck;
+    }
+
     const [updated] = await db
       .update(systemSettings)
       .set(updates)
@@ -216,6 +231,7 @@ export async function updateSystemSettings(
         cleanupRetentionDays: systemSettings.cleanupRetentionDays,
         cleanupSchedule: systemSettings.cleanupSchedule,
         cleanupBatchSize: systemSettings.cleanupBatchSize,
+        enableClientVersionCheck: systemSettings.enableClientVersionCheck,
         createdAt: systemSettings.createdAt,
         updatedAt: systemSettings.updatedAt,
       });

+ 2 - 1
src/types/message.ts

@@ -16,7 +16,8 @@ export interface ProviderChainItem {
     | "request_success" // 修复:请求成功(首次)
     | "retry_success" // 重试成功
     | "retry_failed" // 重试失败(供应商错误,已计入熔断器)
-    | "system_error"; // 系统/网络错误(不计入熔断器)
+    | "system_error" // 系统/网络错误(不计入熔断器)
+    | "retry_with_official_instructions"; // Codex instructions 自动重试
 
   // === 选择方法(细化) ===
   selectionMethod?:

+ 11 - 0
src/types/provider.ts

@@ -1,6 +1,9 @@
 // 供应商类型枚举
 export type ProviderType = "claude" | "claude-auth" | "codex" | "gemini-cli" | "openai-compatible";
 
+// Codex Instructions 策略枚举
+export type CodexInstructionsStrategy = "auto" | "force_official" | "keep_original";
+
 export interface Provider {
   id: number;
   name: string;
@@ -29,6 +32,10 @@ export interface Provider {
   // 加入 Claude 调度池:仅对非 Anthropic 提供商有效
   joinClaudePool: boolean;
 
+  // Codex Instructions 策略:控制如何处理 Codex 请求的 instructions 字段
+  // 仅对 providerType = 'codex' 的供应商有效
+  codexInstructionsStrategy: CodexInstructionsStrategy;
+
   // 金额限流配置
   limit5hUsd: number | null;
   limitWeeklyUsd: number | null;
@@ -78,6 +85,8 @@ export interface ProviderDisplay {
   allowedModels: string[] | null;
   // 加入 Claude 调度池
   joinClaudePool: boolean;
+  // Codex Instructions 策略
+  codexInstructionsStrategy: CodexInstructionsStrategy;
   // 金额限流配置
   limit5hUsd: number | null;
   limitWeeklyUsd: number | null;
@@ -123,6 +132,7 @@ export interface CreateProviderData {
   model_redirects?: Record<string, string> | null;
   allowed_models?: string[] | null;
   join_claude_pool?: boolean;
+  codex_instructions_strategy?: CodexInstructionsStrategy;
 
   // 金额限流配置
   limit_5h_usd?: number | null;
@@ -169,6 +179,7 @@ export interface UpdateProviderData {
   model_redirects?: Record<string, string> | null;
   allowed_models?: string[] | null;
   join_claude_pool?: boolean;
+  codex_instructions_strategy?: CodexInstructionsStrategy;
 
   // 金额限流配置
   limit_5h_usd?: number | null;

+ 9 - 2
src/types/system-config.ts

@@ -14,13 +14,17 @@ export interface SystemSettings {
   cleanupSchedule?: string;
   cleanupBatchSize?: number;
 
+  // 客户端版本检查配置
+  enableClientVersionCheck: boolean;
+
   createdAt: Date;
   updatedAt: Date;
 }
 
 export interface UpdateSystemSettingsInput {
-  siteTitle: string;
-  allowGlobalUsageView: boolean;
+  // 所有字段均为可选,支持部分更新
+  siteTitle?: string;
+  allowGlobalUsageView?: boolean;
 
   // 货币显示配置(可选)
   currencyDisplay?: CurrencyCode;
@@ -30,4 +34,7 @@ export interface UpdateSystemSettingsInput {
   cleanupRetentionDays?: number;
   cleanupSchedule?: string;
   cleanupBatchSize?: number;
+
+  // 客户端版本检查配置(可选)
+  enableClientVersionCheck?: boolean;
 }