From 85ff33c352f367bba3a61e9ce8f21e274e9548e1 Mon Sep 17 00:00:00 2001 From: umaru Date: Sun, 24 May 2026 11:42:56 +0800 Subject: [PATCH 1/2] =?UTF-8?q?docs(architecture):=20fill=20phase=202=20ba?= =?UTF-8?q?tch=206A=20=E2=80=94=20core=20mechanism=20trio=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 填充三篇架构核心机制文档: - upstream-model: RouteCapability 八枚举 + provider 分组、upstreams 表关键字段、 model-router 五步路由(前缀 → provider type → 熔断过滤 → 白名单/别名 → 回退)、 load-balancer 的 tier 过滤 + 加权抽样 + session affinity,以及健康状态对路由 不直接生效的事实。 - failover-circuit: 熔断器三态状态机表格 + 默认阈值 + recordFailure/recordSuccess 转换条件、forwardWithFailover 主循环与 isFailoverableError 判定、FailureRule 屏蔽熔断计数的机制、Admin 强制 open/close、决策日志写入 request_logs 的字段。 - database-schema: 双 schema 文件(PG + SQLite)的 barrel 切换、20 张表分四组 清单(按用途)、FK 与 cascade/set null 策略汇总、JSON 列 TypeScript 类型注解 表、单列与复合索引摘要、迁移目录与最近五次 PG 迁移、客户端单例与连接池配置。 所有事实均带 file:line 引用,便于读者照着源码读。 --- docs/guide/architecture/database-schema.md | 240 +++++++++++++++++++- docs/guide/architecture/failover-circuit.md | 187 ++++++++++++++- docs/guide/architecture/upstream-model.md | 225 +++++++++++++++++- 3 files changed, 622 insertions(+), 30 deletions(-) diff --git a/docs/guide/architecture/database-schema.md b/docs/guide/architecture/database-schema.md index 1cb2e5eb..9c4de603 100644 --- a/docs/guide/architecture/database-schema.md +++ b/docs/guide/architecture/database-schema.md @@ -5,19 +5,239 @@ outline: deep # 数据库 schema -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 用 Drizzle ORM 维护数据库 schema,PostgreSQL 是首选生产数据库、SQLite 仅用于本地开发沙箱。所有表 / 关系 / 类型在源码里都有可靠的单一定义,转发链路、统计聚合、计费快照都从这些表读写。 + +这一页给出表清单、表与表的关系、JSON 列存储什么 TypeScript 类型、迁移如何管理、以及客户端怎么拿到 `db` 实例。所有引用都指向 `master` 分支源码。某张表在路由 / 计费 / 录制中是怎么被消费的,参见对应的架构页或使用文档。 + +## 两个 schema 文件,一个 barrel + +数据库 schema 同时维护两份: + +- `src/lib/db/schema-pg.ts` —— PostgreSQL 版本,**全部生产能力以这一份为准** +- `src/lib/db/schema-sqlite.ts` —— SQLite 版本,仅供本地沙箱 +- `src/lib/db/schema.ts` —— barrel,按 `config.dbType` 在两份之间切换 + +barrel 文件做的事情只有一件: + +```ts +// src/lib/db/schema.ts:1-5 +import { config } from "../utils/config"; +import * as pgSchema from "./schema-pg"; +import * as sqliteSchema from "./schema-sqlite"; + +const schema = (config.dbType === "sqlite" ? sqliteSchema : pgSchema) as typeof pgSchema; +``` + +整个项目所有业务代码都从 `@/lib/db` 这个 barrel 导入表对象与类型,不直接 import `schema-pg` 或 `schema-sqlite`,保证一份业务代码同时能跑在两套数据库上。 + +::: warning SQLite 不是平替 +注释(`src/lib/db/index.ts:14`)明确说明:SQLite 在结构上对常规 CRUD 兼容,但 `PERCENTILE_CONT` 等 PG 专用 SQL 在 SQLite 上不可用。统计聚合(`/api/admin/stats/*`)在 SQLite 上会有部分查询直接报错。线上务必用 PostgreSQL。 ::: -## 计划覆盖的内容 +## 表清单 + +`schema-pg.ts` 内总共定义 20 张表,按用途分四组: + +### 客户端 Key 与上游 + +| 表 | 行号 | 用途 | +| ------------------------ | ------- | ------------------------------------------------------- | +| `api_keys` | 44-69 | 下游客户端 Key 与限额规则 | +| `upstreams` | 74-128 | 上游 provider 配置(详见 [上游模型](./upstream-model)) | +| `upstream_health` | 133-152 | 上游健康状态与探测结果(一对一) | +| `upstream_probe_results` | 157-195 | 协议能力 / 客户端 profile 的诊断探测结果 | +| `api_key_upstreams` | 200-217 | Key ↔ Upstream 多对多授权 | + +### 熔断器与失败规则 + +| 表 | 行号 | 用途 | +| ------------------------ | ------- | ----------------------------------------------------------------------- | +| `circuit_breaker_states` | 222-251 | 每个上游一行的熔断器状态机(详见 [失败转移与熔断](./failover-circuit)) | +| `upstream_failure_rules` | 257-274 | 命中后免于触发熔断的失败规则(含全局与上游局部) | + +### 请求日志与录制 + +| 表 | 行号 | 用途 | +| ---------------------------- | ------- | -------------------------------------------------------------------- | +| `request_logs` | 279-342 | 每次请求一行的审计日志(详见 [请求日志与统计](../usage/logs-stats)) | +| `traffic_recording_settings` | 347-355 | 流量录制全局单例配置 | +| `traffic_recordings` | 360-390 | 录制文件索引(详见 [请求录制](../usage/request-recording)) | + +### 计费与价格 + +| 表 | 行号 | 用途 | +| -------------------------------- | ------- | ---------------------------------------------- | +| `billing_model_prices` | 395-417 | 自动同步的模型价格目录(openrouter / litellm) | +| `billing_manual_price_overrides` | 422-436 | 管理后台手动覆盖的价格 | +| `billing_tier_rules` | 443-469 | 按上下文长度分档计费规则 | +| `billing_price_sync_history` | 474-486 | 价格同步任务运行历史 | +| `request_billing_snapshots` | 544-587 | 每条 `request_logs` 一份的计费快照(一对一) | + +### 后台任务与扩展 + +| 表 | 行号 | 用途 | +| --------------------------- | ------- | --------------------------------------------------------------------------- | +| `background_sync_tasks` | 491-514 | 后台任务调度状态(单例 per task name) | +| `background_sync_task_runs` | 519-539 | 后台任务每次运行的历史 | +| `compensation_rules` | 592-607 | 出站 header 补偿 / 改写规则 | +| `cliproxy_instances` | 718-738 | CLIProxyAPI 实例注册(详见 [CLIProxyAPI 集成位置](./cliproxy-integration)) | +| `cliproxy_auth_accounts` | 744-771 | 从 CLIProxyAPI 缓存的 OAuth 账号元数据 | + +::: tip 没有 users 表 +`api_keys.user_id` 列保留为 nullable,无外键约束,源码 `schema-pg.ts:53` 注释 `// Reserved for future user system`。当前认证只有「客户端 API Key」与「Admin Bearer Token」两种身份,没有完整的用户系统。 +::: + +## 外键与级联策略 + +| 子表 | 列 | 父表 | onDelete | +| --------------------------- | ------------------------- | -------------------- | ---------- | +| `upstreams` | `cliproxy_instance_id` | `cliproxy_instances` | `set null` | +| `upstream_health` | `upstream_id` (UNIQUE) | `upstreams` | `cascade` | +| `upstream_probe_results` | `upstream_id` | `upstreams` | `cascade` | +| `api_key_upstreams` | `api_key_id` | `api_keys` | `cascade` | +| `api_key_upstreams` | `upstream_id` | `upstreams` | `cascade` | +| `circuit_breaker_states` | `upstream_id` (UNIQUE) | `upstreams` | `cascade` | +| `upstream_failure_rules` | `upstream_id` (nullable) | `upstreams` | `cascade` | +| `request_logs` | `api_key_id` | `api_keys` | `set null` | +| `request_logs` | `upstream_id` | `upstreams` | `set null` | +| `traffic_recordings` | `request_log_id` | `request_logs` | `set null` | +| `traffic_recordings` | `api_key_id` | `api_keys` | `set null` | +| `traffic_recordings` | `upstream_id` | `upstreams` | `set null` | +| `request_billing_snapshots` | `request_log_id` (UNIQUE) | `request_logs` | `cascade` | +| `request_billing_snapshots` | `api_key_id` | `api_keys` | `set null` | +| `request_billing_snapshots` | `upstream_id` | `upstreams` | `set null` | +| `cliproxy_auth_accounts` | `instance_id` | `cliproxy_instances` | `cascade` | + +**一对一约束**:靠 UNIQUE 字段实现,上面表里标 `(UNIQUE)` 的三行——`upstream_health.upstream_id`、`circuit_breaker_states.upstream_id`、`request_billing_snapshots.request_log_id`——每条父记录最多对应一条子记录。 + +**级联与设空的语义**: + +- `cascade`:父记录被删,子记录跟着被物理删除。删除一个 `upstreams` 行会同时清掉它的健康状态、探测结果、熔断器、授权关系等。 +- `set null`:父记录被删,子记录的外键列被设为 `NULL`,子记录本身保留。删除一个 `upstreams` 后,历史 `request_logs` 和 `request_billing_snapshots` 仍然存在,但 `upstream_id` 变成 NULL,统计页面会显示「未知上游」。 +- `cliproxy_instance_id` 用 `set null` 是为了允许「删除 CLIProxyAPI 实例时不影响上游配置本身」——但应用层在 `deleteCliproxyInstance` 会先做引用检查并抛 409,FK set null 实际只在绕过应用层删除时才会触发(详见 [使用 / CLIProxyAPI 外部 vs sidecar](../usage/cliproxy-modes))。 + +## JSON 列与 TypeScript 类型 + +Drizzle 的 `json()` 列通过 `.$type()` 注解绑定 TypeScript 类型,但**数据库层不做运行时校验**,类型安全只在编译期成立。所有 JSON 列: + +| 表 | 列 | 注解类型 | +| ------------------------ | --------------------- | -------------------------------------------------- | +| `api_keys` | `allowed_models` | `string[] \| null` | +| `api_keys` | `spending_rules` | `{period_type, limit, period_hours?}[] \| null` | +| `upstreams` | `route_capabilities` | `string[] \| null` | +| `upstreams` | `allowed_models` | `string[] \| null` | +| `upstreams` | `model_redirects` | `Record \| null` | +| `upstreams` | `model_discovery` | `UpstreamModelDiscoveryConfig \| null` | +| `upstreams` | `model_catalog` | `UpstreamModelCatalogEntry[] \| null` | +| `upstreams` | `model_rules` | `UpstreamModelRule[] \| null` | +| `upstreams` | `queue_policy` | `UpstreamQueuePolicy \| null` | +| `upstreams` | `failure_rule_config` | `{useGlobalRules: boolean} \| null` | +| `upstreams` | `affinity_migration` | `{enabled, metric, threshold} \| null` | +| `upstreams` | `spending_rules` | `{period_type, limit, period_hours?}[] \| null` | +| `upstream_failure_rules` | `match` | `UpstreamFailureRuleMatch` | +| `circuit_breaker_states` | `config` | `{failureThreshold?,…,streamIdleTimeout?} \| null` | +| `request_logs` | `header_diff` | `HeaderDiff \| null` | +| `compensation_rules` | `capabilities` | `string[]` | +| `compensation_rules` | `sources` | `string[]` | +| `cliproxy_auth_accounts` | `raw_metadata` | `Record \| null` | + +`UpstreamModelDiscoveryConfig` / `UpstreamModelCatalogEntry` / `UpstreamModelRule` 等导入自 `@/lib/services/upstream-model-types`(`schema-pg.ts:16-21`)。`UpstreamFailureRuleMatch` 与 `UpstreamFailureRuleConfig` 是 schema 文件内的本地类型(`schema-pg.ts:29-39`)。 + +::: warning request_logs 的 JSON 实际存为 text +`request_logs.failover_history`、`routing_decision`、`thinking_config` 三列在 schema 中是 `text` 而不是 `json`(`schema-pg.ts:310-311`),写入时调用 `JSON.stringify`、读取时手动 `JSON.parse`。这是历史选择,目的是兼容 SQLite 与避免某些 PG 版本对大 JSON 文档的索引问题。新加字段应优先用 `json()`,并补 `$type<>()` 注解。 +::: + +## 索引 + +显式定义的非 PK / 非 UNIQUE 单列索引按表汇总: + +| 表 | 索引列 | +| -------------------------------- | ----------------------------------------------------------------------------------- | +| `api_keys` | `key_hash`, `is_active` | +| `upstreams` | `name`, `is_active`, `priority` | +| `upstream_health` | `upstream_id`, `is_healthy` | +| `upstream_probe_results` | `upstream_id`, `status`, `checked_at` | +| `api_key_upstreams` | `api_key_id`, `upstream_id` | +| `circuit_breaker_states` | `upstream_id`, `state` | +| `upstream_failure_rules` | `upstream_id`, `enabled`, `priority` | +| `request_logs` | `api_key_id`, `upstream_id`, `created_at`, `routing_type` | +| `traffic_recordings` | `request_log_id`, `api_key_id`, `upstream_id`, `status_code`, `model`, `created_at` | +| `billing_model_prices` | `model`, `source` | +| `billing_manual_price_overrides` | `model` | +| `billing_tier_rules` | `model`, `source` | +| `billing_price_sync_history` | `created_at` | +| `background_sync_tasks` | `enabled`, `next_run_at` | +| `background_sync_task_runs` | `task_name`, `started_at`, `status` | +| `request_billing_snapshots` | `request_log_id`, `billing_status`, `model`, `created_at` | +| `compensation_rules` | `enabled` | +| `cliproxy_instances` | `name`, `enabled` | +| `cliproxy_auth_accounts` | `instance_id` | + +另有复合 UNIQUE 索引(同时充当复合查询索引): + +| 表 | 复合唯一键 | +| ------------------------ | -------------------------------------------------------------------- | +| `upstream_probe_results` | `(upstream_id, route_capability, client_profile, probe_template_id)` | +| `api_key_upstreams` | `(api_key_id, upstream_id)` | +| `billing_model_prices` | `(model, source)` | +| `billing_tier_rules` | `(model, source, threshold_input_tokens)` | +| `cliproxy_auth_accounts` | `(instance_id, auth_file_name)` | + +## 关系定义 + +Drizzle 的 `relations()` 没有放在独立文件,而是直接写在 `schema-pg.ts:609-782` 的尾部。每张表的关联关系(含一对多 / 多对一 / 多对多)都声明在那里,可以直接在 `db.query.api_keys.findFirst({ with: { upstreams: true } })` 这样的查询里使用。 + +## 迁移目录 + +PostgreSQL 与 SQLite 各自有独立的迁移目录: + +| 目录 | 用途 | 文件数 | +| ----------------- | --------------- | ------------------------------- | +| `drizzle/` | PostgreSQL 迁移 | 当前 40 个 SQL(最高编号 0037) | +| `drizzle-sqlite/` | SQLite 迁移 | 当前 16 个 SQL | + +两套迁移**并不严格一一对应**,因为某些 PG 特定能力(json 类型、`gen_random_uuid()`、`timestamptz`)在 SQLite 上需要不同的表达方式甚至跳过。每次给 `schema-pg.ts` 加字段后,标准流程: + +```bash +# 1. 生成 PG 迁移 +pnpm db:generate + +# 2. 手动同步改 schema-sqlite.ts,并单独生成 SQLite 迁移 +pnpm exec drizzle-kit generate --config=drizzle-sqlite.config.ts +``` + +`drizzle/meta/_journal.json` 记录 PG 迁移的应用顺序。最近五次 PG 迁移示例: + +| idx | tag | 大致改动 | +| --- | ---------------------------- | -------------------------------------------------------------------------------------------------- | +| 33 | `0033_shocking_emma_frost` | 创建 `traffic_recording_settings` 与 `traffic_recordings` | +| 34 | `0034_youthful_sally_floyd` | 创建 `cliproxy_instances` | +| 35 | `0035_powerful_nightcrawler` | 创建 `cliproxy_auth_accounts` | +| 36 | `0036_furry_warhawk` | 把 `cliproxy_auth_accounts.provider` / `status` 列拓宽为 `text` | +| 37 | `0037_familiar_nico_minoru` | 给 `upstreams` 加 `cliproxy_instance_id` / `cliproxy_auth_file_name` / `cliproxy_provider` 列 + FK | + +## 客户端单例与连接池 + +源码:`src/lib/db/index.ts`。 + +`db` 通过懒加载 Proxy 暴露给业务代码(`index.ts:82`),第一次访问时按 `config.dbType` 选择底层驱动: + +- **PostgreSQL(生产)**:用 `postgres` 库(postgres.js)建立连接池 + - `max: 10`(最多 10 个连接) + - `idle_timeout: 20` 秒 + - `connect_timeout: 10` 秒 + - 源码 `index.ts:38-43` +- **SQLite(开发)**:动态 `require('@libsql/client')`(`index.ts:59-63`),按 `SQLITE_DB_PATH` 指向本地文件 + +`db` 是单例,跨整个 Node.js 进程共享。导出还包括 `closeDatabase()`(`index.ts:92`)用于优雅停机时关闭连接池,主要在测试 setup / teardown 里调用。 -主要表的字段、关系图、`drizzle/`(PG)与 `drizzle-sqlite/` 双 schema 的差异维护方式。 +## 类型导出 -## 在正文就绪前的临时建议 +每张表都同时导出 `$inferSelect`(读类型)和 `$inferInsert`(写类型),命名约定 `Xxx` / `NewXxx`: -在该文档正文上线之前,可以参考以下材料获取等价信息: +```ts +export type ApiKey = typeof apiKeys.$inferSelect; +export type NewApiKey = typeof apiKeys.$inferInsert; +``` -- 项目仓库根目录的 [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) +类型定义在 `schema-pg.ts:784-821`,barrel 在 `schema.ts:41-78` 重新命名导出,业务代码统一从 `@/lib/db` 导入。`src/types/api.ts` 用这些基础类型组合出 API 请求 / 响应 DTO,所有 admin 路由(`src/app/api/admin/**`)和服务层都消费它们。 diff --git a/docs/guide/architecture/failover-circuit.md b/docs/guide/architecture/failover-circuit.md index 7c8d78c4..772794ad 100644 --- a/docs/guide/architecture/failover-circuit.md +++ b/docs/guide/architecture/failover-circuit.md @@ -5,19 +5,186 @@ outline: deep # 失败转移与熔断 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +AutoRouter 把「上游会失败」当作常态。一次客户端请求可能触发多次转发,前一次失败的上游会被排除、下一次从剩余候选里重新挑;连续多次失败的上游会被熔断器隔离,避免持续把流量打到一个已知坏掉的节点。这一页拆开两个机制:单次请求内的故障转移循环,以及跨请求保持状态的熔断器。 + +所有引用都指向 `master` 分支源码。上游候选池如何被构建参见 [上游模型](./upstream-model);这里只关注「选中之后失败该怎么办」。熔断器配置在管理后台的操作面板见 [使用 / 熔断器配置](../usage/circuit-breaker-config)。 + +## 熔断器状态机 + +源码:`src/lib/services/circuit-breaker.ts`。三态枚举的字符串值是 `closed` / `open` / `half_open`,每个上游一行 state,持久化在数据库 `circuit_breaker_states` 表(schema 见 [数据库 schema](./database-schema))。 + +### 状态转换表 + +| 当前状态 | 事件 | 新状态 | 触发条件 | 源码行 | +| --------- | ----------------------------------- | ------------ | ------------------------------------- | ------------------------------------- | +| CLOSED | `recordFailure` | OPEN | `failureCount + 1 ≥ failureThreshold` | `circuit-breaker.ts:249-263` | +| CLOSED | `recordFailure` | CLOSED | 未达阈值,仅累加计数 | `circuit-breaker.ts:277-287` | +| OPEN | 下一次请求到来检查 `canRequestPass` | HALF_OPEN | `now - opened_at ≥ openDuration` | `circuit-breaker.ts:118-120, 177-179` | +| OPEN | 下一次请求到来检查 `canRequestPass` | OPEN(拒绝) | 未到 `openDuration` | 同上 | +| HALF_OPEN | `recordSuccess` | CLOSED | `successCount + 1 ≥ successThreshold` | `circuit-breaker.ts:215-225` | +| HALF_OPEN | `recordSuccess` | HALF_OPEN | 未达阈值,仅累加成功计数 | `circuit-breaker.ts:226-235` | +| HALF_OPEN | `recordFailure` | OPEN | 任何一次失败即回滚 | `circuit-breaker.ts:264-276` | + +::: tip OPEN → HALF_OPEN 是惰性的 +没有任何定时器主动把状态翻成 HALF_OPEN。OPEN 状态的过期检查只在「下一次有真实请求到来、需要选这个上游」时由 `acquireCircuitBreakerPermit` 触发(`circuit-breaker.ts:106-124`)。这意味着:若一个 OPEN 上游迟迟没有流量打到它,它会一直保持 OPEN,直到某次请求把它选回候选池,才有机会被翻成 HALF_OPEN 做探测。 +::: + +### 默认阈值 + +源码:`src/lib/circuit-breaker-defaults.ts:10-17`。 + +| 参数 | 默认值 | 含义 | +| ------------------- | ---------- | ------------------------------- | +| `failureThreshold` | 5 | CLOSED → OPEN 所需失败次数 | +| `successThreshold` | 2 | HALF_OPEN → CLOSED 所需成功次数 | +| `openDuration` | 300 000 ms | OPEN 状态持续时间(5 分钟) | +| `probeInterval` | 30 000 ms | HALF_OPEN 探测最小间隔(30 秒) | +| `firstByteTimeout` | 30 000 ms | 上游响应首字节超时 | +| `streamIdleTimeout` | 60 000 ms | 流式响应空闲超时 | + +每个上游可以通过 `circuit_breaker_states.config` JSON 列覆盖以上任意字段(`schema-pg.ts:236-243`),未覆盖项继续走默认。`canRequestPass` 与 `acquireCircuitBreakerPermit` 读出的 `effectiveConfig` 始终是「上游覆盖 ∪ 默认值」的合集。 + +### recordFailure / recordSuccess + +`recordFailure(upstreamId, _errorType?)`(`circuit-breaker.ts:243`)的逻辑: + +- CLOSED 且累加后达到阈值 → 写 `state=open, openedAt=now` +- HALF_OPEN → 任意失败回到 OPEN,`successCount` 清零 +- 其他情况 → 只 `failureCount += 1` + +`recordSuccess(upstreamId)`(`circuit-breaker.ts:208`)只在 HALF_OPEN 状态下生效: + +- 累加后达到阈值 → 写 `state=closed, failureCount=0, successCount=0` +- 否则 → 只 `successCount += 1` +- CLOSED 状态下不做任何写入,避免无效写(`circuit-breaker.ts:237-238` 注释明确) + +::: warning 没有独立的决策日志表 +两个函数都只更新 `circuit_breaker_states` 一张表,不会单独写决策日志。每次失败的证据是写到 `request_logs.failover_history` 这个 JSON 列里(见后文)。 ::: -## 计划覆盖的内容 +## 单次请求内的故障转移循环 + +入口函数 `forwardWithFailover`,源码 `src/app/api/proxy/v1/[...path]/route.ts:1289-1753`。签名: + +```ts +// route.ts:1289-1313(节选) +async function forwardWithFailover( + request, + routeCapability, + path, + requestId, + candidateUpstreamIds: string[], + requestModel, + affinityContext, + compensationHeaders, + onQueueStateChange?, + config: FailoverConfig = DEFAULT_FAILOVER_CONFIG +); +``` + +默认配置在 `src/lib/services/failover-config.ts:44-48`: + +```ts +export const DEFAULT_FAILOVER_CONFIG: FailoverConfig = { + strategy: "exhaust_all", // 耗尽所有候选;另一个选项是 "max_attempts" + maxAttempts: 10, // 仅 max_attempts 策略下生效 + excludeStatusCodes: [], // 不豁免任何状态码,全部非 2xx 都算失败 +}; +``` + +主循环每一轮做三件事: + +1. 调用 `selectFromUpstreamCandidates(candidateUpstreamIds, failedUpstreamIds, affinityContext)`,把已经失败的上游排除(`route.ts:1371` 维护 `failedUpstreamIds` 数组); +2. 调用 `forwardRequest(...)` 实际转发; +3. 根据结果决定下一步: + - 成功 → `markHealthy` + `recordSuccess` + 返回响应 + - 可故障转移失败 → `markUnhealthy` + `recordFailure`(除非命中 FailureRule)+ 把当前上游加入 `failedUpstreamIds` + 进入下一轮 + - 不可故障转移失败 → 直接把这个错误返回给客户端 + +### 哪些错误算「可故障转移」 + +代理层把两类错误判定为可故障转移: + +**异常类(`isFailoverableError`,`route.ts:842-863`)**: -熔断状态机、自动 failover 决策、健康检查后台任务、决策日志。 +- `CircuitBreakerOpenError` +- `FirstByteTimeoutError` / `StreamIdleTimeoutError` +- 错误消息包含 `timed out` / `timeout` / `econnrefused` / `econnreset` / `socket hang up` / `network` / `fetch failed` / `circuit breaker` + +**HTTP 响应类(`shouldTriggerFailover`,`failover-config.ts:57-73`)**: + +- 状态码非 2xx 且不在 `excludeStatusCodes` 中 + +默认 `excludeStatusCodes` 为空数组,意味着**所有 4xx(包括 401 / 403 / 404 / 429)都会触发故障转移**。`getErrorType()` 会区分 `http_429` 和通用 `http_4xx`(`route.ts:828-829`),但并不影响是否触发转移。如果不希望客户端的 401 把所有上游试一遍,需要在 `FailoverConfig.excludeStatusCodes` 里配置 `[401, 403]` 等。 + +### 失败是否记入熔断器:FailureRule + +`upstream_failure_rules` 表(`schema-pg.ts:257-272`,详见 [数据库 schema](./database-schema))允许声明「某些失败不应该让熔断器升温」。规则可以是全局(`upstream_id IS NULL`)也可以是上游局部。匹配字段: + +| 字段 | 含义 | +| ------------------------------ | ------------------------------------------ | +| `statusCodes` | HTTP 状态码列表 | +| `errorTypes` | 错误类型字符串(如 `stream_idle_timeout`) | +| `bodyPattern` | 响应体正则 | +| `headerName` + `headerPattern` | 响应头名 + 值正则 | + +源码 `src/lib/services/upstream-failure-rules.ts:8-14`。当 `matchFailureRule()` 命中一条规则时,本次失败仍然会触发故障转移,但 `circuitBreakerRecorded = false`(`route.ts:1549-1556, 1707-1710`),不写入 `circuit_breaker_states.failure_count`。 + +典型用法:上游对应 OAuth 受控的 CLIProxyAPI auth-file,正常会偶发 401 触发后台 refresh,不希望把上游打到熔断;可以加一条 `statusCodes: [401], bodyPattern: "token expired"` 的规则。上游层 `upstreams.failure_rule_config.useGlobalRules`(默认 `true`)控制是否同时参与全局规则匹配(`upstream-failure-rules.ts:318-326`)。 + +### 并发已满与队列等待 + +当 `selectFromUpstreamCandidates` 抛出 `AllCandidatesConcurrencyFullError` 并携带 `waitableCandidate` 时,主循环不会立即返回失败,而是调用 `resumeQueuedUpstreamSelection`(`route.ts:1403-1463`),内部通过 `upstreamQueueAdmission` 等待该上游的并发槽位释放。等待时长由 `upstream.queue_policy` 控制,超时会抛 `UpstreamQueueWaitTimeoutError`,此时不再尝试其他上游,直接返回 503 / 504。 + +### 故障转移决策日志 + +每次请求结束时会更新 `request_logs` 表(`schema-pg.ts:279-342`),与故障转移相关的列: + +| 列 | 含义 | +| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `failover_attempts` | 总尝试次数(含第一次) | +| `failover_history` | `FailoverAttempt[]` 的 JSON 序列化:每条含 `upstream_id`、`upstream_name`、`error_type`、`error_message`、`status_code`、`response_headers`、`response_body_text`、`response_body_json`、`attempted_at`、`circuit_breaker_recorded`、`matched_failure_rule` | +| `routing_decision` | `RoutingDecisionLog`:含 `selected_upstream_id`、`actual_upstream_id`、`candidates[]`(每条含 `circuit_state`)、`excluded[]`、`failure_stage`、`final_selection_reason` | +| `upstream_id` | 最终成功的上游 ID;全部失败时为 `null` | + +这是排查「某次客户端请求为什么用了 8 秒、试了 4 个上游」的唯一可靠数据源。前端日志详情页和 `/api/admin/logs/[id]` 都会解析这两个字段。日志读写细节见 [使用 / 请求日志与统计](../usage/logs-stats)。 + +## 健康检查与后台任务 + +`src/lib/services/health-checker.ts` 提供 `checkUpstreamHealth(upstreamId)`、`probeUpstream(upstreamId)`、`markHealthy`、`markUnhealthy` 四个函数。前两个用于主动探测,后两个由代理层在请求成功 / 失败时被动调用。 + +但要注意:**当前项目没有定时器在后台自动探测熔断器**。`src/lib/services/background-sync-registry.ts` 注册的后台任务只有三个: + +| 后台任务 | 用途 | +| ---------------------------------------------- | ---------------- | +| `createBillingPriceCatalogSyncTaskDefinition` | 同步模型价格目录 | +| `createUpstreamModelCatalogSyncTaskDefinition` | 同步上游模型列表 | +| `createTrafficRecordingCleanupTaskDefinition` | 清理过期录制文件 | + +`probeUpstream()` 是 Admin API 专用的手动探测入口,结果写入 `upstream_health` 表,不会更新熔断器状态。换句话说:要把一个 OPEN 上游放回 CLOSED,要么等真实流量打到它触发 HALF_OPEN,要么使用下文的强制操作。 + +## Admin 强制控制 + +源码:`src/app/api/admin/circuit-breakers/`。 + +| 端点 | 行为 | +| --------------------------------------------------- | ---------------------------------------------------------------- | +| `GET /api/admin/circuit-breakers` | 分页列出所有上游熔断器状态,支持 `?state=` 过滤(`route.ts:80`) | +| `GET /api/admin/circuit-breakers/[id]` | 查询单个上游(`[id]/route.ts:18`) | +| `POST /api/admin/circuit-breakers/[id]/force-open` | 调 `forceOpen(upstreamId)`(`force-open/route.ts:37`) | +| `POST /api/admin/circuit-breakers/[id]/force-close` | 调 `forceClose(upstreamId)`(`force-close/route.ts:37`) | + +`forceOpen(upstreamId)`(`circuit-breaker.ts:293-304`)写 `state=open, openedAt=now`,**不清零** `failureCount`。`forceClose(upstreamId)`(`circuit-breaker.ts:309-320`)写 `state=closed, failureCount=0, successCount=0`,等价于「恢复出厂」。 + +::: warning 强制操作不写审计日志 +两个端点只验证 `Authorization: Bearer `,操作本身不写任何审计记录,也不记录是谁触发的。如果需要可追溯的强制操作,建议在 Nginx / 反向代理层加访问日志,并约束 `ADMIN_TOKEN` 的分发范围。 +::: -## 在正文就绪前的临时建议 +## 推荐排查流程 -在该文档正文上线之前,可以参考以下材料获取等价信息: +某个上游被频繁熔断时,按以下顺序排查: -- 项目仓库根目录的 [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) +1. 在管理后台「请求日志」筛该上游近 1 小时的失败请求,查看 `failover_history[*].error_type` 的分布——是网络层(`timeout` / `econnrefused`)还是协议层(`http_4xx` / `http_5xx`)。 +2. 查 `request_logs.failover_history[*].matched_failure_rule`,确认是否有 `circuit_breaker_recorded: false` 的失败(说明 FailureRule 在工作,熔断不是这些失败导致的)。 +3. 在「熔断器」面板查该上游的 `failureCount` 累积速度。若每分钟超过 `failureThreshold`(默认 5)次,结合上一步定位最常见错误,要么修上游、要么加一条 FailureRule 屏蔽不该计入的失败、要么调大 `failureThreshold`。 +4. 临时排障期间,用 `force-open` 把上游隔离,避免新流量继续打过去;问题排清后用 `force-close` 立刻恢复,不必等 5 分钟 `openDuration`。 diff --git a/docs/guide/architecture/upstream-model.md b/docs/guide/architecture/upstream-model.md index 89e63687..7c8d4e6e 100644 --- a/docs/guide/architecture/upstream-model.md +++ b/docs/guide/architecture/upstream-model.md @@ -5,19 +5,224 @@ outline: deep # 上游模型 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 +「上游」是 AutoRouter 路由层最核心的对象,对应一个真实可调用的 AI provider 连接:一组 `base_url` + 加密后的 API Key + 协议能力声明 + 路由权重 + 计费倍率。一次客户端请求最终会在「所有 active 上游」中按规则筛出一个候选池、按权重抽中一个具体上游、再把请求转发出去。 + +这一页解释「候选池如何构建」和「最终上游如何被选中」,所有引用都指向 `master` 分支的源码行号。与请求生命周期总览的关系参见 [请求生命周期](./request-lifecycle);选中失败后的故障转移与熔断细节参见 [失败转移与熔断](./failover-circuit);表结构与索引详情参见 [数据库 schema](./database-schema)。 + +## 协议能力 RouteCapability + +`RouteCapability` 是 AutoRouter 把「上游能处理什么协议端点」与「客户端请求落在哪个路径」对齐的字符串枚举。源码定义在 `src/lib/route-capabilities.ts:1-10`: + +| 枚举值 | 含义 | +| ----------------------------- | --------------------------------------------------------------------- | +| `anthropic_messages` | Anthropic Messages API(`POST /v1/messages`) | +| `claude_code_messages` | Anthropic Messages + Claude Code 客户端 profile | +| `openai_responses` | OpenAI Responses API(`POST /v1/responses`) | +| `codex_cli_responses` | OpenAI Responses + Codex CLI 客户端 profile | +| `openai_chat_compatible` | OpenAI Chat Completions / `GET /v1/models` | +| `openai_extended` | OpenAI 扩展端点(completions / embeddings / images / moderations 等) | +| `gemini_native_generate` | Google Gemini Native(`/v1beta/models/{m}:generateContent`) | +| `gemini_code_assist_internal` | Google Gemini Code Assist Internal | + +枚举值按 provider 归组(`route-capabilities.ts:93-102`): + +- `anthropic` 组:`anthropic_messages`, `claude_code_messages` +- `openai` 组:`openai_responses`, `codex_cli_responses`, `openai_chat_compatible`, `openai_extended` +- `google` 组:`gemini_native_generate`, `gemini_code_assist_internal` + +历史值 `codex_responses` 会被 `normalizeRouteCapabilities` 自动重映射为 `openai_responses`(`route-capabilities.ts:17-21`),数据库里旧记录在 `listUpstreams` 时会通过 `ensureRouteCapabilityMigration()` 完成一次性迁移(`src/lib/services/upstream-crud.ts:697`)。 + +### CLI profile 与降级 + +两个 CLI 后缀枚举(`codex_cli_responses`、`claude_code_messages`)是「专门匹配 CLI 客户端的窄能力」。请求路径匹配器在识别到 Codex 或 Claude Code 客户端时(通过 UA、`x-codex-*`、`anthropic-beta: claude-code-*` 等 header)才会落到这两个值上。若没有任何上游声明 CLI 能力,`getFallbackRouteCapability` 会把请求降级到对应的通用能力(`route-capabilities.ts:198`): + +- `codex_cli_responses` → `openai_responses` +- `claude_code_messages` → `anthropic_messages` + +降级行为在 `src/app/api/proxy/v1/[...path]/route.ts:2663` 通过双候选池实现:先按 CLI 能力构建主池,再按 fallback 能力构建副池,由 `shouldPreferGenericFallbackPool` 决定使用哪个池。 + +## upstreams 表关键字段 + +完整列定义见 `src/lib/db/schema-pg.ts:74-128`。按用途分组介绍最常被路由层读取的字段: + +### 路由能力与白名单 + +| 字段 | 类型 | 作用 | +| -------------------- | ------------------------------ | ----------------------------------------------------------- | +| `route_capabilities` | `json (string[])` | 该上游能处理哪些 `RouteCapability`,运行期会被规范化 | +| `allowed_models` | `json (string[])` | 模型名白名单。`null` 或空数组 = 不限制 | +| `model_redirects` | `json (Record)` | 把客户端请求里的 `model` 改写为另一个值再做白名单匹配与转发 | +| `is_active` | `boolean` | `false` 时整个上游不参与任何路由(管理后台「禁用」) | + +`model_redirects` 在 model-router 与请求转发两个阶段都会被应用:先按它解析 model 名再过白名单(避免别名旁路),转发时也会把 body 里的 `model` 字段改写成解析后的值。映射链限制 10 跳防循环(`src/lib/services/model-router.ts:355-381`)。 + +### 调度参数 + +| 字段 | 类型 | 默认 | 作用 | +| -------------------- | --------- | ------ | ----------------------------------------------------- | +| `priority` | `integer` | `0` | 值越小优先级越高,相同 `priority` 的上游归为同一 tier | +| `weight` | `integer` | `1` | tier 内加权随机的权重基数 | +| `max_concurrency` | `integer` | `null` | 单上游最大并发,`null` = 不限 | +| `queue_policy` | `json` | `null` | 并发已满时是否允许排队等待,等待时长与队列容量 | +| `affinity_migration` | `json` | `null` | session affinity 命中后是否允许迁移到更高优先级的上游 | + +### 转发与加密 + +| 字段 | 类型 | 作用 | +| ------------------- | --------- | ------------------------------------------- | +| `base_url` | `text` | 转发目标地址 | +| `api_key_encrypted` | `text` | Fernet 对称加密后的上游 API Key,明文不落盘 | +| `timeout` | `integer` | 单位**秒**(不是毫秒),默认 60 | +| `config` | `text` | 自定义 header 等扩展配置,JSON 字符串 | + +API Key 的加解密统一通过 `src/lib/utils/encryption.ts` 提供的 `encrypt` / `decrypt`,`createUpstream` 写入时加密、转发前 `getDecryptedApiKey(upstream)` 临时解密(`upstream-crud.ts:432, 950`)。响应给前端的 DTO 用 `maskApiKey` 脱敏,格式 `sk-***1234`(`upstream-crud.ts:262`)。 + +### CLIProxyAPI 关联字段 + +| 字段 | 类型 | 作用 | +| ------------------------- | ------------- | --------------------------------------------------- | +| `cliproxy_instance_id` | `uuid` | 外键指向 `cliproxy_instances.id`,删除实例时设 NULL | +| `cliproxy_auth_file_name` | `text` | 该上游绑定的 CLIProxyAPI auth-file 名 | +| `cliproxy_provider` | `varchar(32)` | 该 auth-file 对应的 OAuth provider 标识 | + +详细集成机制见 [CLIProxyAPI 集成位置](./cliproxy-integration)。 + +### 计费倍率 + +`billing_input_multiplier` / `billing_output_multiplier`(默认 1.0)会乘到该上游所有请求的 token 单价上,用于「同一模型在不同上游有不同折扣」的场景。`spending_rules` 是限额规则数组,结构与 `api_keys.spending_rules` 一致,详见 [使用 / 请求日志与统计](../usage/logs-stats)。 + +::: tip 表里没有 `provider` 列 +路由层判断 provider 的依据是 `route_capabilities`,不是某个独立列。`getPrimaryProviderByCapabilities()`(`route-capabilities.ts:93`)按能力前缀映射出 `anthropic` / `openai` / `google`。 ::: -## 计划覆盖的内容 +## model-router 选上游:第一阶段(按模型前缀) + +入口函数 `routeByModel(model)` 位于 `src/lib/services/model-router.ts:306`,五步流程: + +### 步骤 1:从 model 名推断 provider type + +`getProviderTypeForModel(model)` 把 model 名 lowercase 后匹配前缀(`model-router.ts:20-24`): + +| 模型前缀 | provider type | +| --------- | ------------- | +| `claude-` | `anthropic` | +| `gpt-` | `openai` | +| `gemini-` | `google` | + +无匹配的 model(例如 `qwen-max`)返回 `routingType: "none"`,表示「不按模型路由」,由路径匹配器(见 [请求生命周期](./request-lifecycle))的 `RouteCapability` 直接决定候选池。 + +### 步骤 2:按 provider type 过滤 active 上游 + +第 335 行根据上一步结果,调用 `getPrimaryProviderByCapabilities(upstream.routeCapabilities)` 推算每个 `is_active=true` 上游所属 provider,留下匹配项作为候选。 -`upstreams` 表的字段含义、`route_capabilities` 枚举、状态机、与熔断器的交互。 +### 步骤 3:剔除熔断 OPEN 中的上游 + +`filterUpstreamsByCircuitBreaker`(`model-router.ts:345`)排除状态为 `OPEN` 且尚未超过 `openDuration` 的上游,剔除原因记为 `"circuit_open"`。OPEN 超时后会被允许通过,作为 HALF_OPEN 探测请求。 + +### 步骤 4:白名单 + 别名解析 + +第 355-381 行对每个剩余上游: + +1. 用 `resolveModelWithRedirects(model, upstream.modelRedirects)` 解析 model 名(最多 10 跳,循环检测); +2. 若 `allowedModels` 非空,检查解析后的 model 是否在白名单里; +3. 第一个通过的上游被记为 `selectedUpstream`。 + +### 步骤 5:回退兜底 + +如果没有任何上游通过白名单,但确实存在健康上游,第 389 行会忽略 `allowedModels` 取第一个健康上游作 fallback。这一行为是为了避免「客户端用了一个生僻 model 名 → 全部上游拒收 → 直接 500」的可用性问题,但代价是白名单失效。 + +### 错误类型 + +| 错误类 | 含义 | +| ------------------------ | ----------------------------------------------------- | +| `NoUpstreamGroupError` | provider type 有效,但没有任何上游声明对应 capability | +| `NoHealthyUpstreamError` | 有候选上游,但全部被熔断器过滤掉 | + +错误类定义在 `model-router.ts:72, 82`。具体 HTTP 状态码与客户端错误码映射见 [使用 / 故障排查手册](../usage/troubleshooting)。 + +## load-balancer 选上游:第二阶段(按 tier + 加权) + +`routeByModel` 完成「按 model 选 provider type」之后,候选 ID 列表传给 `selectFromUpstreamCandidates`(`src/lib/services/load-balancer.ts:675`),由它执行 tier 过滤、加权抽样、session affinity。 + +### 候选池过滤顺序 + +核心函数 `performTieredSelection`(`load-balancer.ts:983`)按以下顺序过滤: + +``` +allowedUpstreamIds → 按 API Key 授权过滤 + ↓ +priority 升序分 tier + ↓ +对每个 tier 依次: + filterByCircuitBreaker → 排除 OPEN 且未到 openDuration 的上游 + filterBySpendingQuota → 排除已超限额的上游 + filterByExclusions → 排除上一次请求里失败的上游(excludeIds) + filterByConcurrencyCapacity → 排除并发已满的上游 + ↓ +通过的上游 → selectWeightedWithHealthScore(加权抽样) +``` + +只有当当前 tier 一个候选都不剩时,才进入下一个 tier(`load-balancer.ts:989-999`)。 + +### 加权抽样 + +`selectWeightedWithHealthScore`(`load-balancer.ts:485`)按以下公式给每个上游算 `effectiveWeight`: + +``` +score = 1.0 - min(latencyMs / 500, 0.5) // 至少 0.1 +effectiveWeight = upstream.weight * score +``` + +最近一次记录的 `latency_ms`(来自 `upstream_health` 表)越大、分越低。但要注意:当前 `markHealthy` 调用点写入的 latency 固定为 `100`(`src/lib/services/health-checker.ts` + `route.ts:1595, 2066`),不是实测值。因此 `score` 在当前实现里基本恒为 1.0,加权采样近似等价于按 `upstream.weight` 加权随机。 + +加权抽样完成后输出的 `selectedUpstream` 即为本次实际转发目标。 + +### Session affinity 与迁移 + +`selectFromUpstreamPool`(`load-balancer.ts:795-939`)会先按 `(apiKeyId, routeCapability, sessionId)` 查 session 缓存: + +- 命中且目标可用 → 直接返回该上游,标记 `affinityHit: true` +- 命中但当前优先级更高的上游可用 → 按 `upstream.affinityMigration.metric`(`tokens` 或 `length`)累计、与 `threshold` 比较,达到阈值才允许迁移,标记 `affinityMigrated: true` + +### 路由层错误 + +| 错误类 | 触发条件 | +| ----------------------------------- | ----------------------------------------------------- | +| `NoAuthorizedUpstreamsError` | API Key 授权集合与候选集合无交集 | +| `NoHealthyUpstreamsError` | 所有 tier 全部过滤后仍为空 | +| `AllCandidatesConcurrencyFullError` | 候选池存在但全部 `concurrency_full`,可能携带等待句柄 | + +错误类定义在 `load-balancer.ts:29, 39, 49`。`AllCandidatesConcurrencyFullError` 携带的 `waitableCandidate` 会被代理入口拿去做队列等待(`route.ts:1403-1463`),等待超时则抛 `UpstreamQueueWaitTimeoutError` 转 504,详见 [失败转移与熔断](./failover-circuit)。 + +## 健康状态与路由的关系 + +`upstream_health` 表(`schema-pg.ts:133-152`)记录 `is_healthy`、`latency_ms`、`failure_count`、`error_message` 等。代码里有两处「健康写入」入口: + +| 写入函数 | 触发点 | +| ------------------------------------ | ------------------------------------------------------------------------------------------------- | +| `markHealthy(upstreamId, latencyMs)` | 请求成功(`route.ts:1595` 非流式;`route.ts:2066` 流式完成) | +| `markUnhealthy(upstreamId, reason)` | HTTP 非 2xx(`route.ts:1553`)、网络/超时错误(`route.ts:1716`)、流式中途错误(`route.ts:2097`) | + +::: warning is_healthy 不直接参与路由 +`load-balancer.ts:1033` 的 `filterByExclusions` 注释明确写着: + +> Filter by exclusion list (health status is display-only, not used for routing) + +实际「能不能被选中」由熔断器状态决定,`upstream_health.is_healthy` 字段只用于管理后台的可视化展示。这意味着:手动把某个上游的 `is_healthy` 改成 `false` 不会让它从候选池里消失;要禁用必须改 `is_active` 或让熔断器进入 OPEN。 +::: -## 在正文就绪前的临时建议 +## 调用链一览 -在该文档正文上线之前,可以参考以下材料获取等价信息: +| 入口 | 行号 | 作用 | +| ------------------------------------------------------------------ | --------- | --------------------------------- | +| `src/app/api/proxy/v1/[...path]/route.ts` `handleProxy` | 2434 | 代理主流程容器 | +| ↳ `resolveRouteCapability(method, path, headers)` | 2498 | 路径 → RouteCapability | +| ↳ `resolveRouteCapabilityCandidatePool` | 2657 | 按主能力构建候选池 | +| ↳ `getFallbackRouteCapability` + 副候选池 | 2663-2672 | CLI 能力降级路径 | +| ↳ `forwardWithFailover(... candidateUpstreamIds ...)` | 1289 | 故障转移主循环 | +| `src/lib/services/model-router.ts` `routeByModel(model)` | 306 | 按 model 字段筛 provider 与白名单 | +| `src/lib/services/load-balancer.ts` `selectFromUpstreamCandidates` | 675 | tier 过滤 + 加权抽样 | +| ↳ `performTieredSelection` | 983 | 内部 tier 循环 | +| ↳ `selectWeightedWithHealthScore` | 485 | 加权抽样实现 | -- 项目仓库根目录的 [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) +读源码时按这条链顺着走即可。后续上游被选中后的转发、SSE 处理、失败重试由 [请求生命周期](./request-lifecycle) 和 [失败转移与熔断](./failover-circuit) 接力描述。 From dd65167ed43d75cb2bd2302e221b971f91b75e9c Mon Sep 17 00:00:00 2001 From: umaru Date: Sun, 24 May 2026 12:24:34 +0800 Subject: [PATCH 2/2] docs(architecture): correct routing flow and probe side effects per codex review (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正 codex 审查指出的两处材料性错误: 1. upstream-model.md - 删除「routeByModel 是第一阶段入口」的错误描述。实测全仓库 routeByModel( 零调用点(仅定义本身),生产代理路径采用的是 resolveRouteCapabilityCandidatePool + filterCandidatesByModelRules + resolvePathRoutingModelForUpstream 的三层过滤, 基于请求路径解析出的 RouteCapability 与 model_rules,与模型名前缀无关。 - 整章重写为基于真实运行期函数的五步流程(capability+授权 → model_rules 过滤 → resolvedModel 解析 → 候选 ID 交给 forwardWithFailover)。 - 修正 model_redirects 描述:仅用于 filtering / logging / billing 三件事, 不会改写转发 body。proxy-client.ts:896, 1095-1098 显示 applyModelOverride 只在 modelOverride 非空时触发,而 modelOverride 仅 CLIProxyAPI 上游路径 构造(route.ts:1513-1525, 1534),普通上游 body 原样透传。 - 补充 UpstreamModelRule 类型定义与 normalizeUpstreamModelRules 合并行为: model_rules 非空时为唯一规则源,否则降级把 allowed_models / model_redirects 转成 exact / alias 规则使用。 - 调用链表替换 routeByModel 行为新的实际函数列表。 2. failover-circuit.md - 修正 probeUpstream 描述。源码 health-checker.ts:546 显示 probeUpstream 仅调用 testUpstreamConnection 返回 boolean,零 DB 写,且全仓库 probeUpstream( 零调用点(dead export)。实际写 upstream_health 表的只有 checkUpstreamHealth (health-checker.ts:310 → updateHealthStatus)。改写为正确归因,并保留 「主动探测不改变熔断器状态」的结论。 --- docs/guide/architecture/failover-circuit.md | 4 +- docs/guide/architecture/upstream-model.md | 167 ++++++++++++++------ 2 files changed, 123 insertions(+), 48 deletions(-) diff --git a/docs/guide/architecture/failover-circuit.md b/docs/guide/architecture/failover-circuit.md index 772794ad..ebd12aa8 100644 --- a/docs/guide/architecture/failover-circuit.md +++ b/docs/guide/architecture/failover-circuit.md @@ -161,7 +161,9 @@ export const DEFAULT_FAILOVER_CONFIG: FailoverConfig = { | `createUpstreamModelCatalogSyncTaskDefinition` | 同步上游模型列表 | | `createTrafficRecordingCleanupTaskDefinition` | 清理过期录制文件 | -`probeUpstream()` 是 Admin API 专用的手动探测入口,结果写入 `upstream_health` 表,不会更新熔断器状态。换句话说:要把一个 OPEN 上游放回 CLOSED,要么等真实流量打到它触发 HALF_OPEN,要么使用下文的强制操作。 +这四个函数里,**真正会写 `upstream_health` 表的只有 `checkUpstreamHealth`**:它在 `health-checker.ts:310` 调用 `updateHealthStatus`,后者执行 `db.update(upstreamHealth)` / `db.insert(upstreamHealth)`(`health-checker.ts:192, 206`)。`probeUpstream`(`health-checker.ts:546`)则只调用 `testUpstreamConnection` 做连通测试并返回 `boolean`,**不写任何表**,且 grep 全仓库 `probeUpstream(` 没有任何调用点(dead export)。 + +任何一种情况下,主动探测都不会改变熔断器状态。要把一个 OPEN 上游放回 CLOSED,要么等真实流量打到它触发 HALF_OPEN,要么使用下文的强制操作。 ## Admin 强制控制 diff --git a/docs/guide/architecture/upstream-model.md b/docs/guide/architecture/upstream-model.md index 7c8d4e6e..14a02e96 100644 --- a/docs/guide/architecture/upstream-model.md +++ b/docs/guide/architecture/upstream-model.md @@ -45,16 +45,39 @@ outline: deep 完整列定义见 `src/lib/db/schema-pg.ts:74-128`。按用途分组介绍最常被路由层读取的字段: -### 路由能力与白名单 +### 路由能力与模型规则 + +| 字段 | 类型 | 作用 | +| -------------------- | ------------------------------ | ------------------------------------------------------------------------- | +| `route_capabilities` | `json (string[])` | 该上游能处理哪些 `RouteCapability`,运行期会被规范化 | +| `model_rules` | `json (UpstreamModelRule[])` | 当前统一的模型匹配规则,详见下文 | +| `allowed_models` | `json (string[])` | **legacy 字段**,仅在 `model_rules` 为空时降级生效(每项当 `exact` 规则) | +| `model_redirects` | `json (Record)` | **legacy 字段**,仅在 `model_rules` 为空时降级生效(每项当 `alias` 规则) | +| `is_active` | `boolean` | `false` 时整个上游不参与任何路由(管理后台「禁用」) | + +`UpstreamModelRule` 的 TypeScript 定义在 `src/lib/services/upstream-model-types.ts:42-48`: + +```ts +interface UpstreamModelRule { + type: "exact" | "regex" | "alias"; + value: string; // exact 名称 / 正则表达式 / alias 源名 + targetModel: string | null; // 仅 alias 类型有值 + source: "manual" | "native" | "inferred" | "litellm"; + displayLabel: string | null; +} +``` + +三种规则类型的匹配语义在 `src/lib/services/upstream-model-rules.ts:326`: + +- `exact`:`rule.value === model` 严格相等 +- `alias`:`rule.value === model` 命中后通过 `resolveAliasTarget` 解析 `targetModel`,支持多层别名链(最深 10 跳,循环检测) +- `regex`:`new RegExp(rule.value).test(model)` 全字段正则匹配 -| 字段 | 类型 | 作用 | -| -------------------- | ------------------------------ | ----------------------------------------------------------- | -| `route_capabilities` | `json (string[])` | 该上游能处理哪些 `RouteCapability`,运行期会被规范化 | -| `allowed_models` | `json (string[])` | 模型名白名单。`null` 或空数组 = 不限制 | -| `model_redirects` | `json (Record)` | 把客户端请求里的 `model` 改写为另一个值再做白名单匹配与转发 | -| `is_active` | `boolean` | `false` 时整个上游不参与任何路由(管理后台「禁用」) | +::: warning model_redirects 与 model_rules 的 alias **不改写转发 body** +两者解析出的「目标模型名」只用于**过滤候选**、**写日志** 和 **计费价格解析** 三件事,**不会**改写客户端请求 body 里的 `model` 字段。`forwardRequest` 把原始 model 原样发给上游(`src/lib/services/proxy-client.ts:896, 1095-1098`),唯一会改写 body 的路径是 CLIProxyAPI 上游:当 `selectedUpstream.cliproxyAuthFileName` 存在时,代理层构造 `cliproxyModelOverride` 传给 `forwardRequest`(`route.ts:1513-1525, 1534`),由 `applyModelOverride` 改写 body。 -`model_redirects` 在 model-router 与请求转发两个阶段都会被应用:先按它解析 model 名再过白名单(避免别名旁路),转发时也会把 body 里的 `model` 字段改写成解析后的值。映射链限制 10 跳防循环(`src/lib/services/model-router.ts:355-381`)。 +这意味着:给一个普通 OpenAI 上游配置 `model_redirects: { "gpt-4o-mini": "gpt-4o" }`,客户端发 `gpt-4o-mini`,候选筛选与日志会按 `gpt-4o` 来,但实际打到上游的 body 里仍是 `gpt-4o-mini`。需要真正的服务端 model 改写时,应当在客户端层面解决,或者走 CLIProxyAPI 集成。 +::: ### 调度参数 @@ -95,54 +118,101 @@ API Key 的加解密统一通过 `src/lib/utils/encryption.ts` 提供的 `encryp 路由层判断 provider 的依据是 `route_capabilities`,不是某个独立列。`getPrimaryProviderByCapabilities()`(`route-capabilities.ts:93`)按能力前缀映射出 `anthropic` / `openai` / `google`。 ::: -## model-router 选上游:第一阶段(按模型前缀) +## 候选池构建:第一阶段(按 RouteCapability + 模型规则) + +候选池的构建发生在 `handleProxy`(`src/app/api/proxy/v1/[...path]/route.ts:2434`)内部,按「能力 → API Key 授权 → 模型规则」三层过滤,最终交给 `selectFromUpstreamCandidates`。 -入口函数 `routeByModel(model)` 位于 `src/lib/services/model-router.ts:306`,五步流程: +::: tip 关于 routeByModel +`src/lib/services/model-router.ts:306` 的 `routeByModel(model)` 实现了一套基于模型名前缀(`claude-` / `gpt-` / `gemini-`)推断 provider type 再过滤候选的算法,但**当前运行期没有任何生产路径调用它**——全仓库 `routeByModel(` 仅匹配定义本身。代理路径采用的是下文描述的 `resolveRouteCapabilityCandidatePool` + `filterCandidatesByModelRules`,按客户端**请求路径**解析出的 `RouteCapability` 与 `model_rules` 进行匹配,与模型名前缀无关。阅读源码时如果落到 `routeByModel` 上,可以视为历史代码。 +::: -### 步骤 1:从 model 名推断 provider type +### 步骤 1:按 RouteCapability + API Key 授权构建候选池 -`getProviderTypeForModel(model)` 把 model 名 lowercase 后匹配前缀(`model-router.ts:20-24`): +`resolveRouteCapabilityCandidatePool`(`route.ts:661`)签名: -| 模型前缀 | provider type | -| --------- | ------------- | -| `claude-` | `anthropic` | -| `gpt-` | `openai` | -| `gemini-` | `google` | +```ts +function resolveRouteCapabilityCandidatePool( + activeUpstreams: Upstream[], + allowedUpstreamIdSet: Set, + requestedCapability: RouteCapability, + candidateCapability: RouteCapability +): RouteCapabilityCandidatePool; +``` + +`activeUpstreams` 是数据库查出的全部 `is_active=true` 上游(`route.ts:2648`);`allowedUpstreamIdSet` 在 `restricted` 模式下取 API Key 绑定的 `api_key_upstreams` 集合,`unrestricted` 模式下取全集(`route.ts:2651-2655`)。 + +过滤逻辑(`route.ts:667-668`): + +```ts +const capabilityCandidates = activeUpstreams.filter((upstream) => + resolveRouteCapabilities(upstream.routeCapabilities).includes(candidateCapability) +); +``` -无匹配的 model(例如 `qwen-max`)返回 `routingType: "none"`,表示「不按模型路由」,由路径匹配器(见 [请求生命周期](./request-lifecycle))的 `RouteCapability` 直接决定候选池。 +随后再用 `allowedUpstreamIdSet` 做授权过滤(`route.ts:670-672`),得到 `authorizedCapabilityCandidates`,并把这一层结果命名输出在 `RouteCapabilityCandidatePool`(`route.ts:653-659`): -### 步骤 2:按 provider type 过滤 active 上游 +- `capabilityCandidates`:能力匹配但不限授权 +- `authorizedCapabilityCandidates`:能力匹配 + API Key 授权 +- `candidateUpstreamIds`:上一层 ID 列表,是后续函数的实际输入 -第 335 行根据上一步结果,调用 `getPrimaryProviderByCapabilities(upstream.routeCapabilities)` 推算每个 `is_active=true` 上游所属 provider,留下匹配项作为候选。 +主候选池在 `route.ts:2657` 构建。如果客户端命中的是 CLI 窄能力(`codex_cli_responses` / `claude_code_messages`),代理还会在 `route.ts:2665` 用 `getFallbackRouteCapability` 解析出的通用能力构建第二个 fallback 池,由 `shouldPreferGenericFallbackPool` 决定使用哪个。 -### 步骤 3:剔除熔断 OPEN 中的上游 +### 步骤 2:按 model_rules 过滤候选 + +`filterCandidatesByModelRules`(`route.ts:591`)以请求 body 里的 `model` 字段为输入: + +```ts +function filterCandidatesByModelRules( + originalModel: string | null, + candidates: Upstream[] +): { allowed: Upstream[]; excluded: RoutingExcluded[] }; +``` + +行为(`route.ts:595-622`): + +- `originalModel` 为 `null`(请求 body 没有 `model` 字段)→ 全部放行,不过滤 +- 否则对每个候选调用 `resolvePathRoutingModelForUpstream(originalModel, candidate)`: + - 命中(`matched: true`)→ 加入 `allowed` + - 未命中且上游有显式规则(`hasExplicitRules: true`)→ 加入 `excluded`,理由 `"model_not_allowed"` + - 未命中且上游没有任何规则(`hasExplicitRules: false`)→ **仍加入 `allowed`**(视为「不限制」) + +这步调用在 `route.ts:2749`,紧跟主候选池构建之后;fallback 池切换时第二次调用在 `route.ts:3062`。 + +### 步骤 3:resolvePathRoutingModelForUpstream 与规则合并 + +每个候选上游被 `filterCandidatesByModelRules` 调用时,最终落到 `resolvePathRoutingModelForUpstream`(`route.ts:557`),它内部调用 `matchUpstreamModelRules` 完成实际匹配,返回: + +```ts +{ + (matched, hasExplicitRules, resolvedModel, redirectApplied); +} +``` -`filterUpstreamsByCircuitBreaker`(`model-router.ts:345`)排除状态为 `OPEN` 且尚未超过 `openDuration` 的上游,剔除原因记为 `"circuit_open"`。OPEN 超时后会被允许通过,作为 HALF_OPEN 探测请求。 +`normalizeUpstreamModelRules`(`upstream-model-rules.ts:189`)是规则合并的统一入口: -### 步骤 4:白名单 + 别名解析 +- `model_rules` 非空 → 逐条规范化为 `exact` / `regex` / `alias` +- `model_rules` 为空 → 降级兼容旧字段:把 `allowed_models` 的每一项转成 `exact` 规则,把 `model_redirects` 的每一项转成 `alias` 规则 -第 355-381 行对每个剩余上游: +匹配按规则数组顺序逐条尝试,第一条命中即生效。命中 `alias` 规则后通过 `resolveAliasTarget` 解析 `targetModel`(多层别名链,最深 10 跳)。 -1. 用 `resolveModelWithRedirects(model, upstream.modelRedirects)` 解析 model 名(最多 10 跳,循环检测); -2. 若 `allowedModels` 非空,检查解析后的 model 是否在白名单里; -3. 第一个通过的上游被记为 `selectedUpstream`。 +### 步骤 4:resolvedModel 的真实用途 -### 步骤 5:回退兜底 +`resolvePathRoutingModelForUpstream` 返回的 `resolvedModel` 在四处被消费(`route.ts:2847, 3080, 3142, 3897`): -如果没有任何上游通过白名单,但确实存在健康上游,第 389 行会忽略 `allowedModels` 取第一个健康上游作 fallback。这一行为是为了避免「客户端用了一个生僻 model 名 → 全部上游拒收 → 直接 500」的可用性问题,但代价是白名单失效。 +1. 决定 API Key 配额检查时用哪个 model 名(计费维度对齐) +2. 写入 `request_logs` 与 `RoutingDecisionLog.resolved_model` +3. `request_billing_snapshots` 计算模型价格时使用 +4. failover 错误路径中以最终归因上游计算 `resolvedModel` 后写入失败日志 -### 错误类型 +如前文「路由能力与模型规则」section 所述,**`resolvedModel` 不参与请求 body 改写**,仅普通上游的 body 里 `model` 字段保持客户端原值。 -| 错误类 | 含义 | -| ------------------------ | ----------------------------------------------------- | -| `NoUpstreamGroupError` | provider type 有效,但没有任何上游声明对应 capability | -| `NoHealthyUpstreamError` | 有候选上游,但全部被熔断器过滤掉 | +### 步骤 5:候选 ID 列表交给 load-balancer -错误类定义在 `model-router.ts:72, 82`。具体 HTTP 状态码与客户端错误码映射见 [使用 / 故障排查手册](../usage/troubleshooting)。 +走到这里得到 `candidateUpstreamIds`(已通过 capability、API Key 授权、model_rules 三重过滤),由 `handleProxy` 在 `route.ts:3039` / `route.ts:3094`(fallback 路径)传给 `forwardWithFailover`,后者在 `route.ts:1380` 调用 `selectFromUpstreamCandidates` 进入第二阶段。 ## load-balancer 选上游:第二阶段(按 tier + 加权) -`routeByModel` 完成「按 model 选 provider type」之后,候选 ID 列表传给 `selectFromUpstreamCandidates`(`src/lib/services/load-balancer.ts:675`),由它执行 tier 过滤、加权抽样、session affinity。 +候选 ID 列表传给 `selectFromUpstreamCandidates`(`src/lib/services/load-balancer.ts:675`),由它执行 tier 过滤、加权抽样、session affinity。 ### 候选池过滤顺序 @@ -213,16 +283,19 @@ effectiveWeight = upstream.weight * score ## 调用链一览 -| 入口 | 行号 | 作用 | -| ------------------------------------------------------------------ | --------- | --------------------------------- | -| `src/app/api/proxy/v1/[...path]/route.ts` `handleProxy` | 2434 | 代理主流程容器 | -| ↳ `resolveRouteCapability(method, path, headers)` | 2498 | 路径 → RouteCapability | -| ↳ `resolveRouteCapabilityCandidatePool` | 2657 | 按主能力构建候选池 | -| ↳ `getFallbackRouteCapability` + 副候选池 | 2663-2672 | CLI 能力降级路径 | -| ↳ `forwardWithFailover(... candidateUpstreamIds ...)` | 1289 | 故障转移主循环 | -| `src/lib/services/model-router.ts` `routeByModel(model)` | 306 | 按 model 字段筛 provider 与白名单 | -| `src/lib/services/load-balancer.ts` `selectFromUpstreamCandidates` | 675 | tier 过滤 + 加权抽样 | -| ↳ `performTieredSelection` | 983 | 内部 tier 循环 | -| ↳ `selectWeightedWithHealthScore` | 485 | 加权抽样实现 | +| 入口 | 行号 | 作用 | +| ------------------------------------------------------------------------------ | --------- | ---------------------------------- | +| `src/app/api/proxy/v1/[...path]/route.ts` `handleProxy` | 2434 | 代理主流程容器 | +| ↳ `resolveRouteCapability(method, path, headers)` | 2498 | 路径 → RouteCapability | +| ↳ `resolveRouteCapabilityCandidatePool` | 2657 | 按主能力 + API Key 授权构建候选池 | +| ↳ `getFallbackRouteCapability` + 副候选池 | 2663-2672 | CLI 能力降级路径 | +| ↳ `filterCandidatesByModelRules` | 2749 | 按 `model_rules` 过滤候选 | +| ↳ `forwardWithFailover(... candidateUpstreamIds ...)` | 3039 | 故障转移主循环 | +| `src/app/api/proxy/v1/[...path]/route.ts` `resolvePathRoutingModelForUpstream` | 557 | 实际匹配规则、产出 `resolvedModel` | +| `src/lib/services/upstream-model-rules.ts` `normalizeUpstreamModelRules` | 189 | model_rules / 旧字段统一规范化 | +| `src/lib/services/upstream-model-rules.ts` `matchUpstreamModelRules` | 326 | 三种规则类型的实际匹配 | +| `src/lib/services/load-balancer.ts` `selectFromUpstreamCandidates` | 675 | tier 过滤 + 加权抽样 | +| ↳ `performTieredSelection` | 983 | 内部 tier 循环 | +| ↳ `selectWeightedWithHealthScore` | 485 | 加权抽样实现 | 读源码时按这条链顺着走即可。后续上游被选中后的转发、SSE 处理、失败重试由 [请求生命周期](./request-lifecycle) 和 [失败转移与熔断](./failover-circuit) 接力描述。