diff --git a/docs/guide/architecture/overview.md b/docs/guide/architecture/overview.md index 4ec44572..a8e3c5ec 100644 --- a/docs/guide/architecture/overview.md +++ b/docs/guide/architecture/overview.md @@ -5,19 +5,229 @@ outline: deep # 整体架构总览 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +AutoRouter 是一个 Next.js 全栈应用:同一个进程同时承担「管理后台 UI」「管理 API」「面向调用方的 AI 代理 API」三类职责,没有独立的后端服务。大部分持久化状态落在一个关系型数据库(默认 PostgreSQL,可选 SQLite);唯一的例外是流量录制功能——`recordTrafficFixture`(`src/lib/services/traffic-recorder.ts:517`)把每条录制的请求 / 响应 fixture 以 JSON 文件形式写到本地磁盘,目录默认为 `data/traffic-recordings`,可由环境变量 `RECORDER_FIXTURES_DIR` 覆盖(`src/lib/services/traffic-recording-service.ts:148`、`:163`)。数据库 `trafficRecordings` 表只保存索引(`fixture_path`、`outcome`、`status_code` 等元数据,`src/lib/db/schema-pg.ts:360`),完整请求 / 响应内容只存在于该磁盘目录内。部署侧规划备份时,若启用了录制,必须同时备份数据库与该磁盘目录,单独备份数据库无法恢复 fixture。除录制以外的所有运行期决策(鉴权、选路、熔断、计费)都基于数据库当前快照做出。本页给出整套系统的分层结构、关键模块以及彼此之间的关系,让阅读者在动手改任何具体功能前先建立全貌。 -## 计划覆盖的内容 +## 进程拓扑 -一张图说明 Next.js fullstack 结构、前后端职责划分、各服务模块的关系。 +最小化的部署只有一个长驻进程: -## 在正文就绪前的临时建议 +``` +┌──────────────────────────────────────────────────────────────────┐ +│ AutoRouter (Next.js standalone) │ +│ │ +│ ┌────────────────────────┐ ┌────────────────────────────┐ │ +│ │ /[locale]/(dashboard) │ │ /api/admin/* (管理 API) │ │ +│ │ 管理后台 React UI │ ─▶ │ 上游/密钥/熔断/日志/计费 │ │ +│ └────────────────────────┘ └────────────────────────────┘ │ +│ │ +│ ┌────────────────────────┐ ┌────────────────────────────┐ │ +│ │ /api/proxy/v1/[...path]│ │ /api/health (无鉴权探针) │ │ +│ │ 面向调用方的代理入口 │ │ │ │ +│ └────────────────────────┘ └────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ PostgreSQL(默认)/ SQLite(可选)│ + │ 唯一持久化层,所有运行期决策依据 │ + └──────────────────────────────────┘ + │ + ▼ + ┌──────────────────────────────────┐ + │ 上游 AI 服务(OpenAI / Anthropic │ + │ / Gemini / 中转 / CLIProxy …) │ + └──────────────────────────────────┘ +``` -在该文档正文上线之前,可以参考以下材料获取等价信息: +构建产物用 Next.js standalone 模式(`next.config.ts:9`),所以正式镜像里没有 dev-server、没有热重载,启动后即就绪。可选的 CLIProxyAPI sidecar 不属于必选拓扑,它的接入方式见 [CLIProxyAPI Sidecar 部署](../deployment/cliproxy-sidecar)。 -- 项目仓库根目录的 [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) +## 目录分层 + +代码组织遵循 Next.js App Router 的常规分层,运行期逻辑集中在 `src/lib/services/`: + +| 路径 | 职责 | +| ----------------------------------------- | --------------------------------------------------------------------------- | +| `src/app/api/proxy/v1/[...path]/route.ts` | 唯一的代理入口,GET/POST/PUT/DELETE/PATCH 都委托给同一个 `handleProxy` 函数 | +| `src/app/api/admin/` | 管理 API:上游、密钥、熔断、日志、统计、计费、流量录制、CLIProxy 等 | +| `src/app/api/health/route.ts` | 公开健康探针,不需要鉴权 | +| `src/app/[locale]/(dashboard)/` | 管理后台页面集合(需要登录) | +| `src/app/[locale]/(auth)/login/` | 登录页(独立布局,不挂 dashboard 框架) | +| `src/lib/services/` | 全部运行期业务逻辑模块 | +| `src/lib/db/` | Drizzle ORM schema 与数据库 client | +| `src/lib/utils/` | 通用工具:配置加载、鉴权 helper、加密、CORS 等 | +| `src/components/` | 管理后台 React 组件(shadcn/ui 基础) | +| `src/hooks/` | TanStack Query 包装的数据获取 hooks | +| `src/i18n/`、`src/messages/` | next-intl 配置与中英文翻译 | + +`src/app/api/proxy/v1/[...path]/route.ts` 在文件末尾把所有 HTTP 方法都导向同一个内部函数(`POST` 位于第 4141 行、`handleProxy` 位于第 2434 行),后文「请求生命周期」会逐步展开它的内部流程。 + +## 服务模块清单 + +`src/lib/services/` 下的所有模块按职责分为以下几组,归类可作为阅读源码时的索引: + +### 代理与转发 + +- `proxy-client.ts`:与上游建立 HTTP 连接、复制 header、转发请求与流。 +- `request-logger.ts`:请求开始时写入「in-progress」日志行,结束时更新;同时承载实时日志推送的数据源。 +- `stats-service.ts`:聚合 `requestLogs` 与 `requestBillingSnapshots` 形成统计面板。 +- `unified-error.ts`:把内部异常统一映射为带 `code` 字段的 JSON 错误响应。 + +### 上游管理 + +- `upstream-crud.ts`:上游的增删改查与字段校验。 +- `upstream-service.ts`:上层入口,重新导出 CRUD 与上游服务的对外 API。 +- `upstream-connection-tester.ts`:「测试连接」按钮的实现。 +- `upstream-ssrf-validator.ts`:阻止上游 base URL 指向内网或回环地址。 +- `upstream-probe-service.ts`、`upstream-quota-tracker.ts`:探活与额度跟踪。 +- `upstream-model-catalog-background-sync.ts`、`upstream-model-discovery.ts`、`upstream-model-rules.ts`、`upstream-model-types.ts`:模型目录的拉取、识别、规则、类型定义。 +- `upstream-failure-rules.ts`、`upstream-queue-admission.ts`:失败触发熔断的判定与请求入队控制。 + +### 路由选路 + +- `model-router.ts`:从请求模型名 + 路由能力筛出候选上游集合。 +- `route-capability-matcher.ts`:把客户端请求路径(与可选的请求头 profile)映射为 `RouteCapability`,例如 `/v1/chat/completions` → `openai_chat_compatible`、`/v1/messages` → `anthropic_messages` 或 `claude_code_messages`。 +- `load-balancer.ts`:在候选集合内做加权随机选择,结合延时分数与熔断状态过滤。 +- `session-affinity.ts`:会话粘性策略,保障同一对话尽量命中同一上游。 +- `failover-config.ts`:失败转移的触发条件配置(哪些状态码、哪些错误类型算可重试)。 +- `route-capability-migration.ts`:早期上游缺失 capability 字段时的回填迁移。 + +### 健康与熔断 + +- `circuit-breaker.ts`:熔断器状态机,三态 `CLOSED` / `OPEN` / `HALF_OPEN`;提供「转发前申请准入」「转发后记录成功 / 失败」「强制开关」三类操作。 +- `health-checker.ts`:后台周期性主动探活,更新 `upstreamHealth` 表。 + +### 客户端密钥与认证 + +- `key-manager.ts`:客户端 Key 的生成、bcrypt 哈希、Fernet 加密原文、揭示与删除。 +- `api-key-quota-tracker.ts`、`spending-rules.ts`:Key 维度的额度与消费规则判定。 + +### 计费与限额 + +- `billing-cost-service.ts`:按请求计算费用并 upsert 到 `requestBillingSnapshots`。 +- `billing-price-service.ts`:模型单价的读写。 +- `billing-price-background-sync.ts`:后台同步定价源。 +- `billing-management-service.ts`:管理 API 的计费业务封装。 +- `compensation-service.ts`:请求头补偿规则(例如某些上游需要追加固定 header)。 + +### 流量录制与回放 + +- `traffic-recorder.ts`:根据 `trafficRecordingSettings` 的运行期开关决定是否把请求 / 响应快照写入磁盘。 +- `traffic-recording-service.ts`:管理 API 对录制配置与历史的封装。 +- `traffic-recording-background-cleanup.ts`:按 retention 策略清理历史录制。 + +### CLIProxy 集成 + +- `cliproxy-instance-crud.ts`、`cliproxy-management-client.ts`:CLIProxy 实例的注册与管控。 +- `cliproxy-auth-account-service.ts`、`cliproxy-oauth-login-service.ts`:CLIProxy 侧 OAuth 账号管理。 +- `cliproxy-connection-tester.ts`、`cliproxy-upstream-preset.ts`:连接测试与预置上游模板。 + +### 后台同步框架 + +- `background-sync.ts`、`background-sync-scheduler.ts`、`background-sync-store.ts`、`background-sync-registry.ts`、`background-sync-types.ts`:把所有后台周期任务(模型目录同步、定价同步等)纳入统一调度。 + +### 实时数据推送 + +- `request-log-live-updates.ts`:管理后台「请求日志」页的实时刷新数据源。 + +## 数据模型 + +`src/lib/db/schema.ts` 是入口,按部署的数据库类型代理到 `schema-pg.ts` 或 `schema-sqlite.ts`,两者字段保持一致。以 PostgreSQL 版本为准,所有表如下: + +| 表名 | 用途 | +| ----------------------------- | -------------------------------------------------------------- | +| `apiKeys` | 客户端访问密钥(bcrypt hash + Fernet 加密原文) | +| `upstreams` | 上游服务配置(base URL、加密的 api_key、能力声明、权重、规则) | +| `upstreamHealth` | 各上游的最新健康快照 | +| `upstreamProbeResults` | 探活结果历史 | +| `apiKeyUpstreams` | 客户端 Key 与上游的 M:N 关联(受限模式可见性) | +| `circuitBreakerStates` | 熔断器状态 | +| `upstreamFailureRules` | 自定义失败触发熔断规则 | +| `requestLogs` | 请求日志完整记录(含 failover 历史) | +| `trafficRecordingSettings` | 流量录制运行期配置单例 | +| `trafficRecordings` | 已录制的流量快照 | +| `billingModelPrices` | 模型单价 | +| `billingManualPriceOverrides` | 单价手动覆盖 | +| `billingTierRules` | 阶梯计费规则 | +| `billingPriceSyncHistory` | 价格同步历史 | +| `requestBillingSnapshots` | 每次请求的费用快照 | +| `backgroundSyncTasks` | 后台同步任务定义 | +| `backgroundSyncTaskRuns` | 后台同步任务运行历史 | +| `compensationRules` | 请求头补偿规则 | +| `cliproxyInstances` | CLIProxy 实例注册 | +| `cliproxyAuthAccounts` | CLIProxy OAuth 账号 | + +需要查 schema 字段细节时,直接读 `src/lib/db/schema-pg.ts`;SQLite 部署模式下读 `schema-sqlite.ts`。Drizzle 的 migration 文件目录在 `drizzle/`,由 `pnpm db:generate` 生成。 + +## 进入与离开应用的边界 + +应用对外暴露三类入口,行为差异如下: + +| 入口 | 鉴权方式 | 用途 | +| -------------------- | ------------------------------ | ------------------ | +| `/api/proxy/v1/*` | 客户端 Key(三种 header 任一) | 转发到上游 AI 服务 | +| `/api/admin/*` | `ADMIN_TOKEN` Bearer | 管理后台数据接口 | +| `/api/health` | 无 | 健康探针 | +| `/[locale]/...` 页面 | 浏览器侧 sessionStorage Token | 管理后台 UI | + +代理入口的全部 HTTP 方法都委托给 `handleProxy`;管理 API 的每个路由独立鉴权;健康探针完全公开。next-intl 中间件位于 `src/proxy.ts`(注意:是 `src/proxy.ts`,不是 Next.js 默认惯用的 `src/middleware.ts`),其 matcher 显式排除 `/_next`、`/api`、带扩展名的资源路径,因此中间件**不会**拦截任何 API 请求,所有 API 鉴权都发生在 route handler 自身内部。 + +## 国际化与路由分组 + +`src/app/[locale]/` 是页面路由的根,`[locale]` 由 next-intl 在中间件层解析。配置如下: + +- `src/i18n/config.ts`:支持的语言列表 `["zh-CN", "en"]`,默认 `"zh-CN"`。 +- `src/i18n/routing.ts`:`localePrefix: "always"`,即所有页面 URL 强制带语言前缀;cookie 名 `NEXT_LOCALE` 用于记住用户选择。 +- `next.config.ts`:通过 `createNextIntlPlugin("./src/i18n/request.ts")` 把 next-intl 挂入 Next.js 构建。 + +`[locale]/` 下用了两个路由组: + +- `(auth)/login/`:登录页,使用独立布局,不挂 dashboard 框架。 +- `(dashboard)/`:所有需要登录的管理页面(仪表盘、上游、密钥、日志、设置、系统等)。 + +中英文翻译文件位于 `src/messages/zh-CN.json` 与 `src/messages/en.json`,组件内通过 `useTranslations("nav")` 之类的 hook 调用。新增页面或菜单时需要在两份翻译文件中同时补齐键名。 + +## 关键依赖与版本 + +正式镜像内固化的关键依赖版本(`package.json` 第 31–64 行): + +| 依赖 | 版本 | 用途 | +| ------------- | ------ | -------------------------- | +| `next` | 16.2.6 | 全栈框架(含 App Router) | +| `drizzle-orm` | 0.45.2 | TypeScript ORM | +| `next-intl` | 4.9.2 | 国际化 | +| `bcryptjs` | 3.0.3 | 客户端 Key hash(成本 12) | +| `zod` | 4.1.13 | 运行期 schema 校验 | +| `pino` | 10.3.0 | 结构化日志 | + +Fernet 加密没有独立 npm 包,实现位于 `src/lib/utils/encryption.ts`(自实现的 Python Fernet 兼容版本,密钥需为 32 字节 base64)。 + +## 配置加载 + +`src/lib/utils/config.ts` 用 Zod schema 加载并校验所有环境变量,导出单例 `config`。关键约束如下: + +- 生产环境必须显式设置 `DATABASE_URL`,否则启动时 fast-fail。 +- `ENCRYPTION_KEY` 必须为 44 字符 base64(解码后 32 字节),可通过 `ENCRYPTION_KEY_FILE` 从挂载文件读入。 +- `ADMIN_TOKEN` 必填,用于管理 API Bearer 鉴权。 +- `CORS_ORIGINS` 是逗号分隔的白名单,默认 `http://localhost:3000`,但当前代码只在 `config.ts` 解析它、没有任何代码读它后输出 `Access-Control-Allow-*` 响应头,因此该字段没有运行期效果(详见 [请求生命周期](./request-lifecycle) 阶段二)。 +- 其他可调字段:`LOG_LEVEL`、`LOG_RETENTION_DAYS`、`HEALTH_CHECK_INTERVAL` 等。 + +各字段的完整含义与默认值见 [`.env` 配置参考](../deployment/env-reference)。 + +## 部署与 CI 入口 + +| 文件 | 用途 | +| --------------------------------------- | ----------------------------------------------------------------------- | +| `Dockerfile` | 多阶段构建,基于 `node:22-alpine`,产出 standalone 镜像 | +| `docker-compose.yml` | 默认部署编排,包含 AutoRouter 与数据库 | +| `docker-compose.cliproxy.yml` | 可叠加文件,附加 CLIProxyAPI sidecar | +| `.github/workflows/release.yml` | Tag `v*` 触发,构建并推送镜像到 `ghcr.io/g1331/autorouter` | +| `.github/workflows/verify.yml` | `src/**` 或 `tests/**` 变更时跑测试与校验 | +| `.github/workflows/deploy-personal.yml` | `workflow_dispatch` 手动触发,按指定镜像 tag 通过 SSH 部署到个人服务器 | +| `.github/workflows/docs.yml` | `master` 上文档相关路径变更时,构建并发布 VitePress 站点到 GitHub Pages | + +## 接下来读什么 + +- 想从「调用一次 `/api/proxy/v1/chat/completions` 后内部发生了什么」入手,看 [请求生命周期](./request-lifecycle)。 +- 想了解部署细节,看 [部署总览](../deployment/overview)、[快速开始](../deployment/quickstart)。 +- 想从管理后台界面入手,看 [管理后台总览](../usage/admin-overview)。 +- 想从调用方角度入手,看 [通过 AutoRouter 调用模型](../usage/invoke-models)。 diff --git a/docs/guide/architecture/request-lifecycle.md b/docs/guide/architecture/request-lifecycle.md index 55b64459..fa73b503 100644 --- a/docs/guide/architecture/request-lifecycle.md +++ b/docs/guide/architecture/request-lifecycle.md @@ -5,19 +5,209 @@ outline: deep # 请求生命周期 -::: warning 撰写中 -此文档目前为占位,正文尚未填充。完整撰写进度跟踪见 [issue #167](https://github.com/g1331/AutoRouter/issues/167)。 -::: +这一页跟踪一次客户端请求从打到 AutoRouter 入口、到上游响应回到调用方手中的完整流程。所有引用都指向 `master` 分支上的源码与行号,可以照着读、照着改。示例以最常见的 `POST /api/proxy/v1/chat/completions` 为基准,其他协议(Anthropic `/v1/messages`、Gemini `/v1beta/models/:generateContent`、OpenAI `/v1/responses` 等)的差异在每一阶段单独标出。 -## 计划覆盖的内容 +## 阶段一:HTTP 方法分发 -一次客户端请求从 `/api/proxy/v1/*` 进入到上游返回的完整流转:鉴权、模型解析、上游选择、转发、日志、计费。 +入口文件:`src/app/api/proxy/v1/[...path]/route.ts`。 -## 在正文就绪前的临时建议 +文件末尾导出全部 5 个 HTTP 方法,每个方法只做一件事——把请求委托给同一个内部函数 `handleProxy`: -在该文档正文上线之前,可以参考以下材料获取等价信息: +| 导出 | 行号 | +| -------- | ---- | +| `GET` | 4134 | +| `POST` | 4141 | +| `PUT` | 4148 | +| `DELETE` | 4155 | +| `PATCH` | 4162 | -- 项目仓库根目录的 [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) +```ts +// route.ts:4141 +export async function POST(request: NextRequest, context: RouteContext) { + return handleProxy(request, context); +} +``` + +`handleProxy` 自身从 `route.ts:2434` 开始,是后续所有阶段的容器函数。阅读源码时把它当成「主时序图」即可。 + +## 阶段二:CORS 与 OPTIONS + +代理入口**没有**显式导出 `OPTIONS` handler,也没有独立的 `cors.ts` 工具文件。环境变量 `CORS_ORIGINS` 解析后保存在 `src/lib/utils/config.ts` 的 `corsOrigins` 字段中(默认 `["http://localhost:3000"]`),但当前**仅此一处引用**——全仓没有任何代码读取该字段后输出 `Access-Control-Allow-Origin` 或允许请求头等响应头(grep `Access-Control-Allow` 无匹配)。也就是说:`CORS_ORIGINS` 在当前实现里没有运行期效果,把某个 origin 加入该列表并不能让代理通过浏览器的 preflight。如果一定要让浏览器侧 SDK 直连代理,需要在代理前置一层反向代理(Nginx / Caddy / Traefik 等)由它来注入 CORS 头;典型部署中,代理仍被服务端调用方使用,浏览器不直接访问。 + +## 阶段三:客户端鉴权 + +提取客户端 Key 的函数:`extractProxyApiKey`,`route.ts:2249`。三种 header 按以下顺序判定,先命中先用: + +```ts +// route.ts:2253-2268(节选) +const fromAuthorization = extractApiKey(request.headers.get("authorization")); +if (fromAuthorization) return { keyValue: fromAuthorization, authSource: "authorization" }; + +const fromApiKey = extractApiKey(request.headers.get("x-api-key")); +if (fromApiKey) return { keyValue: fromApiKey, authSource: "x-api-key" }; + +const fromGoogleApiKey = extractApiKey(request.headers.get("x-goog-api-key")); +if (fromGoogleApiKey) return { keyValue: fromGoogleApiKey, authSource: "x-goog-api-key" }; +``` + +`extractApiKey` 同时识别 `Bearer ` 与裸字符串两种写法。任意一种 header 都能通过,目的是兼容 OpenAI SDK(`Authorization: Bearer`)、Anthropic SDK(`x-api-key`)与 Gemini SDK(`x-goog-api-key`)的默认行为。 + +提取到候选 Key 后,鉴权依次执行以下检查: + +1. **存在性**(`route.ts:2448`):`keyValue` 为空 → `{ "error": "Missing API key" }` HTTP 401。 +2. **bcrypt 比对**(`route.ts:2460`):以 prefix 找出候选记录,调用 `verifyApiKey(keyValue, candidate.keyHash)`(内部 `bcrypt.compare`)。比对失败 → `{ "error": "Invalid API key" }` HTTP 401(`route.ts:2472`)。 +3. **过期判定**(`route.ts:2463`):`candidate.expiresAt && candidate.expiresAt < new Date()` → `{ "error": "API key has expired" }` HTTP 401。 + +注意这三类早期错误响应体里**只有一个 `error` 字符串字段**,没有 `code` 或 `error_code`,与后续路由阶段的统一错误格式不同。客户端如果要按机器可读规则区分原因,需要解析这个字符串本身。 + +## 阶段四:路由能力解析与模型提取 + +`handleProxy` 在鉴权通过后立刻把请求映射为一个 `RouteCapability`,所有后续上游筛选都基于这个枚举值。 + +**路径 → 能力映射**:`resolveRouteCapability(method, path, headers)`,`src/lib/services/route-capability-matcher.ts:307`。内部分两步: + +1. `matchProtocolFamily`(`route-capability-matcher.ts:171`):按 URL 路径段匹配基础协议族,例如 `chat/completions` → `openai_chat_compatible`,`messages` → `anthropic_messages`,`responses` → `openai_responses`,`v1beta/models/:generateContent` → `gemini_native_generate`。 +2. `resolveFinalCapability`(`route-capability-matcher.ts:218`):再结合请求头中的 client profile 做升级。例如 Claude Code CLI 的特征 header 会把 `anthropic_messages` 升级为 `claude_code_messages`,Codex CLI 会把 `openai_responses` 升级为 `codex_cli_responses`。 + +`RouteCapability` 的全部取值定义在 `src/lib/route-capabilities.ts:1`: + +``` +"anthropic_messages" | "claude_code_messages" | +"openai_responses" | "codex_cli_responses" | +"openai_chat_compatible" | "openai_extended" | +"gemini_native_generate" | "gemini_code_assist_internal" +``` + +**模型提取**:`extractRequestContext`,`route.ts:2390`。单次解析请求体,按协议族取值: + +- OpenAI / Anthropic:`bodyJson.model`(`route.ts:2408`)。 +- Gemini:`extractGeminiModelFromPath(path)`(`route.ts:2391`、`route-capability-matcher.ts:279`),从 URL 路径段 `v1beta/models/:generateContent` 中取出 ``。 +- 最终:`model = modelFromBody ?? modelFromPath`(`route.ts:2413`)。 + +当请求体里 `bodyJson.model` 是 string 时直接采用,否则 `modelFromBody` 为 `null`(`route.ts:2408`)。当 `modelFromBody` 与 `modelFromPath` 都为 `null` 时,最终 `model` 字段也是 `null`,AutoRouter **不会**在本地拒绝该请求:`filterCandidatesByModelRules`(`route.ts:591`)在 `originalModel` 为 null 时直接返回全部候选(`route.ts:595-600`),请求仍会进入阶段五并被转发到选中的上游。若调用方因此收到 400,错误来自上游侧的响应,而非 AutoRouter 的统一错误层。 + +## 阶段五:候选过滤与上游选路 + +进入上游选路前要先确定候选集合。`handleProxy` 在 `route.ts:2628-2654` 附近做受限模式过滤: + +```ts +// route.ts:2628-2654(节选) +const accessMode = validApiKey.accessMode ?? "restricted"; +const allowedUpstreamIds = + accessMode === "restricted" + ? storedAllowedUpstreamIds // 来自 apiKeyUpstreams 关联表 + : activeUpstreams.map((u) => u.id); // unrestricted: 全部活跃上游 +``` + +`storedAllowedUpstreamIds` 来自 `apiKeyUpstreams` 表,是该客户端 Key 创建或编辑时绑定的上游集合。受限模式下未绑定的上游一律不可见;非受限模式下任何活跃上游都可被命中(具体能否承接当前请求,仍由路由能力与模型可用性进一步过滤)。 + +接下来在候选内做选路。整套逻辑分为三层: + +1. **熔断状态过滤**(`src/lib/services/load-balancer.ts:243`,`filterByCircuitBreaker`): + - `OPEN` 状态且距离开启时间 `< openDuration` → 跳过(`load-balancer.ts:273-279`)。 + - `HALF_OPEN` 状态且距离上次探测 `< probeInterval` → 跳过(`load-balancer.ts:289-295`)。 + - 其余进入下一步。 +2. **模型匹配**(`src/lib/services/model-router.ts`):根据请求模型名结合每个上游的 `model_rules` 与 `model_redirects` 决定是否承接,承接的上游进入加权选择池。 +3. **加权随机选择**(`src/lib/services/load-balancer.ts:485`,`selectWeightedWithHealthScore`):当前实现只用一种策略——加权随机叠加延时分数。有效权重 = `upstream.weight * latencyScore`,`latencyPenalty = min(latencyMs / 500, 0.5)`(`load-balancer.ts:496`)。当所有候选 `totalWeight == 0` 时退化为纯随机(`load-balancer.ts:510`)。 + +选中候选后转发前再申请一次熔断器准入(`src/lib/services/circuit-breaker.ts:160`,`acquireCircuitBreakerPermit`)。若期间状态已切换到 `OPEN`,直接抛 `CircuitBreakerOpenError`(`circuit-breaker.ts:183`),由失败转移逻辑接住(见下一阶段)。 + +熔断器自身是个三态机:`CLOSED`(默认)/ `OPEN`(熔断中,拒绝新流量)/ `HALF_OPEN`(半开,按 `probeInterval` 节奏放探测请求)。状态枚举定义在 `circuit-breaker.ts:13-17`,状态持久化在 `circuitBreakerStates` 表中。状态机的完整行为详见 [`docs/circuit-breaker.md`](/circuit-breaker)。 + +## 阶段六:上游转发与流式包装 + +转发函数:`forwardRequest(request, upstream, path, requestId, ...)`,`src/lib/services/proxy-client.ts:984`。流程如下: + +1. **header 处理**:调用 `filterHeaders`(`proxy-client.ts:216`)剔除 hop-by-hop header;调用 `injectAuthHeader`(`proxy-client.ts:237`)按上游配置注入正确的鉴权 header(部分上游用 `Authorization`、部分用 `x-api-key` 或 `x-goog-api-key`)。 +2. **发起请求**:通过 `fetch` 把改写后的请求体发到上游(`proxy-client.ts:1129`)。 +3. **响应类型判定**:上游响应若带 `content-type: text/event-stream`,进入 SSE 流式分支;否则按非流式整体回传。 + +SSE 分支的处理(`proxy-client.ts:1179` 起): + +- `createSSETransformer`:把 chunk 解析为标准 `data: ...\n\n` 事件。 +- `stream.tee()`:分出两路,一路给客户端、一路给日志侧用于提取 token 计数与 TTFT。 +- `waitForFirstStreamContent`(`proxy-client.ts:1210`):实现 first-byte 超时,避免上游长时间不吐第一块。 + +回到 `handleProxy`,给客户端的那一路再被包一层 `wrapStreamWithConnectionTracking`(`route.ts:1975`): + +- 每次 `read()` 与 `streamIdleTimeoutMs` 超时 promise 竞争(`route.ts:2004-2007`)。 +- `abortSignal.abort` 触发(典型场景:客户端关连接)时,调用 `reader.cancel` 并释放上游侧并发槽位(`route.ts:2031-2033`)。 +- 流正常完成后释放槽位(`route.ts:2063`),并 fire-and-forget 调 `markHealthy` 与 `recordSuccess` 通知健康与熔断模块(`route.ts:2066-2067`)。 + +**失败转移分两类,行为不一样**: + +- **首字节前的失败(可重试)**(`route.ts:1538` 起):上游返回响应头时如果 `shouldTriggerFailover(result.statusCode, config)` 为真(典型:5xx、特定错误码、连接超时),记录此次失败、释放连接、调 `markUnhealthy` 与 `recordFailure`,向本次请求的 `failoverHistory` 数组追加一条记录(`route.ts:1559`),把当前上游加入「已失败」集合,`continue` 重新进入阶段五选下一条候选。当且仅当全部候选都失败时,才向调用方返回最终错误。这一阶段的重试对调用方完全无感。 +- **流开始后的中断(不可重试)**(`route.ts:1592-1651`):一旦 `result.isStream === true`,函数直接 `return` 包装好的流给调用方(`route.ts:1651`),中途读流失败由 `wrapStreamWithConnectionTracking` 的回调(`route.ts:1618-1649`)交给 `settleStreamRuntimeFailureForCircuitBreaker` 处理——只更新日志、记录熔断失败、释放连接,**不会**回到阶段五选另一条上游接着吐 chunk。调用方此时看到的是一条提前结束的 SSE 流,需要自行处理「上游 stream 中断」这一错误。 + +`failoverHistory` 数组在请求结束时随日志一起写入 `requestLogs.failoverHistory` 字段,可在管理后台「请求日志」详情页查看每一次尝试的 upstream_id、错误类型、状态码与时间戳。流式中断的失败记录入口不在这个数组,而是写入流式日志更新(阶段七的 `metricsPromise.then(...)` 路径)。 + +## 阶段七:日志、计费、响应回写 + +**请求日志**:`src/lib/services/request-logger.ts`。 + +- `logRequestStart`(`request-logger.ts:333`):请求进入时**同步 await** 写入一行 `requestLogs`,状态 `in-progress`,所有 token / latency 字段先填 0。 +- `updateRequestLog`(`request-logger.ts:381`):请求结束或失败时 await 更新同一行(非流式路径在 `route.ts:3669` 与 `route.ts:4051`)。SSE 流式路径下,token 与 TTFT 在 `metricsPromise.then(...)` 内异步算完后再更新(`route.ts:3548`),失败用 `.catch` 兜底为 fire-and-forget。 +- `logRequest`(`request-logger.ts:467`):无 `requestLogId` 时的兜底单次 INSERT,用于异常分支。 + +**计费**:`src/lib/services/billing-cost-service.ts`。 + +- 入口:`calculateAndPersistRequestBillingSnapshot`(`billing-cost-service.ts:431`),由 `route.ts:136` 的 `persistBillingSnapshotSafely` 封装做错误兜底。 +- 时机:日志写入后立即 **await**——非流式在 `route.ts:3739-3748`,流式在 `metricsPromise.then(...)` 内(`route.ts:3530-3545`)。 +- 写入:`requestBillingSnapshots` 表,使用 Drizzle 的 `onConflictDoUpdate`(`billing-cost-service.ts:118`)实现幂等 upsert,对同一 `request_log_id` 多次写入安全。 + +**响应 header 回写**:`route.ts:3192` 用 `new Headers(result.headers)` 拷贝得到响应 header,但 `result.headers` 不是上游原始 header 的 1:1 副本,已经经过 `proxy-client.ts` 两道处理——`proxy-client.ts:1147-1153` 的 inline 循环按 `HOP_BY_HOP_HEADERS` 集合过滤上游响应头去掉 hop-by-hop 字段(与请求侧 `filterHeaders` 是两段不同代码);当 undici 解压响应体时 `proxy-client.ts:1157-1159` 再删 `content-encoding` 与 `content-length`。SSE 分支额外强制写入 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`Connection: keep-alive`(`route.ts:3557-3559`)。代理层**不会**追加任何 AutoRouter 专属 header(既无 `X-AutoRouter-Request-Id`,也无 `X-AutoRouter-Upstream-Id`)。请求 ID 与命中上游 ID 只通过管理后台「请求日志」回查。 + +**统一错误格式**:路由阶段及之后的所有错误经 `src/lib/services/unified-error.ts` 包装,响应体形如 `{ error: { code, message, ... } }`,状态码与错误码的映射定义在 `unified-error.ts` 的 `STATUS_CODE_MAP`。注意阶段三的鉴权早期错误**不经过**这一层,格式更朴素(只有顶层 `error` 字段,无 `code`)。 + +**流量录制**:`src/lib/services/traffic-recorder.ts`。 + +- 决策:`shouldRecordFixture(outcome, settings)`(`traffic-recorder.ts:158`)依据 `trafficRecordingSettings` 表的运行期配置(`enabled` + `mode`)判断当前请求是否录制。该开关现为 DB 运行期配置,详见 [`.env` 配置参考](../deployment/env-reference) 中的 RECORDER 章节。 +- 时机:鉴权通过后立即按需读入请求体快照(`route.ts:2485`,`recorderEnabled ? await readRequestBody(request) : null`);响应完成后在日志写入后 `void recordTrafficFixture(...).catch(...)` 异步落盘(`route.ts:3796` 与 `route.ts:4034`),错误不阻塞调用方响应。 + +## 时序总览 + +``` +客户端 + │ POST /api/proxy/v1/chat/completions + ▼ +[1] 方法分发 ──────────► handleProxy(route.ts:2434) + ▼ +[2] CORS / OPTIONS(无自定义 handler;CORS_ORIGINS 当前无运行期效果) + ▼ +[3] 鉴权 + ├ 缺 key → 401 { error: "Missing API key" } + ├ bcrypt 失败 → 401 { error: "Invalid API key" } + └ 已过期 → 401 { error: "API key has expired" } + ▼ +[4] 路由能力 + 模型解析 + route-capability-matcher.ts → RouteCapability + bodyJson.model 或 URL 路径 + ▼ +[5] 候选过滤 + 选路 + 受限模式 → apiKeyUpstreams 过滤 + 熔断状态 → filterByCircuitBreaker + 模型匹配 → model-router.ts + 加权随机 → selectWeightedWithHealthScore + 申请准入 → acquireCircuitBreakerPermit(OPEN 抛 CircuitBreakerOpenError) + ▼ +[6] 转发 + proxy-client.forwardRequest → 上游 + SSE → tee + wrapStreamWithConnectionTracking + 失败 → 记 failoverHistory,回到 [5] 选下一条 + ▼ +[7] 日志 / 计费 / 响应 + requestLogs 更新 + requestBillingSnapshots upsert + 上游 header 透传 + SSE 强制写三个标准头 + traffic-recorder fire-and-forget + ▼ +客户端 ← 2xx 响应体(与上游一致)或统一错误格式 +``` + +## 不在本页范围内 + +- 客户端 Key 的创建与可见性配置:见 [创建客户端 API Key](../usage/client-keys)。 +- 上游配置字段与能力声明:见 [添加第一个上游](../usage/first-upstream)。 +- 各类 SDK 调用样例:见 [通过 AutoRouter 调用模型](../usage/invoke-models)。 +- 熔断器与失败转移的状态机细节:见 [`docs/circuit-breaker.md`](/circuit-breaker)。 +- 模型路由规则与多上游同模型的调度细节:后续「模型路由规则」「负载均衡与权重」专题文档。 diff --git a/docs/guide/usage/invoke-models.md b/docs/guide/usage/invoke-models.md index ad317a8d..1cea94f8 100644 --- a/docs/guide/usage/invoke-models.md +++ b/docs/guide/usage/invoke-models.md @@ -206,7 +206,12 @@ print(response.text) ## 响应行为:透传 + 改写 -正常 2xx 响应:AutoRouter 直接把上游响应体透传给调用方,仅做必要的 header 改写(去除上游侧暴露内部地址的 header、追加 AutoRouter 自身的请求 ID 与命中上游 ID)。响应体格式与上游完全一致,调用方不需要任何兼容层。 +正常 2xx 响应:AutoRouter 把上游响应体透传给调用方,响应 header 在 `src/app/api/proxy/v1/[...path]/route.ts:3192` 处由 `new Headers(result.headers)` 拷贝得到。这里的 `result.headers` 并不是上游响应的原始 header,已经经过 `src/lib/services/proxy-client.ts` 的两道处理: + +1. **去 hop-by-hop**:`proxy-client.ts:1147-1153` 的 inline 循环按 `HOP_BY_HOP_HEADERS` 集合过滤上游响应头,剔除 `connection`、`keep-alive`、`transfer-encoding` 等不应跨连接传递的字段(与 `filterHeaders` 处理请求侧 inbound header 是两段不同代码,不要混淆)。 +2. **去解压元数据**:当 undici 已经自动解压响应体时,`proxy-client.ts:1157-1159` 会同时删除 `content-encoding` 与 `content-length`,避免响应体长度与声明值不一致导致下游再解压时报 `Z_DATA_ERROR`。 + +也就是说调用方拿到的不是 1:1 的上游 header 副本。SSE 流式分支额外强制写入 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`Connection: keep-alive` 三个标准头(`route.ts:3557-3559`)。代理层**不会**追加 `X-AutoRouter-Request-Id` / `X-AutoRouter-Upstream-Id` 之类的自定义头;本次请求的 ID 与命中上游 ID 通过管理后台的「请求日志」回查。响应体本身格式与上游完全一致,调用方不需要任何兼容层。 错误响应分两类,调用方需要分别识别: @@ -231,7 +236,7 @@ print(response.text) 状态码与错误码映射关系定义在 `src/lib/services/unified-error.ts`;以上仅列最常见者,完整枚举以源文件 `UnifiedErrorCode` 与 `STATUS_CODE_MAP` 为准。 -failover 在调用方眼里是无感的:AutoRouter 会按 [`docs/circuit-breaker.md`](/circuit-breaker) 中的逻辑自动尝试下一条候选,仅当全部候选都失败时才返回最终错误。`/api/admin/logs` 中可以看到本次请求的 `failoverHistory` 字段,记录每次尝试的上游 ID、错误类型与时间戳。 +failover 只在「首字节前」对调用方无感:上游在返回响应头时如果命中可重试条件(5xx、连接超时等),AutoRouter 会按 [`docs/circuit-breaker.md`](/circuit-breaker) 中的逻辑自动尝试下一条候选,仅当全部候选都失败时才返回最终错误。一旦 SSE 流的第一块数据已经吐出(`result.isStream === true`、`src/app/api/proxy/v1/[...path]/route.ts:1592-1651`),后续的流中断不会再换上游,调用方会看到一条提前结束的 SSE 流,需要自行处理「上游 stream 中断」错误。两类失败都会写入 `requestLogs`,可在 `/api/admin/logs` 看到本次请求的 `failoverHistory` 字段,记录每次尝试的上游 ID、错误类型与时间戳。 ## 模型字段的写法约束