diff --git a/docs/guide/usage/circuit-breaker-config.md b/docs/guide/usage/circuit-breaker-config.md index ff625336..af62d788 100644 --- a/docs/guide/usage/circuit-breaker-config.md +++ b/docs/guide/usage/circuit-breaker-config.md @@ -5,19 +5,174 @@ outline: deep # 熔断器配置 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +AutoRouter 给每条上游单独维护一个熔断器,行为遵循 CLOSED → OPEN → HALF_OPEN 的标准状态机。状态机本身的转移规则、与 failover 的协同详见现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker);本页只补全实际配置层面的三块内容:可调阈值字段、自定义失败规则、Admin 强制开关。 -## 计划覆盖的内容 +## 状态机一句话回顾 -CLOSED / OPEN / HALF_OPEN 状态机解释、阈值配置、Admin 强制开关的使用场景。 +枚举位于 `src/lib/services/circuit-breaker.ts:13-17`: -## 在正文就绪前的临时建议 +``` +CLOSED // 正常服务 +OPEN // 已熔断,拒绝新流量;到达 openDuration 后自动转 HALF_OPEN +HALF_OPEN // 半开,按 probeInterval 节奏放探针请求 +``` -在该文档正文上线之前,可以参考以下材料获取等价信息: +驱动状态转移的三个函数: -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +| 函数 | 触发 | 行为 | +| ----------------------------- | ---------------------------------------- | ------------------------------------------------------------------------- | +| `recordFailure` | 转发失败时调(`circuit-breaker.ts:243`) | CLOSED 累计失败到 `failureThreshold` → OPEN;HALF_OPEN 任意失败 → 回 OPEN | +| `recordSuccess` | 转发成功时调(`circuit-breaker.ts:208`) | 仅 HALF_OPEN 生效,连续 `successThreshold` 次成功 → CLOSED | +| `acquireCircuitBreakerPermit` | 每次请求前调(`circuit-breaker.ts:160`) | OPEN 状态下若 `openDuration` 已超时自动转 HALF_OPEN | + +详细行为图与边界处理见 [`docs/circuit-breaker.md`](/circuit-breaker)。 + +## 上游级可调阈值 + +熔断参数**不**直接落在 `upstreams` 表上,而是按上游写入 `circuit_breaker_states.config`(`src/lib/db/schema-pg.ts:236-243`)。创建或编辑上游时通过 `circuit_breaker_config` 嵌套对象提交,路由层用「上游覆盖值 → 全局默认值」的回退顺序读取。 + +API 字段(管理 API 层接收以秒为单位的输入并转换为毫秒存储;`src/app/api/admin/upstreams/route.ts:23-31`、`:81-88`): + +| API 字段 | 类型 | 默认 | 单位(API) | 单位(DB) | 含义 | +| --------------------- | ---------------- | ---- | ----------- | ---------- | -------------------------------------------------------- | +| `failure_threshold` | integer 1–100 | 5 | 次数 | 次数 | CLOSED 状态下累计多少次失败转 OPEN | +| `success_threshold` | integer 1–100 | 2 | 次数 | 次数 | HALF_OPEN 状态下连续多少次成功转 CLOSED | +| `open_duration` | integer 1–300000 | 300 | 秒 | 毫秒 | OPEN 持续多久后可转 HALF_OPEN(默认 5 分钟) | +| `probe_interval` | integer 1–60000 | 30 | 秒 | 毫秒 | HALF_OPEN 探针节流:相邻两次探针的最小间隔(默认 30 秒) | +| `first_byte_timeout` | integer 1–300000 | 30 | 秒 | 毫秒 | 上游响应首字节超时(默认 30 秒) | +| `stream_idle_timeout` | integer 1–300000 | 60 | 秒 | 毫秒 | SSE 流空闲超时(默认 60 秒) | + +默认值来源:`src/lib/circuit-breaker-defaults.ts:10-17`。`open_duration` / `probe_interval` 等字段在 API 层接收秒、在 DB 内部按毫秒存储,是为兼容更早期的纯毫秒提交格式而设的双单位约定。 + +**UI 入口**:上游编辑弹框的「Reliability → Circuit Breaker Config」分区(`src/components/admin/upstream-form-dialog.tsx:3651-3808`,分区 id `advanced-circuit-breaker`、`upstream-form-dialog.tsx:1282-1285`)。每个字段都带 i18n 标签与默认值显示,编辑后保存即生效——不需要重启进程。 + +### 调参建议 + +| 场景 | 该调哪个 | +| ----------------------------- | ------------------------------------------------------------- | +| 上游偶有抖动但不希望整条断开 | 调大 `failure_threshold`(默认 5 已经比较宽容) | +| 上游故障后希望尽快尝试恢复 | 调小 `open_duration`(最小 1 秒,太短会让故障上游被反复探活) | +| HALF_OPEN 探针请求被压垮 | 调大 `probe_interval`,让探针之间留出更多缓冲 | +| 上游 SSE 流空闲很久才继续输出 | 调大 `stream_idle_timeout`(默认 60 秒) | +| 上游首字节响应慢但稳定 | 调大 `first_byte_timeout`(默认 30 秒) | + +## 自定义失败规则(upstream_failure_rules) + +不是所有 HTTP 错误都应该被记入熔断器——有些是已知的、可预期的、对上游健康度没有意义的失败。AutoRouter 提供「失败规则」让你针对特定错误特征**抑制熔断计数**(但 failover 仍然发生)。 + +### Schema 与匹配语义 + +`upstream_failure_rules`(`src/lib/db/schema-pg.ts:257-274`): + +| 字段 | 类型 | 含义 | +| ------------- | ------------------ | ----------------------------------------------------- | +| `upstream_id` | `uuid` 或 `NULL` | `NULL` = 全局规则,命中所有上游;非 NULL = 仅对该上游 | +| `name` | `varchar(128)` | 规则名 | +| `enabled` | boolean,默认 true | 是否启用 | +| `priority` | integer,默认 0 | 匹配优先级(升序,越小越先匹配) | +| `match` | json | 匹配条件 | + +`match` 字段结构(`src/lib/services/upstream-failure-rules.ts:8-14`): + +| 子字段 | 含义 | +| -------------------------------- | ---------------------------------- | +| `status_codes` | HTTP 状态码白名单(命中即匹配) | +| `error_types` | 错误类型字符串列表(如 `timeout`) | +| `body_pattern` | 响应体正则 | +| `header_name` + `header_pattern` | 响应头名 + 值正则 | + +四类子条件以 AND 关系拼接:都给值就都得满足;都不给则永远不匹配(空规则无意义)。 + +### 规则命中的效果 + +`matchFailureRule`(`upstream-failure-rules.ts:307`)按 `priority` 升序找第一条命中规则,返回 `MatchedFailureRule | null`。返回非 null 时: + +- **failover 仍发生**:请求会换下一条上游继续重试。 +- **熔断不计数**:`route.ts:1549-1557` 显式判断 `matchedFailureRule === null`,命中规则时跳过 `recordFailure(upstream, errorType)`。 + +也就是说失败规则的语义是「这次失败已经被规则解释了,不再算作上游故障」,而不是「这次失败不算失败」。 + +### 管理 API + +| 方法 | 路径 | 用途 | +| -------------------------- | ----------------------------------------- | -------------------- | +| `GET` / `POST` | `/api/admin/upstream-failure-rules` | 列 / 建全局规则 | +| `GET` / `PATCH` / `DELETE` | `/api/admin/upstream-failure-rules/[id]` | 取 / 改 / 删全局规则 | +| `GET` / `POST` | `/api/admin/upstreams/[id]/failure-rules` | 列 / 建上游局部规则 | + +POST body 字段对应 `match` 结构(`upstream-failure-rules.ts:16-22`、`failure-rules/route.ts:18-24`):`name`、`enabled`、`priority`、`match.status_codes`、`match.error_types`、`match.body_pattern`、`match.header_name`、`match.header_pattern`。 + +### 典型用法 + +| 想做的事 | 写一条这样的规则 | +| ---------------------------------------------- | --------------------------------------------------------------------------------- | +| 上游侧返回的「模型暂时不可用」不要导致整条熔断 | `status_codes: [503]` + `body_pattern: "model.*not.*available"` | +| 用户侧 4xx 错误不影响上游健康度 | `status_codes: [400, 401, 403, 422]`(默认 failover 会跳过 4xx,但保险起见) | +| 单一上游的特定 retry-after 不计入熔断 | `status_codes: [429]` + `header_name: "retry-after"` + `header_pattern: "^[1-9]"` | + +## circuit_breaker_states 表与持久化 + +字段(`src/lib/db/schema-pg.ts:222-251`): + +| 字段 | 类型 | 说明 | +| ----------------- | ---------------------------- | --------------------------------------------- | +| `upstream_id` | `uuid UNIQUE NOT NULL` | 一条上游对应一行 | +| `state` | `varchar(16)`,默认 `closed` | 当前状态 | +| `failure_count` | integer,默认 0 | 累计失败次数 | +| `success_count` | integer,默认 0 | HALF_OPEN 下连续成功计数 | +| `last_failure_at` | timestamptz | 最后一次失败时间 | +| `opened_at` | timestamptz | 最近进入 OPEN 的时间(用于计算 openDuration) | +| `last_probe_at` | timestamptz | 最近探针时间(用于 probeInterval 节流) | +| `config` | json 或 null | 上游覆盖配置(null = 全用默认值) | + +**状态完全持久化**:所有字段写入 PostgreSQL,进程重启后熔断状态完整恢复(`circuit-breaker.ts:43-46`,`getOrCreateCircuitBreakerState` 直接 `db.query` 读盘)。重启**不会**重置 OPEN 状态,`opened_at` 时间戳仍然有效,重启后下一次请求会按真实经过时间判断是否可转 HALF_OPEN。 + +## Admin 强制开关 + +有些场景下手动控制熔断比等自动状态机更直接,例如: + +- 已知上游计划维护,提前 force open 避免触发 failover 浪费请求。 +- 故障已修复但 OPEN 状态还没到期,想立刻恢复服务,force close。 +- 想清空累积的 `failure_count`,让计数从零开始。 + +| 方法 | 路径 | 行为 | +| ------ | ------------------------------------------------------------ | ------------------------------------------------------------------------------ | +| `POST` | `/api/admin/circuit-breakers/[id]/force-open` | 调 `forceOpen`,写 `opened_at = now`、`state = OPEN`,不动 `failure_count` | +| `POST` | `/api/admin/circuit-breakers/[id]/force-close` | 调 `forceClose`,写 `state = CLOSED`、`failure_count = 0`、`success_count = 0` | +| `GET` | `/api/admin/circuit-breakers/[id]` | 查单条上游熔断状态 | +| `GET` | `/api/admin/circuit-breakers?state=open&page=1&page_size=20` | 分页列出,可按状态过滤 | + +源码:`src/app/api/admin/circuit-breakers/[id]/force-open/route.ts:18-50`、`force-close/route.ts:18-50`;底层调用 `forceOpen`(`circuit-breaker.ts:293`)与 `forceClose`(`circuit-breaker.ts:309`)。 + +**UI 入口**:上游列表页(`src/app/[locale]/(dashboard)/upstreams/page.tsx:86`)可按 `circuit_open` 状态过滤;`useForceCircuitBreaker()` hook(`src/hooks/use-circuit-breaker.ts:33-58`)封装两个 mutation,按钮点击后自动 invalidate `circuit-breakers` 与 `upstreams` 查询缓存。 + +`force-open` 与 `force-close` 都不需要 body,仅需 `Authorization: Bearer ` 头。 + +## 与 failover 的关系 + +熔断与 failover 共用同一次 HTTP 失败事件,但处于两个独立的代码路径: + +- **failover**:「换一个上游重试」。触发条件由 `src/lib/services/failover-config.ts:57-73` 决定,默认任何非 2xx 都触发,可通过 `excludeStatusCodes` 排除;策略可选 `exhaust_all`(默认)或 `max_attempts`(默认 10 次,`failover-config.ts:44-48`)。 +- **熔断计数**:「这条上游不健康」。由 `recordFailure` 写入,受 `shouldRecordCircuitBreakerFailure(path)`(`route.ts:800-803`)与 `matchedFailureRule === null`(`route.ts:1549-1557`)两个条件共同控制。 + +`shouldRecordCircuitBreakerFailure` 维护一个路径白名单 `CIRCUIT_BREAKER_NEUTRAL_PATHS = {"messages/count_tokens"}`(`route.ts:793`)。命中白名单的路径即使失败也不计入熔断(这种 token 计数类请求不代表上游真实健康度)。 + +`matchedFailureRule` 在三处出现:HTTP 错误分支(`route.ts:1549-1556`)、流式错误 settlement 分支(`:1708-1712`)、网络 / 超时 settlement 分支(`:1948-1951`)。**例外**:流式 runtime 错误分支(`:1632-1635`)不检查 failure rule,直接按白名单决定。 + +## 排查清单 + +| 现象 | 检查 | +| -------------------------------- | --------------------------------------------------------------------------- | +| 上游频繁被熔断 | 检查 `failure_threshold` 是否过小;是否有规律性失败需要加 failure rule 抑制 | +| 已知好转但熔断状态不解除 | force-close;或调小该上游的 `open_duration` | +| HALF_OPEN 探针请求太密集压垮上游 | 调大 `probe_interval` | +| 熔断状态进程重启后还在 | 是正常行为(持久化在 DB),如需清空请 force-close | +| 某类已知错误不应导致熔断 | 加 `upstream_failure_rules` 规则匹配该错误的状态码 / 响应体特征 | +| force-open 后忘了恢复 | 列表页过滤 `circuit_open`,再 force-close 单条恢复 | + +## 不在本页范围内 + +- 状态机的转移图、自动 failover 决策序列、健康检查与熔断的协同:见 [`docs/circuit-breaker.md`](/circuit-breaker)。 +- 负载均衡如何把熔断 OPEN 的上游从候选剔除:见 [负载均衡与权重](./load-balancing) 的「熔断与并发」一节。 +- 一次请求经过哪些阶段、`recordFailure` 何时被调:见 [请求生命周期](../architecture/request-lifecycle) 阶段五与阶段六。 +- 错误码 / 状态码与统一错误响应:见 [通过 AutoRouter 调用模型](./invoke-models) 的「响应行为」一节。 diff --git a/docs/guide/usage/cliproxy-first-time.md b/docs/guide/usage/cliproxy-first-time.md index 1f00ae42..d035f073 100644 --- a/docs/guide/usage/cliproxy-first-time.md +++ b/docs/guide/usage/cliproxy-first-time.md @@ -5,19 +5,193 @@ outline: deep # CLIProxyAPI 首次使用指南 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +本页带读者从「AutoRouter 已部署 + CLIProxyAPI(下称 CPA)sidecar 已起来」出发,走完一条完整链路:登记 CPA 实例 → OAuth 登录账号(Codex / Claude / Gemini)→ 创建池上游 → 客户端调用成功。整个流程在管理后台 `/system/cliproxy` 页面与命令行客户端之间反复切换,每一步的字段含义、失败排查方法、踩坑点都按出现顺序展开。 -## 计划覆盖的内容 +前置条件: -从零到能用的完整链路:登记实例、OAuth 登录账号、创建池上游、客户端调用 Codex / Claude / Gemini。 +- AutoRouter 实例可访问且能登录管理后台(参见 [快速开始](../deployment/quickstart))。 +- CPA sidecar 已通过 `docker-compose.cliproxy.yml` 叠加文件启动(参见 [CLIProxyAPI Sidecar 部署](../deployment/cliproxy-sidecar))。 -## 在正文就绪前的临时建议 +## 第一步:登记 CPA 实例 -在该文档正文上线之前,可以参考以下材料获取等价信息: +侧边栏 **系统 → CLIProxyAPI**(页面文件 `src/app/[locale]/(dashboard)/system/cliproxy/page.tsx`)。点击「添加实例」按钮打开表单弹窗(`src/components/admin/cliproxy-instance-form-dialog.tsx`)。 -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +实例字段对应数据库表 `cliproxy_instances`(`src/lib/db/schema-pg.ts:718`),表单字段如下: + +| 字段 | 必填 | 含义 | +| ---------------- | ---------------------- | ----------------------------------------------------------------------------------------- | +| `name` | 是,唯一,最长 64 字符 | 实例名称,仅用于管理后台显示 | +| `mode` | 是,默认 `managed` | `managed`(sidecar,与 AutoRouter 同 Docker 网络)/ `external`(独立运行的远端 CPA 服务) | +| `base_url` | 是 | 客户端代理转发的基础地址。后续创建池上游时会拼接 provider 后缀作为上游 `base_url` | +| `management_url` | 是 | 管理 API 基础地址。AutoRouter 调用 `/v0/management/*` 拉取账号、发起 OAuth 等都走这里 | +| `client_api_key` | 创建必填,编辑可留空 | 转发流量时注入到 `Authorization` 头的密钥;DB 中以 Fernet 加密存(`schema-pg.ts:727`) | +| `management_key` | 创建必填,编辑可留空 | 管理 API 鉴权密钥;DB 中同样 Fernet 加密 | +| `enabled` | 否,默认 true | 关闭后所有依赖该实例的池上游不可用 | +| `description` | 否,最长 512 字符 | 备注 | + +### sidecar 拓扑下的 base_url 与 management_url 怎么填 + +`mode = managed` 时,AutoRouter 容器与 CPA 容器在同一 Docker 网络中,**不能**用 `localhost`——`localhost` 指向 AutoRouter 容器自身。两个地址都应填 **CPA 容器的 Docker 服务名**,例如: + +``` +base_url: http://cliproxyapi: +management_url: http://cliproxyapi: +``` + +CPA 实际监听的端口由 CPA 自身配置决定,AutoRouter 源码中没有硬编码默认端口(`src/lib/db/schema-pg.ts:725-726` 只声明字段,不指定值),请以 `docker-compose.cliproxy.yml` 中暴露的端口为准;若未改动,按 CLIProxyAPI 自身文档的默认值填即可。 + +`mode = external` 时按外部 CPA 服务的真实地址填,可以是 `https://cpa.example.com` 或带端口的 IP。 + +### 「保存前先测一下」 + +表单内置「连通性预测试」按钮(`src/components/admin/cliproxy-instance-form-dialog.tsx:123-134`)。填好 `management_url` 与 `management_key` 后点它,会向 `/api/admin/cliproxy/instances/test` 发请求,后端调用 `testCliproxyConnection`(`src/lib/services/cliproxy-connection-tester.ts`)。 + +测试逻辑:以 `management_key` 作为 Bearer,对 `/v0/management/auth-files` 发 GET 请求,超时 10 秒。结果按下表归类(`src/lib/services/cliproxy-connection-tester.ts:83-123`): + +| 返回状态 | 触发条件 | 文案示例 | +| --------------- | ----------------------------- | ------------------------------------------------ | +| `success` | HTTP 2xx | 连接正常 | +| `auth_failed` | HTTP 401 / 403 | 「管理 API 密钥无效,CLIProxyAPI 拒绝鉴权」 | +| `service_error` | 其他非 2xx | 「CLIProxyAPI 管理 API 返回异常状态码 ``」 | +| `unreachable` | 10 秒超时、DNS 失败、连接拒绝 | 「管理 API 地址不可达:请求在 10 秒内未完成」 等 | + +`unreachable` 几乎都是 `localhost` 与容器服务名填错引起。**推荐**习惯:保存前一定先点这个按钮,不要靠保存后再排查。 + +## 第二步:OAuth 登录账号 + +实例保存后,进入实例详情页或在实例行点击「OAuth 登录」按钮。AutoRouter 支持三种 provider(`src/lib/services/cliproxy-management-client.ts:9`): + +```ts +export const CLIPROXY_OAUTH_PROVIDERS = ["codex", "anthropic", "gemini"] as const; +``` + +对应 CPA 管理 API 的授权端点片段(`src/lib/services/cliproxy-management-client.ts:13-17`): + +| Provider | CPA 端点 | +| ----------- | -------------------------------------------------- | +| `codex` | `/v0/management/codex-auth-url?is_webui=true` | +| `anthropic` | `/v0/management/anthropic-auth-url?is_webui=true` | +| `gemini` | `/v0/management/gemini-cli-auth-url?is_webui=true` | + +`is_webui=true` 由 AutoRouter 自动追加(`src/lib/services/cliproxy-management-client.ts:227-230`),用于让 CPA 的 callback forwarder 处理容器部署下的回调链。 + +### 流程 + +1. UI 点「OAuth 登录」选择 provider → 前端调用 `POST /api/admin/cliproxy/instances/:id/oauth-login`,body `{ provider }`,后端走 `initiateCliproxyOAuthLogin`(`src/lib/services/cliproxy-oauth-login-service.ts:64-75`),返回 `{ provider, url, state }`。 +2. UI 弹出新窗口打开 `url`(OAuth 授权页),等用户在该窗口完成授权并被重定向回 CPA。 +3. UI 同时按固定间隔轮询 `GET /api/admin/cliproxy/instances/:id/oauth-login/status?state=`,调用 `pollCliproxyOAuthStatus`(`cliproxy-oauth-login-service.ts:83-97`)。 +4. 当 CPA 报告登录成功,`pollCliproxyOAuthStatus` 自动触发 `syncCliproxyAuthAccounts` 把账号同步到 AutoRouter 数据库的 `cliproxy_auth_accounts` 表(`src/lib/db/schema-pg.ts:744`)。 + +AutoRouter 自身**不持久化 OAuth 会话**,`state` 完全由 CPA 维护(`cliproxy-oauth-login-service.ts:63-65`);OAuth token 也始终留在 CPA 的 auth 目录里,AutoRouter 只缓存账号非敏感元数据(`schema-pg.ts:741-742`)。这一层职责切分意味着: + +- 账号过期:AutoRouter 侧没有刷新 token 的机制(`cliproxy-auth-account-service.ts` 只提供启停、字段更新、同步缓存三类操作)。过期账号需要重新走一次 OAuth 登录流程。 +- 备份 CPA 自身的 auth 目录就等于备份了所有 OAuth 凭据,参见 [CLIProxyAPI Sidecar 部署](../deployment/cliproxy-sidecar) 的卷管理章节。 + +### 相关管理 API 路由清单 + +| 方法 | 路径 | 说明 | +| ------ | ---------------------------------------------------------------- | ------------------------ | +| `GET` | `/api/admin/cliproxy/instances` | 列出全部实例 | +| `POST` | `/api/admin/cliproxy/instances` | 创建实例 | +| `POST` | `/api/admin/cliproxy/instances/test` | 创建前的连通性预检 | +| `POST` | `/api/admin/cliproxy/instances/:id/oauth-login` | 发起 OAuth 登录 | +| `GET` | `/api/admin/cliproxy/instances/:id/oauth-login/status?state=...` | 轮询登录状态 | +| `GET` | `/api/admin/cliproxy/instances/:id/auth-accounts` | 列出已缓存账号 | +| `POST` | `/api/admin/cliproxy/instances/:id/auth-accounts/sync` | 手动触发账号同步 | +| `POST` | `/api/admin/cliproxy/instances/:id/pool-upstreams` | 一键创建池上游(见下节) | + +## 第三步:创建池上游 + +「池上游」是按 provider 预设的、自动挂回 CPA 实例的上游记录。普通上游需要手填 base_url、API Key、route_capabilities 等十几个字段;池上游只要选一个 provider,剩下的字段由 `createCliproxyPoolUpstream`(`src/lib/services/cliproxy-upstream-preset.ts`)自动填好后落到 `upstreams` 表。 + +### 三类 provider 的预设 + +`src/lib/services/cliproxy-upstream-preset.ts:38-54` 定义如下: + +| Provider | 上游 base_url 后缀 | 自动声明的 route_capabilities | +| ----------- | ---------------------------- | ------------------------------------------------ | +| `codex` | `/v1` | `["codex_cli_responses", "openai_responses"]` | +| `anthropic` | `/api/provider/anthropic/v1` | `["claude_code_messages", "anthropic_messages"]` | +| `gemini` | `/api/provider/google` | `["gemini_native_generate"]` | + +举例:实例 `base_url` 为 `http://cliproxyapi:8317`,anthropic 池上游被创建时实际 `baseUrl` = `http://cliproxyapi:8317/api/provider/anthropic/v1`。`api_key` 字段使用实例的 `clientApiKey`(运行时解密后注入)。落库后 `upstreams` 表里还会回填 `cliproxy_instance_id` 与 `cliproxy_provider` 两个字段,用来在 UI 上把池上游与所属实例关联回去(`cliproxy-upstream-preset.ts:186-190`)。 + +### UI 入口 + +在实例详情页或实例行点「创建池上游」按钮,弹出 `CliproxyPoolUpstreamDialog`(`src/components/admin/cliproxy-pool-upstream-dialog.tsx`)。只有 **服务商**(codex / anthropic / gemini)是必填,其余字段(名称、权重、优先级)都可省略——省略时使用如 `CLIProxyAPI <实例名> Codex Pool` 这样的自动名称(`cliproxy-upstream-preset.ts:178`)。 + +每选一次 provider 就会创建一条独立的池上游记录。同一个实例可以同时挂三类池上游,互不影响。 + +## 第四步:客户端调用 + +池上游创建后,它在 `/upstreams` 列表里与普通上游并列,路由层把它们一视同仁。客户端调用形态与普通上游完全一致——base URL 指向 AutoRouter、`Authorization` 使用 AutoRouter 颁发的客户端 Key 即可,详见 [通过 AutoRouter 调用模型](./invoke-models)。 + +不同的是:CLI 工具自带的特征请求头会让 AutoRouter 把 `RouteCapability` 从基础态升级到 CLI 专属态,从而命中池上游而非普通的 OpenAI / Anthropic / Gemini 上游。识别逻辑在 `src/lib/services/route-capability-matcher.ts`: + +### Codex CLI + +识别(`route-capability-matcher.ts:144-156`):以下任一为真即升级 `POST /v1/responses` 的能力为 `codex_cli_responses`: + +- 请求头 `originator: codex_cli_rs` +- `User-Agent` 以 `codex_cli_rs/` 开头 +- 任意 `x-codex-*` 请求头 + +满足时命中声明 `codex_cli_responses` 的 CPA codex 池上游。最小调用示例: + +```bash +OPENAI_API_KEY=sk-auto-... \ +OPENAI_BASE_URL=http://:3331/api/proxy/v1 \ +codex "解释一下 main.go" +``` + +Codex CLI 默认自带 `originator: codex_cli_rs` 请求头,不需要额外配置。 + +### Claude Code CLI + +识别(`route-capability-matcher.ts:158-169`):以下任一为真即升级 `POST /v1/messages` 的能力为 `claude_code_messages`: + +- 请求头 `anthropic-beta` 包含 `claude-code-` +- `User-Agent` 以 `claude-cli/` 开头**且**请求头 `x-app: cli` 同时存在 + +满足时命中声明 `claude_code_messages` 的 CPA anthropic 池上游。最小调用示例: + +```bash +ANTHROPIC_API_KEY=sk-auto-... \ +ANTHROPIC_BASE_URL=http://:3331/api/proxy/v1 \ +claude "帮我写一个 hello world" +``` + +### Gemini SDK + +Gemini 路由能力仅看路径 `/v1beta/models/:generateContent` 或 `:streamGenerateContent`,**不涉及**请求头 profile 升级(`route-capability-matcher.ts:207-209`)。所以 Gemini SDK 调用本身没有「升级」一说,而是由声明 `gemini_native_generate` 的上游池整体承接。 + +```python +from google import genai + +client = genai.Client( + api_key="sk-auto-...", + http_options={"base_url": "http://:3331/api/proxy/v1"}, +) +response = client.models.generate_content(model="gemini-2.0-flash", contents="hello") +print(response.text) +``` + +`base_url` 末尾必须保留 `/v1`,Gemini SDK 会再追加 `/v1beta/...`,丢掉会落到 404;详见 [通过 AutoRouter 调用模型](./invoke-models)。 + +## 常见踩坑速查 + +| 现象 | 多半原因 | +| --------------------------------------------- | ------------------------------------------------------------------------------------ | +| 连通性测试 `unreachable` | sidecar 拓扑下填了 `localhost`、端口写错、CPA 容器未启动 | +| 连通性测试 `auth_failed` | `management_key` 与 CPA 侧配置不一致;CPA 配置文件改过、AutoRouter 侧未同步 | +| OAuth 登录窗口打开但回调一直不结束 | CPA 侧 callback forwarder 没工作;检查 CPA 配置与日志 | +| 账号显示「已过期」 | AutoRouter 侧无 token 刷新,重新走一次 OAuth 即可;老账号不需要先删 | +| Codex CLI 调用走到普通 OpenAI 上游了 | 检查请求头是否带 `originator: codex_cli_rs`;自定义代理可能剥离了该头导致能力没升级 | +| Claude Code CLI 调用走到普通 Anthropic 上游了 | 检查 `anthropic-beta` 是否含 `claude-code-`;或 `User-Agent` 与 `x-app` 是否同时满足 | + +## 不在本页范围内 + +- CPA 自身的 client_api_key / management_key 怎么生成、CPA 配置文件结构、CPA 监听端口:源码中未直接体现,参见 CLIProxyAPI 自身文档与 [CLIProxyAPI Sidecar 部署](../deployment/cliproxy-sidecar) 的卷与文件章节。 +- 多实例并存的负载均衡逻辑:与普通上游完全一致,见 [负载均衡与权重](./load-balancing)。 +- 模型字段如何与池上游能力交叉匹配:见 [模型路由规则](./model-routing)。 +- 熔断器在池上游上的行为:见 [熔断器配置](./circuit-breaker-config) 与 [`docs/circuit-breaker.md`](/circuit-breaker)。 diff --git a/docs/guide/usage/load-balancing.md b/docs/guide/usage/load-balancing.md index c98f137d..052f4835 100644 --- a/docs/guide/usage/load-balancing.md +++ b/docs/guide/usage/load-balancing.md @@ -5,19 +5,180 @@ outline: deep # 负载均衡与权重 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +当同一个 `RouteCapability` 下有多条上游候选时,AutoRouter 决定把这次请求发给谁。本页讲清楚四件事:每条上游的 `weight` 与 `priority` 字段语义、加权随机叠加延时分的选路算法、会话亲和如何让同一会话粘到同一上游、熔断与并发限制怎么提前把候选剔除掉。 -## 计划覆盖的内容 +把握一个总原则:当前实现**只有一种选路策略**——「先按 priority 分层、同层内加权随机叠加延时分」。代码里没有 round-robin、least-connections 之类的备选策略(管理后台的 i18n 翻译文件里出现过相关字符串,但服务端没有任何实现引用它们)。 -`weight` 与 `priority` 字段的语义、相同组内多上游的调度策略。 +## 上游字段与 UI -## 在正文就绪前的临时建议 +`upstreams` 表里与选路直接相关的字段(`src/lib/db/schema-pg.ts:74`): -在该文档正文上线之前,可以参考以下材料获取等价信息: +| DB 字段 | 类型 | 默认值 | UI 名称 | 范围 | +| -------------------- | --------- | ----------- | -------------------------- | --------- | +| `weight` | `integer` | `1` | Weight | 1–100 | +| `priority` | `integer` | `0` | Priority Tier | 0–100 | +| `max_concurrency` | `integer` | `null` 无限 | Max Concurrency | 整数 / 无 | +| `queue_policy` | `json` | `null` | Queue Policy | 见下文 | +| `affinity_migration` | `json` | `null` | Session Affinity Migration | 见下文 | +| `is_active` | `boolean` | `true` | Active | 开关 | -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +`priority` 字段不是凭名字猜的——它真实存在于 schema 并有专属索引(`src/lib/db/schema-pg.ts:126`)。UI 提示直接说明(`src/messages/en.json:725`):「Lower number = higher priority. Tier 0 is tried first, then tier 1, etc.」;权重的语义是(`src/messages/en.json:728`):「Higher weight = more requests routed to this upstream within the same tier」。 + +简记:**priority 决定优先级层、weight 决定同层内的比例**。 + +## 选路算法 + +`src/lib/services/load-balancer.ts` 是核心模块。最常被调用的入口是 `selectFromProviderType`(`load-balancer.ts:643`)与 `selectFromUpstreamCandidates`(`:675`),它们内部走 `performTieredSelection`(`:983`)。 + +`performTieredSelection` 按 `priority` 升序把候选分成多个 tier,从 tier 0 开始逐层尝试,每个 tier 内做以下过滤与选择: + +| 顺序 | 处理 | 函数 | +| ---- | -------------------------------------- | ----------------------------------------- | +| 1 | 去掉熔断 OPEN / HALF_OPEN 未到期的上游 | `filterByCircuitBreaker`(`:243`) | +| 2 | 去掉超过 spending quota 的上游 | `filterBySpendingQuota`(`:325`) | +| 3 | 去掉调用方传入的 excludeIds | `filterByExclusions`(`:309`) | +| 4 | 去掉并发已满的上游 | `filterByConcurrencyCapacity`(`:451`) | +| 5 | 剩余候选进入加权随机选择 | `selectWeightedWithHealthScore`(`:485`) | + +第 4 步过滤掉并发已满的上游时,开启了 `queue_policy.enabled` 的上游不会被直接丢弃,而是进入 `waitableCandidates` 集合(`load-balancer.ts:1049-1058`);如果所有 tier 都没选出可用候选,再从 `waitableCandidates` 里挑一个排队等待槽位。 + +### 加权随机叠加延时分 + +`selectWeightedWithHealthScore`(`load-balancer.ts:485`)的核心计算: + +``` +score = 1.0 +if latencyMs > 0: + latencyPenalty = min(latencyMs / 500, 0.5) # 至多扣 0.5 + score -= latencyPenalty +score = max(score, 0.1) # 至少保留 0.1 +effectiveWeight = upstream.weight * score +``` + +举例(同一 tier 内): + +| 上游 | weight | latencyMs | latencyPenalty | score | effectiveWeight | +| ---- | ------ | --------- | -------------- | ----- | --------------- | +| A | 10 | 0 | 0 | 1.0 | 10 | +| B | 10 | 100 | 0.2 | 0.8 | 8 | +| C | 10 | 250 | 0.5 | 0.5 | 5 | +| D | 10 | 800 | 0.5(封顶) | 0.5 | 5 | + +最终按 `effectiveWeight` 总和做加权轮盘(`load-balancer.ts:514-521`)。**延时分对权重的最大影响是减半**,不会把某条上游完全排除掉。当所有候选的 `effectiveWeight` 加起来为 0 时,退化为纯随机选一个(`:509-511`)。 + +### 延时数据从哪来 + +`latencyMs` 不是请求级别的滑动平均,而是**上一次后台健康检查测到的单次 RTT**: + +- 来源字段:`upstream_health.latency_ms`(`schema-pg.ts:145`,`integer`,可空)。 +- 写入逻辑:`checkUpstreamHealth` 调用 `testUpstreamConnection` 测真实 RTT(`src/lib/services/health-checker.ts:302-314`),然后 `updateHealthStatus(upstreamId, success, latencyMs)` 直接覆盖写入(`:155-226`,无滚动平均)。 +- 触发频率:`background-sync` 调度器按 `HEALTH_CHECK_INTERVAL` 调用,默认 30 秒(`src/lib/utils/config.ts:38`)。 + +也就是说:上游真实延时上下波动比较剧烈时,`latencyMs` 反应有滞后;不要期望它能在毫秒级别区分上游。 + +## 会话亲和(Session Affinity) + +`src/lib/services/session-affinity.ts` 让同一会话尽可能粘到同一上游,对话类场景(CoT、连续 turn)尤其重要。 + +### 触发条件 + +只有当请求里能提取出 `sessionId` 时才会触发。提取规则按协议而异(`session-affinity.ts:283`): + +- **Anthropic 协议**:`body.metadata.user_id` 含 `_session_{uuid}` 格式(`:308-329`)。 +- **OpenAI 协议**:优先看 header `session_id` / `session-id` / `x-session-id`;其次看 body 的 `prompt_cache_key` / `metadata.session_id` / `previous_response_id`(`:343-377`)。 +- 同时调用方还需传入 `affinityContext`(含 `apiKeyId`、`contentLength`、`affinityScope`,见 `load-balancer.ts:647`)。 + +满足条件后,AutoRouter 在内存 Map 里查 `(apiKeyId, scope, sessionId) → upstreamId` 的绑定,命中则跳过加权随机直接用该上游。 + +### TTL 与容量 + +`session-affinity.ts:39-41`: + +- **滑动 TTL**:5 分钟无访问过期。 +- **绝对 TTL**:30 分钟(即使一直被命中也会过期,避免会话永远粘死在某个上游)。 +- **最大条目数**:10,000,LRU 驱逐。 + +注意亲和缓存只在内存里,**进程重启会丢**。 + +### 绑定上游不可用时 + +当亲和命中的目标上游被 `excludeIds` 排除、熔断 OPEN 未到期、配额超限或并发已满时,AutoRouter 不会强行等待——而是**跳过亲和、走普通的 tiered 选路**(`load-balancer.ts:810-932`)。重要细节:这种情况下**不清除亲和缓存**(注释 `:931`),下次请求若目标上游恢复仍可能命中原绑定。 + +如果想让某个 sessionId 主动「换上游」,目前只能等亲和 TTL 自然过期,没有专门的 admin 接口去清空。 + +### 与负载均衡的顺序 + +`selectFromUpstreamPool`(`load-balancer.ts:795`)的顺序: + +1. 先看亲和缓存——命中且可用就返回。 +2. 命中但目标更高 priority 上游可用时,按 `shouldMigrate`(`:413`)判断是否迁移(具体由上游的 `affinity_migration` 字段控制,例如同一 tier 不迁移、跨 tier 迁移、内容长度阈值之类)。 +3. 亲和未命中或不可用——降级到 `performTieredSelection`。 + +## 熔断与并发对选路的影响 + +### 熔断器 + +`filterByCircuitBreaker`(`load-balancer.ts:243`)严格按下表过滤候选: + +| 熔断器状态 | 条件 | 动作 | +| ----------- | -------------------------- | ----------------------------- | +| `CLOSED` | 任何时候 | 允许通过 | +| `OPEN` | `elapsed < openDuration` | 排除 | +| `OPEN` | `elapsed >= openDuration` | 允许(自动转 HALF_OPEN 试探) | +| `HALF_OPEN` | `elapsed < probeInterval` | 排除 | +| `HALF_OPEN` | `elapsed >= probeInterval` | 允许(探针请求) | +| 未知状态 | — | 宽松允许(`:298-300`) | + +熔断与失败规则的详细配置见 [熔断器配置](./circuit-breaker-config)。 + +### 并发槽位 + +`upstream-queue-admission.ts` 维护一个内存 `UpstreamQueueAdmissionService`(`:123`),按 `max_concurrency` 限流: + +- `max_concurrency == null` → 无限制。 +- `activeCount >= maxConcurrency` 且 `queue_policy.enabled` 为 false → `filterByConcurrencyCapacity` 直接把它从候选剔除。 +- `activeCount >= maxConcurrency` 且 `queue_policy.enabled` 为 true → 进入 `waitableCandidates`,所有 tier 都无可用候选时再从这里选一个去等待槽位(如果 `queue.length >= maxQueueLength` 直接拒绝并报 `queue_full`,见 `upstream-queue-admission.ts:174`)。 + +`queue_policy` 自身的字段:`enabled`、`timeout_ms`(等待槽位的超时)、`max_queue_length`(队列上限)。 + +## 一次典型选择的全流程 + +把上面拼成一次实际选择: + +``` +请求 → RouteCapability → 初始候选集合(声明该能力 + 活跃) + ↓ +Key.allowed_models 白名单 / 受限模式 apiKeyUpstreams 过滤 + ↓ +按 priority 分 tier,逐层尝试 + ┌── 当前 tier ───────────────────────────────┐ + │ filterByCircuitBreaker(OPEN / HALF_OPEN 未到期 → 跳过) │ + │ filterBySpendingQuota(quota 已满 → 跳过) │ + │ filterByExclusions(在排除列表 → 跳过) │ + │ filterByConcurrencyCapacity(并发已满 → 跳过 或 进入 waitable) │ + │ ───────────── │ + │ 若亲和命中且可用 → 直接返回 │ + │ 否则 selectWeightedWithHealthScore │ + │ effectiveWeight = weight * max(1 - min(latencyMs/500, 0.5), 0.1) │ + │ 加权轮盘抽中一个 │ + └────────────────────────────────────────────┘ + ↓ +本 tier 无候选 → 进入下一 tier,最低优先级失败后从 waitable 选一个排队 + ↓ +仍无候选 → 抛 AllCandidatesConcurrencyFullError 或 ROUTE_NO_UPSTREAM_AVAILABLE +``` + +## 调参建议 + +- **想让某条上游接管所有流量**:在它独自所在的 tier(最低数字),且其他上游放更高数字的 tier;它故障时自动降级到下一 tier。 +- **想在两条等价上游之间按比例分流**:放同一 tier,按比例设 `weight`。例如 30:70 → `weight = 3, 7`(也能写 30:70,加权随机不受绝对值影响)。 +- **想限制单上游并发**:设 `max_concurrency`;并发紧张时配合 `queue_policy` 决定是排队还是直接换上游。 +- **想让对话粘到同一上游**:客户端在请求里带 sessionId(OpenAI 用 `prompt_cache_key` 或 header;Anthropic 用 `metadata.user_id` 中嵌 `_session_{uuid}`),AutoRouter 自动绑定。 +- **想让某些 sessionId 跨 tier 迁移**:配置 `affinity_migration` 字段。 + +## 不在本页范围内 + +- 失败转移与熔断状态机细节:见 [熔断器配置](./circuit-breaker-config) 与 [`docs/circuit-breaker.md`](/circuit-breaker)。 +- 模型字段与上游 model_rules 的匹配:见 [模型路由规则](./model-routing)。 +- 一次请求从入口到响应的全流程:见 [请求生命周期](../architecture/request-lifecycle)。 +- spending quota / 计费规则的具体语义:后续「请求日志与统计」「计费」相关文档。 diff --git a/docs/guide/usage/model-routing.md b/docs/guide/usage/model-routing.md index 842e85e4..89db94bc 100644 --- a/docs/guide/usage/model-routing.md +++ b/docs/guide/usage/model-routing.md @@ -5,19 +5,195 @@ outline: deep # 模型路由规则 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +AutoRouter 选择上游的决策依据并非「模型名前缀映射」这类预设规则,而是一组可叠加的可配置约束。本页按选路顺序展开:先讲请求路径与请求头如何确定本次请求的「路由能力」、再讲上游如何声明自己能承接哪些能力与模型、再讲规则匹配与模型名重写的精确语义、最后讲客户端可见模型白名单的两层叠加。 -## 计划覆盖的内容 +读完之后,可以把任何一个「模型字段写什么 → 命中哪条上游」的问题对照源码自行还原。 -`gpt-*` 到 openai 组、`claude-*` 到 anthropic 组、`gemini-*` 到 gemini 组的模型前缀映射逻辑、覆盖与扩展方式。 +## 第一层:路由能力(RouteCapability)筛选 -## 在正文就绪前的临时建议 +`src/lib/route-capabilities.ts:1-10` 定义了 8 种 `RouteCapability`: -在该文档正文上线之前,可以参考以下材料获取等价信息: +``` +"anthropic_messages" | "claude_code_messages" +"openai_responses" | "codex_cli_responses" +"openai_chat_compatible" | "openai_extended" +"gemini_native_generate" | "gemini_code_assist_internal" +``` -- 项目仓库根目录的 [README.md](https://github.com/g1331/AutoRouter/blob/master/README.md) -- 现有长篇 [`docs/cliproxy-deployment.md`](/cliproxy-deployment) -- 现有长篇 [`docs/circuit-breaker.md`](/circuit-breaker) -- 项目 [Issue 列表](https://github.com/g1331/AutoRouter/issues) 与 [OpenSpec 提案](https://github.com/g1331/AutoRouter/tree/master/openspec) +每种能力对应一个 `CapabilityProvider`(`route-capabilities.ts:93-102`):`anthropic_*` → `anthropic`,`openai_*` / `codex_*` → `openai`,`gemini_*` → `google`。 + +入口函数 `resolveRouteCapability(method, path, headers)`(`src/lib/services/route-capability-matcher.ts:307`)分两步把请求映射为一个 `RouteCapability`: + +### 步骤 1:协议族匹配 + +`matchProtocolFamily(method, path)`(`route-capability-matcher.ts:171`)按路径段匹配出基础协议族: + +| 请求路径模板 | 协议族 / 基础能力 | +| ----------------------------------------------------- | ----------------------------- | +| `POST .../messages` | `messages`(先记下) | +| `POST .../responses` | `responses`(先记下) | +| `GET\|POST .../chat/completions` 或 `GET v1/models` | `openai_chat_compatible` | +| `POST .../completions` / `embeddings` / `moderations` | `openai_extended` | +| `POST .../images/*` | `openai_extended` | +| `POST v1beta/models/:generateContent` | `gemini_native_generate` | +| `POST v1beta/models/:streamGenerateContent` | `gemini_native_generate` | +| `POST v1internal:generateContent` | `gemini_code_assist_internal` | +| 其他 / 含路径遍历 | `null` → 直接拒绝 | + +### 步骤 2:客户端 profile 升级 + +`resolveFinalCapability(protocolFamily, headers)`(`route-capability-matcher.ts:218`)再看请求头中的 CLI profile,把基础态升级到 CLI 专属态: + +| 协议族 | 升级触发条件(任一满足) | 升级后能力 | +| ----------- | ----------------------------------------------------------------------------------------- | ---------------------- | +| `messages` | `anthropic-beta` 含 `claude-code-`;或 `User-Agent` 起 `claude-cli/` 且 `x-app: cli` | `claude_code_messages` | +| `messages` | 不满足上述 | `anthropic_messages` | +| `responses` | `originator: codex_cli_rs`;或 `User-Agent` 起 `codex_cli_rs/`;或任意 `x-codex-*` header | `codex_cli_responses` | +| `responses` | 不满足上述 | `openai_responses` | + +最终 `RouteCapability` 决定本次请求只能命中**声明了该能力的上游**。 + +## 第二层:上游声明 route_capabilities + +`upstreams` 表的 `route_capabilities` 字段(`src/lib/db/schema-pg.ts:89`)是一个可空 JSON 字符串数组(无 DB default,新字段默认为 `null`)。管理员在「上游管理」表单中勾选该上游能承接哪些能力。 + +`null` 与空数组的处理:实际的迁移与匹配逻辑保证「未声明 = 不可用」,所以新建上游一定要勾上至少一个能力。 + +启动期一次性迁移:`ensureRouteCapabilityMigration()`(`src/lib/services/route-capability-migration.ts:125`)在进程启动后执行幂等迁移,过 `normalizeRouteCapabilitiesWithMeta()` 去掉非法值、把旧值 `codex_responses` 重映射为 `openai_responses`(`route-capabilities.ts:19-21`)。被规范化过的上游会回写数据库,并在日志里提示管理员若是 CLI 专属应改为 `codex_cli_responses`(`route-capability-migration.ts:84-93`)。 + +## 第三层:模型规则(model_rules / model_redirects / allowed_models) + +上游有三个相关字段(`src/lib/db/schema-pg.ts:90-91, 100`): + +| 字段 | 含义 | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `model_rules` | 新版规则数组,每条规则形如 `{ type, value, target_model, source, display_label }`,`type` 取值 `exact \| regex \| alias`(见 `src/lib/services/upstream-model-types.ts:42-48`) | +| `allowed_models` | 旧版精确模型白名单,单纯一组字符串 | +| `model_redirects` | 旧版模型重定向映射 `{ "客户端模型名": "上游侧别名" }` | + +三者关系:`normalizeUpstreamModelRules`(`src/lib/services/upstream-model-rules.ts:189`)优先读 `model_rules`;若 `model_rules` 为空,则把 `allowed_models` 转成 `exact` 规则、把 `model_redirects` 转成 `alias` 规则,实现向后兼容。 + +### 三种 rule type 的语义 + +| `type` | 语义 | 是否改写模型名 | +| ------- | ------------------------------------------------------- | -------------- | +| `exact` | 客户端模型名严格等于 `value` 时匹配 | 否 | +| `regex` | 客户端模型名匹配 `value` 中的正则时匹配 | 否 | +| `alias` | 同上述任一形式匹配后,转发时把模型名换为 `target_model` | 是 | + +`alias` 链可以传递:A → B → C,最多追踪 10 跳后停止以防环(`upstream-model-rules.ts:131`)。 + +### 规则匹配出口:resolvePathRoutingModelForUpstream + +`resolvePathRoutingModelForUpstream(originalModel, upstream)`(`src/app/api/proxy/v1/[...path]/route.ts:557`)是路由层使用的统一出口。内部调用 `matchUpstreamModelRules`(`upstream-model-rules.ts:326`),返回四个字段: + +| 字段 | 含义 | +| ------------------ | ------------------------------------------------------------ | +| `matched` | 该上游是否接受 `originalModel` | +| `hasExplicitRules` | 该上游是否配置了任何规则(`model_rules` / 兼容来源非空) | +| `resolvedModel` | 真正向上游转发时使用的模型名(`alias` 命中时替换;否则原样) | +| `redirectApplied` | 是否发生了模型名替换 | + +### 「未显式拒绝即默认放行」语义 + +整体过滤逻辑在 `filterCandidatesByModelRules`(`route.ts:591-624`): + +```ts +// 摘自 route.ts:591-624 +if (!originalModel) return { allowed: candidates, excluded: [] }; // 模型缺失 → 全部放行 +for (const candidate of candidates) { + const r = resolvePathRoutingModelForUpstream(originalModel, candidate); + if (r.matched) { + allowed.push(candidate); + continue; + } + if (r.hasExplicitRules) { + excluded.push({ id: candidate.id, name: candidate.name, reason: "model_not_allowed" }); + continue; + } + allowed.push(candidate); +} +``` + +读出来的语义有三条,需要分别记住: + +1. **请求体里没有 `model` 字段**(在 OpenAI / Anthropic 协议下 `bodyJson.model` 不是 string;Gemini 没法从路径里取出来):所有候选都通过,请求会被转发到选中的上游,错误(如果有)来自上游而非 AutoRouter。 +2. **`model` 字段存在且上游没有配置任何 model_rules(空白名单)**:默认放行。「空 = 接受所有模型」,**不是**「空 = 拒绝一切」。 +3. **`model` 字段存在且上游配置了规则但都没命中**:该上游被排除,理由 `model_not_allowed`。 + +这条「未显式拒绝即默认放行」的语义直接影响日常配置:如果一条上游只想承接 `claude-3-5-haiku`,必须显式加一条 `exact` 或 `regex` 规则;只要 `model_rules` 为空,它就会接管所有命中其声明能力的请求。 + +## 旧版前缀映射的现状 + +`getProviderTypeForModel`(`src/lib/services/model-router.ts:105`)保留了基于前缀的映射表(`model-router.ts:20-24`): + +``` +"claude-" → "anthropic" +"gpt-" → "openai" +"gemini-" → "google" +``` + +但这个函数**不再被主代理路由 `src/app/api/proxy/v1/[...path]/route.ts` 调用**(全仓 grep 无 `routeByModel` 在主路由中的引用)。它现在只在两处出现: + +- `model-router.ts:310` 的旧版 `routeByModel`——已不在主路由路径上。 +- `src/lib/services/billing-cost-service.ts:445`——计费时用来区分输入 token 计算口径。 + +也就是说:当前选路完全由「route_capabilities + model_rules」两层决定,**模型前缀不再影响请求会去哪个上游**。如果想达到「`gpt-*` 默认去 OpenAI、`claude-*` 默认去 Anthropic」的效果,做法是: + +- OpenAI 上游声明 `openai_chat_compatible` 等能力,不加额外规则——它会承接所有命中 `openai_chat_compatible` 路径的请求。 +- Anthropic 上游声明 `anthropic_messages` 等能力,不加额外规则——它会承接 `/v1/messages`。 + +请求路径已经天然把 `gpt-*` 与 `claude-*` 分开了(`gpt-*` 通常在 `chat/completions`,`claude-*` 在 `messages`),不需要前缀映射这一层。 + +## 第四层:客户端可见模型白名单 + +客户端 Key 的 `allowed_models` 字段(`schema-pg.ts:55`)是另一层白名单,在候选筛选**之前**生效: + +`isModelAllowedByApiKey(requestedModel, allowedModels)`(`src/lib/api-key-models.ts:16`):`allowedModels` 为空或 null 直接放行;否则做精确字符串 `includes` 检查,命中失败的请求直接返回错误码 `API_KEY_MODEL_NOT_ALLOWED`(`route.ts:2507`)。 + +`getApiKeyVisibleModelList`(`route.ts:626`)仅在 `GET /v1/models` 这种返回模型列表的请求里触发:对 Key 的 `allowedModels` 做过滤,保留其中**能被至少一个候选上游接受**的模型名(用 `resolvePathRoutingModelForUpstream(model, candidate).matched` 判断),返回交集。 + +叠加规则三条: + +1. Key 的 `allowed_models` 为空 / null → 模型层不做限制,模型列表 API 返回全部候选上游支持的模型。 +2. Key 的 `allowed_models` 非空 → 调用某模型时必须在其中,否则鉴权阶段就被拒;模型列表 API 仅返回「Key 白名单」与「上游能接受」两者的**交集**。 +3. 上游的 `model_rules` 与 Key 的 `allowed_models` 是**独立两层**:Key 白名单不能绕过上游层的规则。例如 Key 写了 `["gpt-4o"]` 但所有 OpenAI 上游的 `model_rules` 都明确不接受 `gpt-4o`,请求最终还是无候选可用。 + +## 一次请求的选路顺序 + +把上面四层串起来,一次请求的选路顺序如下: + +``` +请求 → 路径 + headers → RouteCapability + ↓ + Key.allowed_models 白名单(早期拒绝) + ↓ + 初始候选:声明了该 RouteCapability 的活跃上游 + ↓ + 受限模式过滤(如果 Key 是 restricted,按 apiKeyUpstreams 关联表限定) + ↓ + 熔断状态过滤(filterByCircuitBreaker) + ↓ + 模型规则过滤(filterCandidatesByModelRules,按 model_rules 决定 allowed / excluded) + ↓ + 加权随机选择(selectWeightedWithHealthScore,详见 docs/guide/usage/load-balancing) + ↓ + 命中候选 → 若 alias 规则命中则改写 model 字段 → 转发 +``` + +## 调试与排查 + +| 现象 | 多半原因 | +| --------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| 期望命中 A 上游,但实际命中了 B 上游 | A 与 B 都声明了同一 RouteCapability 且都通过模型规则;按加权随机选了 B | +| 期望命中 A 上游,但被路由层拒绝(403/404) | A 的 `route_capabilities` 没勾上该能力;或 A 的 `model_rules` 明确不接受此模型名 | +| 想让某客户端模型名转发为另一个名字 | 在目标上游加 `alias` 规则:`{ type: "alias", value: "gpt-4o", target_model: "gpt-4o-2024-11-20" }` | +| 想让所有上游一律不接受某模型 | 在 Key 的 `allowed_models` 外面挡掉;或在每个上游的 `model_rules` 中显式排除 | +| Codex / Claude Code CLI 不命中预期 CPA 池上游 | 检查请求头是否带特征字段(`originator` / `anthropic-beta` 等),见上文「客户端 profile 升级」一节 | + +## 不在本页范围内 + +- 选路的加权随机算法细节、延时分数、熔断与并发对候选池的影响:见 [负载均衡与权重](./load-balancing)。 +- 熔断状态机与失败规则:见 [熔断器配置](./circuit-breaker-config)。 +- 请求经过哪些阶段、每一阶段做什么:见 [请求生命周期](../architecture/request-lifecycle)。 +- CLIProxyAPI 池上游能力预设:见 [CLIProxyAPI 首次使用指南](./cliproxy-first-time) 与 [`docs/cliproxy-deployment.md`](/cliproxy-deployment)。