diff --git a/CLAUDE.md b/CLAUDE.md index ac2088d9..f9725724 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -AutoRouter is an AI API Gateway providing API Key distribution, multi-upstream routing, and request management. It's a Next.js fullstack application with API Routes for the backend. +AutoRouter is an AI API Gateway providing API Key distribution, multi-upstream routing, and request management. It is a Next.js 16 (App Router) fullstack application: the frontend is an internationalized admin dashboard, and the backend lives in Next.js API Routes. An OpenAI/Anthropic-compatible proxy under `/api/proxy/v1/*` fans incoming traffic out to configured upstreams using capability-based routing, load balancing, circuit breaking, quota/concurrency control, failover, session affinity, and per-request billing. ## Development Commands @@ -12,28 +12,49 @@ AutoRouter is an AI API Gateway providing API Key distribution, multi-upstream r # Install dependencies pnpm install -# Run development server +# Run development server (resets the Next dev cache first, then starts next dev on port 3000) pnpm dev -# Build +# Build / start production pnpm build - -# Database commands (Drizzle ORM) -pnpm db:generate # Generate migrations from schema changes -pnpm db:migrate # Apply migrations -pnpm db:push # Push schema directly (development) -pnpm db:studio # Open Drizzle Studio - -# Linting & formatting -pnpm lint # ESLint -pnpm format # Prettier format -pnpm format:check # Check formatting -pnpm exec tsc --noEmit # Type checking - -# Testing -pnpm test # Run tests (watch mode) -pnpm test:run # Run tests once -pnpm test:run --coverage # Run with coverage +pnpm start + +# Database — PostgreSQL (default dialect) +pnpm db:generate # Generate migrations from schema changes +pnpm db:migrate # Apply migrations +pnpm db:push # Push schema directly (development) +pnpm db:studio # Open Drizzle Studio +pnpm db:check:consistency # Verify schema/migration artifacts are in sync (CI gate) + +# Database — SQLite (local dev sandbox) +pnpm db:generate:sqlite # Generate SQLite migrations (drizzle-sqlite.config.ts) +pnpm db:migrate:sqlite # Apply SQLite migrations +pnpm db:seed # Seed a local SQLite database (scripts/seed-lite.ts) + +# Linting, formatting, types +pnpm lint # ESLint +pnpm format # Prettier write +pnpm format:check # Prettier check +pnpm exec tsc --noEmit # Type checking + +# Unit / component tests (Vitest, jsdom) +pnpm test # Watch mode +pnpm test:run # Run once +pnpm test:run --coverage # With coverage +pnpm test:run tests/unit/.test.ts # Single test file +pnpm test:run -t "" # Single test by name + +# End-to-end tests (Playwright; spins up SQLite + dev server automatically) +pnpm e2e +pnpm e2e:headed +pnpm e2e tests/e2e/.spec.ts # Single E2E spec + +# Proxy stability smoke check (CI gate) +pnpm test:proxy-stability + +# Documentation site (VitePress) +pnpm docs:dev +pnpm docs:build ``` ## Architecture @@ -44,117 +65,144 @@ pnpm test:run --coverage # Run with coverage src/ ├── app/ │ ├── api/ # Next.js API Routes (backend) -│ │ ├── admin/ # Admin API endpoints -│ │ │ ├── keys/ # API key management -│ │ │ ├── upstreams/ # Upstream management -│ │ │ ├── circuit-breakers/ # Circuit breaker management -│ │ │ ├── stats/ # Statistics endpoints -│ │ │ └── logs/ # Request logs -│ │ ├── proxy/v1/ # AI proxy endpoint +│ │ ├── admin/ # Admin API (Bearer ADMIN_TOKEN), one folder per domain: +│ │ │ ├── keys/ # API key management (+ /reveal) +│ │ │ ├── upstreams/ # Upstream CRUD, health, probes, quota, catalog, failure-rules +│ │ │ ├── circuit-breakers/ # Force open/close +│ │ │ ├── billing/ # Prices, overrides, tier-rules, multipliers, overview, recent +│ │ │ ├── cliproxy/ # CLIProxyAPI instances, auth accounts, OAuth login, pool +│ │ │ ├── background-sync/ # Scheduled background tasks + manual run +│ │ │ ├── traffic-recording(s)/ # Recorder settings + recorded fixtures +│ │ │ ├── compensation-rules/ # Header compensation rules +│ │ │ ├── stats/ # overview / timeseries / leaderboard / live +│ │ │ └── logs/ # Request logs (+ /live SSE) +│ │ ├── proxy/v1/[...path]/ # AI proxy entry point (catch-all) +│ │ ├── mock/[...path]/ # Replays recorded fixtures (non-production) │ │ └── health/ # Health check -│ ├── [locale]/ # Internationalized routes (next-intl) +│ ├── [locale]/ # Internationalized routes (next-intl: en, zh-CN) │ │ ├── (auth)/ # Login page (route group) -│ │ └── (dashboard)/ # Protected dashboard pages +│ │ └── (dashboard)/ # dashboard, keys, upstreams, logs, settings, system │ └── layout.tsx # Root layout with providers -├── components/ # React components (shadcn/ui based) -├── hooks/ # Custom React hooks (TanStack Query) +├── components/ # admin/, dashboard/, logs/, ui/ (shadcn/ui based) +├── hooks/ # TanStack Query hooks (use-upstreams, use-billing, use-live-pulse, …) ├── lib/ -│ ├── db/ # Database (Drizzle ORM) -│ │ ├── schema.ts # Table definitions -│ │ └── index.ts # Database client -│ ├── services/ # Business logic -│ │ ├── key-manager.ts # API key CRUD -│ │ ├── upstream-service.ts # Upstream service (re-exports from focused modules) -│ │ ├── upstream-crud.ts # Upstream database CRUD operations -│ │ ├── upstream-connection-tester.ts # Upstream connection testing -│ │ ├── upstream-ssrf-validator.ts # SSRF protection (IP/URL/DNS validation) -│ │ ├── proxy-client.ts # HTTP proxy with SSE support -│ │ ├── request-logger.ts # Request logging -│ │ ├── stats-service.ts # Statistics aggregation -│ │ ├── circuit-breaker.ts # Circuit breaker state machine -│ │ ├── load-balancer.ts # Load balancing strategies -│ │ ├── model-router.ts # Model-based auto routing -│ │ └── health-checker.ts # Health monitoring -│ └── utils/ # Utility functions -│ ├── auth.ts # Authentication (bcrypt) -│ ├── encryption.ts # Fernet encryption -│ └── config.ts # Environment configuration +│ ├── db/ # Drizzle ORM +│ │ ├── schema.ts # Dialect dispatcher (re-exports pg or sqlite tables/types) +│ │ ├── schema-pg.ts # PostgreSQL table definitions +│ │ ├── schema-sqlite.ts # SQLite table definitions (mirror of pg) +│ │ └── index.ts # Lazy dialect-aware client (Proxy-wrapped `db`) +│ ├── services/ # Business logic (see below) +│ ├── route-capabilities.ts # Capability detection from request path/model +│ └── utils/ # auth.ts, encryption.ts (Fernet), config.ts, logger.ts, api-auth.ts, … ├── i18n/ # next-intl configuration -├── messages/ # Translation JSON files (en, zh) +├── messages/ # Translation JSON (en, zh) ├── providers/ # React context providers └── types/ # TypeScript type definitions + +tests/ # unit/ + components/ (Vitest), e2e/ (Playwright), + # a11y/, visual/, fixtures/ (recorded traffic), setup.ts +openspec/ # OpenSpec change-management workspace (changes/, specs/) +scripts/ # ci/, db/, dev/ helper scripts +docs/ # VitePress documentation site ``` ### Key Architectural Patterns -1. **Upstream Management**: Upstreams (AI providers like OpenAI, Anthropic) stored in PostgreSQL database. Runtime selection via model-based auto-routing (e.g., gpt-_ → openai group, claude-_ → anthropic group). +1. **Dual-dialect database**: Drizzle ORM with PostgreSQL (default, production) and SQLite (local dev sandbox). `config.dbType` selects the dialect; when unset it auto-detects `postgres` if `DATABASE_URL` exists, otherwise `sqlite`. `schema.ts` dispatches to `schema-pg.ts` or `schema-sqlite.ts` at import time, but **all business code is written against the PostgreSQL types** — the SQLite drizzle instance is treated as structurally compatible at runtime. Raw SQL via `db.execute()` may use PG-specific syntax (e.g. `PERCENTILE_CONT`) that does not run on SQLite. Production fails fast rather than silently falling back to SQLite. + +2. **Security model**: + - Admin authentication: Bearer token (`ADMIN_TOKEN`) on all `/api/admin/*` routes. + - Client API keys: hashed with bcrypt, verified on proxy requests, scoped to authorized upstreams and expiry. + - Upstream keys: encrypted at rest with Fernet (`ENCRYPTION_KEY`, 44-char base64). + - SSRF protection (`upstream-ssrf-validator.ts`): blocks private/loopback/metadata addresses and validates DNS resolution when registering upstreams. + +3. **Proxy flow** (`/api/proxy/v1/[...path]` → `proxy-client.ts`): verify client API key → detect route capability from path/model and resolve provider → build the candidate upstream set (capability match, key authorization, `model_redirects`, priority, weight, health, circuit-breaker state) → load-balance and acquire concurrency/queue admission → forward with SSE streaming support → record success/failure, token usage, and a billing snapshot → return the response or stream. Session affinity can pin a conversation to a previously selected upstream. -2. **Security Model**: - - Admin authentication: Bearer token (`ADMIN_TOKEN` env var) - - API key authentication: Client keys hashed with bcrypt, verified on proxy requests - - Upstream keys: Encrypted at rest with Fernet (`ENCRYPTION_KEY` env var) +4. **Failover & circuit breaker**: Circuit breaker is a CLOSED / OPEN / HALF_OPEN state machine per upstream. On timeout or 5xx errors the proxy fails over to the next available candidate and logs each attempt with error type and timestamp. Background health checks mark upstreams healthy/unhealthy; admin endpoints can force a breaker open or closed. Upstream-specific failure rules and compensation rules further shape retry/header behavior. -3. **Proxy Flow**: `/api/proxy/v1/*` receives requests → validates API key → extracts model from request body → routes to upstream group based on model prefix → selects upstream → forwards via proxy-client → logs request → returns SSE stream or response +5. **Billing**: Per-request cost is computed from synced model prices, manual overrides, tier rules, and per-upstream multipliers, then persisted as a request billing snapshot. Prices are kept current by a background sync task. -4. **Failover & Circuit Breaker**: Automatic failover with circuit breaker pattern: - - **Circuit Breaker States**: CLOSED (normal), OPEN (failing), HALF_OPEN (probing) - - **Auto-failover**: On timeout/5xx errors, retry with next available upstream - - **Health Monitoring**: Background health checks mark upstreams healthy/unhealthy - - **Decision Logging**: Failover attempts logged with error type and timestamp - - **Admin Control**: API endpoints to force circuit breaker open/closed +6. **Background sync**: A registry/scheduler runs recurring jobs (billing price sync, upstream model catalog sync, traffic-recording cleanup). Tasks are listed and can be triggered manually from the admin API. -5. **Database**: PostgreSQL with Drizzle ORM. Schema defined in `src/lib/db/schema.ts`. +7. **Traffic recording**: When enabled (`RECORDER_ENABLED`), requests are recorded as fixtures and can be replayed through `/api/mock/*` in non-production environments. Mode and redaction are configurable; the recorder is also the source of the `tests/fixtures/` corpus. + +8. **CLIProxyAPI integration** (`cliproxy-*` services): AutoRouter can manage an external/sidecar CLIProxyAPI instance — registering instances, syncing auth accounts, and driving OAuth login flows for Codex / Claude / Gemini upstreams. ## Configuration -### Environment Variables (.env) +Environment variables are validated through a Zod schema in `src/lib/utils/config.ts`. Copy `.env.example` to `.env` (Docker/deploy) or `.env.local` (local dev). ```env -# Required -DATABASE_URL=postgresql://user:password@localhost:5432/autorouter -ENCRYPTION_KEY= # Generate: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" -ADMIN_TOKEN= # Admin API authentication +# Required (runtime) +DATABASE_URL=postgresql://user:password@host:5432/autorouter # when using PostgreSQL +ENCRYPTION_KEY= # Generate: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +ADMIN_TOKEN= # Admin API authentication + +# Database dialect +DB_TYPE=postgres # "postgres" | "sqlite"; auto-detected from DATABASE_URL when unset +SQLITE_DB_PATH=./data/dev.sqlite # only used when DB_TYPE=sqlite # Optional -ALLOW_KEY_REVEAL=false # Allow revealing API keys -LOG_RETENTION_DAYS=90 # Request log retention +ALLOW_KEY_REVEAL=false # Allow revealing API keys via Admin API +LOG_RETENTION_DAYS=90 # Request log retention +LOG_LEVEL=info # fatal|error|warn|info|debug|trace CORS_ORIGINS=http://localhost:3000 +PORT=3000 # In-app port (docker-compose maps host 3331 → container 3000) + +# Traffic recorder +RECORDER_ENABLED=true # docker-compose / deploy enable it; code default is off +RECORDER_MODE=all # all | success | failure +RECORDER_FIXTURES_DIR=tests/fixtures +RECORDER_REDACT_SENSITIVE=true + +# CLIProxyAPI sidecar (optional) — see .env.example for the full CLIPROXY_* block ``` +The default in-app port is **3000**; the production `docker-compose.yml` maps host port **3331** to container `3000`. + ## Docker Deployment ```bash -# Build and run with docker-compose -docker-compose up -d - -# Or build manually -docker build -t autorouter . -docker run -p 3000:3000 \ - -e DATABASE_URL=postgresql://... \ - -e ENCRYPTION_KEY=... \ - -e ADMIN_TOKEN=... \ - autorouter +# Production compose (PostgreSQL service + app) +docker compose up -d # serves on host port 3331 by default + +# Optional CLIProxyAPI sidecar overlay +docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml up -d ``` -## Code Quality Standards +## Code Quality & CI + +`pnpm lint`, `pnpm format:check`, and `pnpm exec tsc --noEmit` are the local quality gates and are mirrored in the `verify.yml` GitHub Actions workflow, which runs on every PR to `master`: + +| Job | What it runs | +| --------------------- | --------------------------------------------------------------------------------- | +| Quality | ESLint, Prettier check, `tsc --noEmit`, Vitest unit/component tests with coverage | +| Production Build | `pnpm build` (with `DB_TYPE=postgres`) | +| Migration Consistency | `pnpm db:check:consistency`, then `pnpm db:migrate` twice (idempotency) | +| Proxy Stability | `pnpm test:proxy-stability` against a live PostgreSQL | +| Playwright E2E | `pnpm e2e` (Chromium) | +| GitHub Actions | `actionlint` on workflow files | + +Commits go through `.pre-commit-config.yaml` (prettier, eslint `--fix`, `tsc --noEmit`, plus generic file hooks). Do not bypass pre-commit when committing. + +## Working Conventions -- **TypeScript**: ESLint + Prettier, strict mode -- **Database**: Drizzle ORM with typed schema -- **Testing**: Vitest for unit tests -- **CI**: GitHub Actions run lint and test workflows on all PRs +- **OpenSpec**: substantive changes are tracked under `openspec/`. Use the OpenSpec workflow (proposal → tasks → implementation → archive) for feature work. Code-touching tasks must include tests, and code should be committed at the end of each task phase. +- **Language**: issues, commit messages, and assistant replies default to Simplified Chinese; identifiers, CLI commands, logs, and error messages stay in their original language. +- **Destructive operations**: deleting database files, clearing data, or resetting state requires explicit user confirmation before execution. ## Common Development Tasks -### Adding a New API Endpoint +### Adding a New Admin API Endpoint -1. Create route in `src/app/api/admin/{endpoint}/route.ts` -2. Add service logic in `src/lib/services/` -3. Update types in `src/types/api.ts` if needed -4. Write tests in `tests/` +1. Create the route in `src/app/api/admin/{domain}/route.ts` (guard with the admin Bearer token). +2. Put business logic in `src/lib/services/`; reuse existing service modules where the domain already exists. +3. If the data model changes, update `schema-pg.ts` and `schema-sqlite.ts` together, then `pnpm db:generate` (and `pnpm db:generate:sqlite`). +4. Add tests under `tests/unit/` or `tests/components/`. ### Adding a New Frontend Page -1. Create route in `src/app/[locale]/(dashboard)/` -2. Add translations in `src/messages/{en,zh}.json` -3. Create components in `src/components/` -4. Add API hooks using TanStack Query patterns from `src/hooks/` +1. Create the route under `src/app/[locale]/(dashboard)/`. +2. Add translations to `src/messages/{en,zh}.json` for both locales. +3. Build components under `src/components/` (reuse `ui/` primitives). +4. Add data hooks using the TanStack Query patterns in `src/hooks/`. diff --git a/README.md b/README.md index fb10df42..0c5e6a47 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ [![Next.js](https://img.shields.io/badge/Next.js-16-000000?logo=next.js&logoColor=white)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://react.dev/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?logo=postgresql&logoColor=white)](https://www.postgresql.org/) diff --git a/README_EN.md b/README_EN.md index 3a5db678..7ce9643f 100644 --- a/README_EN.md +++ b/README_EN.md @@ -17,7 +17,7 @@ [![Next.js](https://img.shields.io/badge/Next.js-16-000000?logo=next.js&logoColor=white)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black)](https://react.dev/) -[![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +[![TypeScript](https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript&logoColor=white)](https://www.typescriptlang.org/) [![PostgreSQL](https://img.shields.io/badge/PostgreSQL-16-4169E1?logo=postgresql&logoColor=white)](https://www.postgresql.org/) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 59ff79a6..ff2f12b5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -111,7 +111,7 @@ export default defineConfig({ socialLinks: [{ icon: "github", link: "https://github.com/g1331/AutoRouter" }], search: { provider: "local" }, footer: { - message: "Released under the MIT License.", + message: "Released under the AGPL-3.0 License.", copyright: "Copyright © 2025-present AutoRouter Contributors", }, }, diff --git a/docs/circuit-breaker.md b/docs/circuit-breaker.md index 7979cab1..83de4cc1 100644 --- a/docs/circuit-breaker.md +++ b/docs/circuit-breaker.md @@ -27,22 +27,20 @@ AutoRouter implements a circuit breaker pattern combined with automatic failover ### State Transitions 1. **CLOSED → OPEN**: When failure count exceeds `failureThreshold` (default: 5) -2. **OPEN → HALF_OPEN**: After `openDuration` timeout (default: 30s) +2. **OPEN → HALF_OPEN**: After `openDuration` timeout (default: 300s) 3. **HALF_OPEN → CLOSED**: When success count reaches `successThreshold` (default: 2) 4. **HALF_OPEN → OPEN**: On any failure during probing ## Configuration -Circuit breaker can be configured per upstream via the `config` field: +Circuit breaker can be configured per upstream via the `circuit_breaker_config` top-level field in the upstream create/update request: ```json { - "circuit_breaker": { - "failure_threshold": 5, - "success_threshold": 2, - "open_duration": 30000, - "probe_interval": 10000 - } + "failure_threshold": 5, + "success_threshold": 2, + "open_duration": 300000, + "probe_interval": 30000 } ``` @@ -50,8 +48,8 @@ Circuit breaker can be configured per upstream via the `config` field: | ------------------- | ------- | --------------------------------------------------------------- | | `failure_threshold` | 5 | Number of consecutive failures to open circuit | | `success_threshold` | 2 | Number of consecutive successes to close circuit from half-open | -| `open_duration` | 30000 | Milliseconds to wait before attempting recovery (half-open) | -| `probe_interval` | 10000 | Milliseconds between probe attempts in half-open state | +| `open_duration` | 300000 | Milliseconds to wait before attempting recovery (half-open) | +| `probe_interval` | 30000 | Milliseconds between probe attempts in half-open state | ## Failover Behavior @@ -161,7 +159,7 @@ Failover attempts are logged in the request logs: | `last_failure_at` | TIMESTAMP | Last failure timestamp | | `opened_at` | TIMESTAMP | When circuit opened | | `last_probe_at` | TIMESTAMP | Last probe attempt | -| `config` | JSONB | Circuit breaker configuration | +| `config` | JSON | Circuit breaker configuration | ## Troubleshooting diff --git a/docs/guide/architecture/cliproxy-integration.md b/docs/guide/architecture/cliproxy-integration.md index 2ba2438d..49869b64 100644 --- a/docs/guide/architecture/cliproxy-integration.md +++ b/docs/guide/architecture/cliproxy-integration.md @@ -52,7 +52,7 @@ export const CLIPROXY_INSTANCE_MODES = ["managed", "external"] as const; | `prefix` | text | 模型名前缀,用于单账号固定路由 | | `model_count` | integer | 该账号当前能用的模型数 | | `priority` / `note` | integer / text | 管理员维护的优先级与备注 | -| `raw_metadata` | jsonb | CPA 响应字段的非敏感快照,禁止包含 token | +| `raw_metadata` | json | CPA 响应字段的非敏感快照,禁止包含 token | | `last_synced_at` | timestamptz | 上次同步成功时间 | `(instance_id, auth_file_name)` 上有唯一约束(`schema-pg.ts:768`),保证同步幂等。 @@ -141,7 +141,7 @@ CPA 调整对外约定时,路径后缀与默认路由能力的改动集中在 ## 转发路径中的 CPA 分支 -CPA 上游在请求生命周期里只有一处特殊处理,即单账号映射上游的模型前缀注入,发生在 `src/app/api/proxy/v1/[...path]/route.ts:1511-1526`: +CPA 上游在请求生命周期里只有一处特殊处理,即单账号映射上游的模型前缀注入,发生在 `src/app/api/proxy/v1/[...path]/route.ts:1519-1532`: ```ts let cliproxyModelOverride: string | undefined; @@ -158,7 +158,7 @@ if (selectedUpstream.cliproxyAuthFileName && selectedUpstream.cliproxyInstanceId 判断条件是 `cliproxyAuthFileName` 和 `cliproxyInstanceId` 同时有值,即仅单账号映射上游会进入这一分支;普通上游和池上游都会跳过。 -拼接后的 `/` 形态通过 `forwardRequest` 的 `modelOverride` 参数传到 `proxy-client.ts:896` 的 `applyModelOverride` 函数:OpenAI / Anthropic 协议改写 JSON body 中的 `model` 字段;Gemini 原生协议改写 URL 路径中的模型段(`proxy-client.ts:887` 的 `GEMINI_NATIVE_MODEL_SEGMENT` 正则匹配)。 +拼接后的 `/` 形态通过 `forwardRequest` 的 `modelOverride` 参数传到 `proxy-client.ts:914` 的 `applyModelOverride` 函数:OpenAI / Anthropic 协议改写 JSON body 中的 `model` 字段;Gemini 原生协议改写 URL 路径中的模型段(`proxy-client.ts:905` 的 `GEMINI_NATIVE_MODEL_SEGMENT` 正则匹配)。 ::: tip 池上游不依赖前缀 池上游的 baseUrl 已经拼好了服务商路径后缀(如 `/api/provider/anthropic/v1`),CPA 收到请求后会按 CPA 自身的账号选择策略分发,AutoRouter 不再额外注入前缀。 diff --git a/docs/guide/architecture/database-schema.md b/docs/guide/architecture/database-schema.md index 9c4de603..ae58e663 100644 --- a/docs/guide/architecture/database-schema.md +++ b/docs/guide/architecture/database-schema.md @@ -31,7 +31,7 @@ const schema = (config.dbType === "sqlite" ? sqliteSchema : pgSchema) as typeof 整个项目所有业务代码都从 `@/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。 +注释(`src/lib/db/index.ts:71`)明确说明:SQLite 在结构上对常规 CRUD 兼容,但 `PERCENTILE_CONT` 等 PG 专用 SQL 在 SQLite 上不可用。统计聚合(`/api/admin/stats/*`)在 SQLite 上会有部分查询直接报错。线上务必用 PostgreSQL。 ::: ## 表清单 @@ -193,8 +193,8 @@ PostgreSQL 与 SQLite 各自有独立的迁移目录: | 目录 | 用途 | 文件数 | | ----------------- | --------------- | ------------------------------- | -| `drizzle/` | PostgreSQL 迁移 | 当前 40 个 SQL(最高编号 0037) | -| `drizzle-sqlite/` | SQLite 迁移 | 当前 16 个 SQL | +| `drizzle/` | PostgreSQL 迁移 | 当前 39 个 SQL(最高编号 0037) | +| `drizzle-sqlite/` | SQLite 迁移 | 当前 15 个 SQL | 两套迁移**并不严格一一对应**,因为某些 PG 特定能力(json 类型、`gen_random_uuid()`、`timestamptz`)在 SQLite 上需要不同的表达方式甚至跳过。每次给 `schema-pg.ts` 加字段后,标准流程: diff --git a/docs/guide/architecture/failover-circuit.md b/docs/guide/architecture/failover-circuit.md index 5f4b1be5..dce2e0a2 100644 --- a/docs/guide/architecture/failover-circuit.md +++ b/docs/guide/architecture/failover-circuit.md @@ -26,7 +26,7 @@ AutoRouter 把「上游会失败」当作常态。一次客户端请求可能触 | 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 做探测。 +没有任何定时器主动把状态翻成 HALF_OPEN。OPEN 状态的过期检查只在「下一次有真实请求到来、需要选这个上游」时由 `acquireCircuitBreakerPermit` 触发(`circuit-breaker.ts:168-179`)。这意味着:若一个 OPEN 上游迟迟没有流量打到它,它会一直保持 OPEN,直到某次请求把它选回候选池,才有机会被翻成 HALF_OPEN 做探测。 ::: ### 默认阈值 @@ -64,10 +64,10 @@ AutoRouter 把「上游会失败」当作常态。一次客户端请求可能触 ## 单次请求内的故障转移循环 -入口函数 `forwardWithFailover`,源码 `src/app/api/proxy/v1/[...path]/route.ts:1289-1753`。签名: +入口函数 `forwardWithFailover`,源码 `src/app/api/proxy/v1/[...path]/route.ts:1295-1760`。签名: ```ts -// route.ts:1289-1313(节选) +// route.ts:1295-1320(节选) async function forwardWithFailover( request, routeCapability, @@ -117,7 +117,7 @@ export const DEFAULT_FAILOVER_CONFIG: FailoverConfig = { - 状态码非 2xx 且不在 `excludeStatusCodes` 中 -默认 `excludeStatusCodes` 为空数组,意味着**所有 4xx(包括 401 / 403 / 404 / 429)都会触发故障转移**。`getErrorType()` 会区分 `http_429` 和通用 `http_4xx`(`route.ts:828-829`),但并不影响是否触发转移。如果不希望客户端的 401 把所有上游试一遍,需要在 `FailoverConfig.excludeStatusCodes` 里配置 `[401, 403]` 等。 +默认 `excludeStatusCodes` 为空数组,意味着**所有 4xx(包括 401 / 403 / 404 / 429)都会触发故障转移**。`getErrorType()` 会区分 `http_429` 和通用 `http_4xx`(`route.ts:829-830`),但并不影响是否触发转移。如果不希望客户端的 401 把所有上游试一遍,需要在 `FailoverConfig.excludeStatusCodes` 里配置 `[401, 403]` 等。 ### 失败是否记入熔断器:FailureRule @@ -130,13 +130,13 @@ export const DEFAULT_FAILOVER_CONFIG: FailoverConfig = { | `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`。 +源码 `src/lib/services/upstream-failure-rules.ts:12-18`。当 `matchFailureRule()` 命中一条规则时,本次失败仍然会触发故障转移,但 `circuitBreakerRecorded = false`(`route.ts:1555-1556, 1714-1715`),不写入 `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`)。 +典型用法:上游对应 OAuth 受控的 CLIProxyAPI auth-file,正常会偶发 401 触发后台 refresh,不希望把上游打到熔断;可以加一条 `statusCodes: [401], bodyPattern: "token expired"` 的规则。上游层 `upstreams.failure_rule_config.useGlobalRules`(默认 `true`)控制是否同时参与全局规则匹配(`upstream-failure-rules.ts:353`)。 ### 并发已满与队列等待 -当 `selectFromUpstreamCandidates` 抛出 `AllCandidatesConcurrencyFullError` 并携带 `waitableCandidate` 时,主循环不会立即返回失败,而是调用 `resumeQueuedUpstreamSelection`(`route.ts:1403-1463`),内部通过 `upstreamQueueAdmission` 等待该上游的并发槽位释放。等待时长由 `upstream.queue_policy` 控制,超时会抛 `UpstreamQueueWaitTimeoutError`,此时不再尝试其他上游,直接返回 503 / 504。 +当 `selectFromUpstreamCandidates` 抛出 `AllCandidatesConcurrencyFullError` 并携带 `waitableCandidate` 时,主循环不会立即返回失败,而是调用 `resumeQueuedUpstreamSelection`(`route.ts:1409-1452`),内部通过 `upstreamQueueAdmission` 等待该上游的并发槽位释放。等待时长由 `upstream.queue_policy` 控制,超时会抛 `UpstreamQueueWaitTimeoutError`,此时不再尝试其他上游,直接返回 503 / 504。 ### 故障转移决策日志 diff --git a/docs/guide/architecture/i18n.md b/docs/guide/architecture/i18n.md index 973c29d4..eebf5366 100644 --- a/docs/guide/architecture/i18n.md +++ b/docs/guide/architecture/i18n.md @@ -187,13 +187,13 @@ API 路由完全不走 i18n 中间件,对外不暴露语言概念——API 返 | 文件 | 行数 | | ------------ | ---- | -| `zh-CN.json` | 1551 | -| `en.json` | 1546 | +| `zh-CN.json` | 1576 | +| `en.json` | 1571 | -按功能 / 页面分 19 个顶层 namespace(按 `en.json` 出现顺序): +按功能 / 页面分 20 个顶层 namespace(按 `en.json` 出现顺序): ``` -common · nav · repository · auth · dashboard · keys · logs · upstreams · +common · nav · repository · auth · dashboard · livePulse · keys · logs · upstreams · circuitBreaker · errors · language · theme · system · billing · backgroundSync · trafficRecording · upstreamFailureRules · compensation · cliproxy ``` diff --git a/docs/guide/architecture/overview.md b/docs/guide/architecture/overview.md index a8e3c5ec..a1efe45b 100644 --- a/docs/guide/architecture/overview.md +++ b/docs/guide/architecture/overview.md @@ -59,7 +59,7 @@ AutoRouter 是一个 Next.js 全栈应用:同一个进程同时承担「管理 | `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/app/api/proxy/v1/[...path]/route.ts` 在文件末尾把所有 HTTP 方法都导向同一个内部函数(`POST` 位于第 4147 行、`handleProxy` 位于第 2440 行),后文「请求生命周期」会逐步展开它的内部流程。 ## 服务模块清单 @@ -128,6 +128,8 @@ AutoRouter 是一个 Next.js 全栈应用:同一个进程同时承担「管理 ### 实时数据推送 - `request-log-live-updates.ts`:管理后台「请求日志」页的实时刷新数据源。 +- `live-pulse-service.ts`:汇总滚动窗口流量指标与网关健康信号(健康上游比例、熔断开路数),供顶栏 live-pulse 状态条使用。 +- `live-pulse-aggregator.ts`:维护滚动时间窗口(60 秒)内的请求采样数据,为 `live-pulse-service` 提供原始快照。 ## 数据模型 @@ -205,7 +207,7 @@ Fernet 加密没有独立 npm 包,实现位于 `src/lib/utils/encryption.ts` `src/lib/utils/config.ts` 用 Zod schema 加载并校验所有环境变量,导出单例 `config`。关键约束如下: -- 生产环境必须显式设置 `DATABASE_URL`,否则启动时 fast-fail。 +- 生产环境若未显式设置 `DB_TYPE`,则必须设置 `DATABASE_URL`,否则启动时 fast-fail;显式设置 `DB_TYPE=sqlite` 时可不设 `DATABASE_URL`。 - `ENCRYPTION_KEY` 必须为 44 字符 base64(解码后 32 字节),可通过 `ENCRYPTION_KEY_FILE` 从挂载文件读入。 - `ADMIN_TOKEN` 必填,用于管理 API Bearer 鉴权。 - `CORS_ORIGINS` 是逗号分隔的白名单,默认 `http://localhost:3000`,但当前代码只在 `config.ts` 解析它、没有任何代码读它后输出 `Access-Control-Allow-*` 响应头,因此该字段没有运行期效果(详见 [请求生命周期](./request-lifecycle) 阶段二)。 @@ -215,15 +217,15 @@ Fernet 加密没有独立 npm 包,实现位于 `src/lib/utils/encryption.ts` ## 部署与 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 | +| 文件 | 用途 | +| --------------------------------------- | -------------------------------------------------------------------------------------------- | +| `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/**`、`scripts/**`、`drizzle/**`、根目录配置文件等多类路径变更时跑测试与校验 | +| `.github/workflows/deploy-personal.yml` | `workflow_dispatch` 手动触发,按指定镜像 tag 通过 SSH 部署到个人服务器 | +| `.github/workflows/docs.yml` | `master` 上文档相关路径变更时,构建并发布 VitePress 站点到 GitHub Pages | ## 接下来读什么 diff --git a/docs/guide/architecture/release.md b/docs/guide/architecture/release.md index 39b14dab..dcb6b65b 100644 --- a/docs/guide/architecture/release.md +++ b/docs/guide/architecture/release.md @@ -153,7 +153,7 @@ release notes 不写手稿,由 `git-cliff` + `cliff.toml` 自动渲染。 - `v0.3.0-alpha.2`:基线是 `v0.3.0-alpha.1`。 - `v0.3.0-beta.1`:基线是「v0.3.0 基线下同渠道(beta)」的上一颗 beta;没有则退化到最近稳定。 -预发布 tag 渲染 changelog 时会带上 `--ignore-tags '.*-(alpha|beta)\\.[0-9]+$'`,避免预发布 tag 被当成稳定版本写入对比关系。 +稳定 tag 渲染 changelog 时会带上 `--ignore-tags '.*-(alpha|beta)\\.[0-9]+$'`,避免预发布 tag 被 git-cliff 当作稳定对比基线。 ### commit 分组 diff --git a/docs/guide/architecture/request-lifecycle.md b/docs/guide/architecture/request-lifecycle.md index fa73b503..6e46c85d 100644 --- a/docs/guide/architecture/request-lifecycle.md +++ b/docs/guide/architecture/request-lifecycle.md @@ -15,20 +15,20 @@ outline: deep | 导出 | 行号 | | -------- | ---- | -| `GET` | 4134 | -| `POST` | 4141 | -| `PUT` | 4148 | -| `DELETE` | 4155 | -| `PATCH` | 4162 | +| `GET` | 4140 | +| `POST` | 4147 | +| `PUT` | 4154 | +| `DELETE` | 4161 | +| `PATCH` | 4168 | ```ts -// route.ts:4141 +// route.ts:4147 export async function POST(request: NextRequest, context: RouteContext) { return handleProxy(request, context); } ``` -`handleProxy` 自身从 `route.ts:2434` 开始,是后续所有阶段的容器函数。阅读源码时把它当成「主时序图」即可。 +`handleProxy` 自身从 `route.ts:2440` 开始,是后续所有阶段的容器函数。阅读源码时把它当成「主时序图」即可。 ## 阶段二:CORS 与 OPTIONS @@ -36,7 +36,7 @@ export async function POST(request: NextRequest, context: RouteContext) { ## 阶段三:客户端鉴权 -提取客户端 Key 的函数:`extractProxyApiKey`,`route.ts:2249`。三种 header 按以下顺序判定,先命中先用: +提取客户端 Key 的函数:`extractProxyApiKey`,`route.ts:2255`。三种 header 按以下顺序判定,先命中先用: ```ts // route.ts:2253-2268(节选) @@ -54,9 +54,9 @@ if (fromGoogleApiKey) return { keyValue: fromGoogleApiKey, authSource: "x-goog-a 提取到候选 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。 +1. **存在性**(`route.ts:2452`):`keyValue` 为空 → `{ "error": "Missing API key" }` HTTP 401。 +2. **bcrypt 比对**(`route.ts:2466`):以 prefix 找出候选记录,调用 `verifyApiKey(keyValue, candidate.keyHash)`(内部 `bcrypt.compare`)。比对失败 → `{ "error": "Invalid API key" }` HTTP 401(`route.ts:2478`)。 +3. **过期判定**(`route.ts:2469`):`candidate.expiresAt && candidate.expiresAt < new Date()` → `{ "error": "API key has expired" }` HTTP 401。 注意这三类早期错误响应体里**只有一个 `error` 字符串字段**,没有 `code` 或 `error_code`,与后续路由阶段的统一错误格式不同。客户端如果要按机器可读规则区分原因,需要解析这个字符串本身。 @@ -78,20 +78,20 @@ if (fromGoogleApiKey) return { keyValue: fromGoogleApiKey, authSource: "x-goog-a "gemini_native_generate" | "gemini_code_assist_internal" ``` -**模型提取**:`extractRequestContext`,`route.ts:2390`。单次解析请求体,按协议族取值: +**模型提取**:`extractRequestContext`,`route.ts:2396`。单次解析请求体,按协议族取值: -- 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`)。 +- OpenAI / Anthropic:`bodyJson.model`(`route.ts:2414`)。 +- Gemini:`extractGeminiModelFromPath(path)`(`route.ts:2397`、`route-capability-matcher.ts:279`),从 URL 路径段 `v1beta/models/:generateContent` 中取出 ``。 +- 最终:`model = modelFromBody ?? modelFromPath`(`route.ts:2419`)。 -当请求体里 `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 的统一错误层。 +当请求体里 `bodyJson.model` 是 string 时直接采用,否则 `modelFromBody` 为 `null`(`route.ts:2414`)。当 `modelFromBody` 与 `modelFromPath` 都为 `null` 时,最终 `model` 字段也是 `null`,AutoRouter **不会**在本地拒绝该请求:`filterCandidatesByModelRules`(`route.ts:592`)在 `originalModel` 为 null 时直接返回全部候选(`route.ts:596-601`),请求仍会进入阶段五并被转发到选中的上游。若调用方因此收到 400,错误来自上游侧的响应,而非 AutoRouter 的统一错误层。 ## 阶段五:候选过滤与上游选路 -进入上游选路前要先确定候选集合。`handleProxy` 在 `route.ts:2628-2654` 附近做受限模式过滤: +进入上游选路前要先确定候选集合。`handleProxy` 在 `route.ts:2634-2659` 附近做受限模式过滤: ```ts -// route.ts:2628-2654(节选) +// route.ts:2634-2659(节选) const accessMode = validApiKey.accessMode ?? "restricted"; const allowedUpstreamIds = accessMode === "restricted" @@ -107,7 +107,7 @@ const allowedUpstreamIds = - `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` 决定是否承接,承接的上游进入加权选择池。 +2. **模型匹配**(`route.ts filterCandidatesByModelRules`):根据每个候选上游的 `model_rules` 决定是否承接当前模型,不匹配的上游加入排除列表。 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`),由失败转移逻辑接住(见下一阶段)。 @@ -116,28 +116,28 @@ const allowedUpstreamIds = ## 阶段六:上游转发与流式包装 -转发函数:`forwardRequest(request, upstream, path, requestId, ...)`,`src/lib/services/proxy-client.ts:984`。流程如下: +转发函数:`forwardRequest(request, upstream, path, requestId, ...)`,`src/lib/services/proxy-client.ts:1004`。流程如下: -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`)。 +1. **header 处理**:调用 `filterHeaders`(`proxy-client.ts:234`)剔除 hop-by-hop header;调用 `injectAuthHeader`(`proxy-client.ts:255`)按上游配置注入正确的鉴权 header(部分上游用 `Authorization`、部分用 `x-api-key` 或 `x-goog-api-key`)。 +2. **发起请求**:通过 `fetch` 把改写后的请求体发到上游(`proxy-client.ts:1149`)。 3. **响应类型判定**:上游响应若带 `content-type: text/event-stream`,进入 SSE 流式分支;否则按非流式整体回传。 -SSE 分支的处理(`proxy-client.ts:1179` 起): +SSE 分支的处理(`proxy-client.ts:1185` 起): - `createSSETransformer`:把 chunk 解析为标准 `data: ...\n\n` 事件。 - `stream.tee()`:分出两路,一路给客户端、一路给日志侧用于提取 token 计数与 TTFT。 -- `waitForFirstStreamContent`(`proxy-client.ts:1210`):实现 first-byte 超时,避免上游长时间不吐第一块。 +- `waitForFirstStreamContent`(`proxy-client.ts:1230`):实现 first-byte 超时,避免上游长时间不吐第一块。 -回到 `handleProxy`,给客户端的那一路再被包一层 `wrapStreamWithConnectionTracking`(`route.ts:1975`): +回到 `handleProxy`,给客户端的那一路再被包一层 `wrapStreamWithConnectionTracking`(`route.ts:1981`): - 每次 `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`)。 +- `abortSignal.abort` 触发(典型场景:客户端关连接)时,调用 `reader.cancel` 并释放上游侧并发槽位(`route.ts:2038-2039`)。 +- 流正常完成后释放槽位(`route.ts:2063`),并 fire-and-forget 调 `markHealthy` 与 `recordSuccess` 通知健康与熔断模块(`route.ts:2072-2073`)。 **失败转移分两类,行为不一样**: -- **首字节前的失败(可重试)**(`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 中断」这一错误。 +- **首字节前的失败(可重试)**(`route.ts:1544` 起):上游返回响应头时如果 `shouldTriggerFailover(result.statusCode, config)` 为真(典型:5xx、特定错误码、连接超时),记录此次失败、释放连接、调 `markUnhealthy` 与 `recordFailure`,向本次请求的 `failoverHistory` 数组追加一条记录(`route.ts:1559`),把当前上游加入「已失败」集合,`continue` 重新进入阶段五选下一条候选。当且仅当全部候选都失败时,才向调用方返回最终错误。这一阶段的重试对调用方完全无感。 +- **流开始后的中断(不可重试)**(`route.ts:1603-1667`):一旦 `result.isStream === true`,函数直接 `return` 包装好的流给调用方(`route.ts:1657`),中途读流失败由 `wrapStreamWithConnectionTracking` 的回调(`route.ts:1618-1649`)交给 `settleStreamRuntimeFailureForCircuitBreaker` 处理——只更新日志、记录熔断失败、释放连接,**不会**回到阶段五选另一条上游接着吐 chunk。调用方此时看到的是一条提前结束的 SSE 流,需要自行处理「上游 stream 中断」这一错误。 `failoverHistory` 数组在请求结束时随日志一起写入 `requestLogs.failoverHistory` 字段,可在管理后台「请求日志」详情页查看每一次尝试的 upstream_id、错误类型、状态码与时间戳。流式中断的失败记录入口不在这个数组,而是写入流式日志更新(阶段七的 `metricsPromise.then(...)` 路径)。 @@ -145,9 +145,9 @@ SSE 分支的处理(`proxy-client.ts:1179` 起): **请求日志**:`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,用于异常分支。 +- `logRequestStart`(`request-logger.ts:364`):请求进入时**同步 await** 写入一行 `requestLogs`,token / latency 字段初始为 0,`statusCode` 字段初始为 null。 +- `updateRequestLog`(`request-logger.ts:412`):请求结束或失败时 await 更新同一行(非流式路径在 `route.ts:3669` 与 `route.ts:4051`)。SSE 流式路径下,token 与 TTFT 在 `metricsPromise.then(...)` 内异步算完后再更新(`route.ts:3471`),失败用 `.catch` 兜底为 fire-and-forget。 +- `logRequest`(`request-logger.ts:504`):无 `requestLogId` 时的兜底单次 INSERT,用于异常分支。 **计费**:`src/lib/services/billing-cost-service.ts`。 @@ -155,14 +155,14 @@ SSE 分支的处理(`proxy-client.ts:1179` 起): - 时机:日志写入后立即 **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 只通过管理后台「请求日志」回查。 +**响应 header 回写**:`route.ts:3198` 用 `new Headers(result.headers)` 拷贝得到响应 header,但 `result.headers` 不是上游原始 header 的 1:1 副本,已经经过 `proxy-client.ts` 两道处理——`proxy-client.ts:1170-1173` 的 inline 循环按 `HOP_BY_HOP_HEADERS` 集合过滤上游响应头去掉 hop-by-hop 字段(与请求侧 `filterHeaders` 是两段不同代码);当 undici 解压响应体时 `proxy-client.ts:1177-1179` 再删 `content-encoding` 与 `content-length`。SSE 分支额外强制写入 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`Connection: keep-alive`(`route.ts:3563-3565`)。代理层**不会**追加任何 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`),错误不阻塞调用方响应。 +- 时机:鉴权通过后立即按需读入请求体快照(`route.ts:2491`,`recorderEnabled ? await readRequestBody(request) : null`);响应完成后在日志写入后 `void recordTrafficFixture(...).catch(...)` 异步落盘(`route.ts:3802` 与 `route.ts:4040`),错误不阻塞调用方响应。 ## 时序总览 @@ -170,7 +170,7 @@ SSE 分支的处理(`proxy-client.ts:1179` 起): 客户端 │ POST /api/proxy/v1/chat/completions ▼ -[1] 方法分发 ──────────► handleProxy(route.ts:2434) +[1] 方法分发 ──────────► handleProxy(route.ts:2440) ▼ [2] CORS / OPTIONS(无自定义 handler;CORS_ORIGINS 当前无运行期效果) ▼ @@ -186,7 +186,7 @@ SSE 分支的处理(`proxy-client.ts:1179` 起): [5] 候选过滤 + 选路 受限模式 → apiKeyUpstreams 过滤 熔断状态 → filterByCircuitBreaker - 模型匹配 → model-router.ts + 模型匹配 → filterCandidatesByModelRules 加权随机 → selectWeightedWithHealthScore 申请准入 → acquireCircuitBreakerPermit(OPEN 抛 CircuitBreakerOpenError) ▼ diff --git a/docs/guide/architecture/security.md b/docs/guide/architecture/security.md index 668f90fa..1478ea2a 100644 --- a/docs/guide/architecture/security.md +++ b/docs/guide/architecture/security.md @@ -119,12 +119,12 @@ if (!config.allowKeyReveal) { } ``` -通过后调用 `revealApiKey`,内部在 `src/lib/utils/auth.ts:83-108` 先 `decrypt(encryptedKey)`,再用 `verifyApiKey(decryptedKey, keyHash)` 做 bcrypt 二次校验,防止数据库被篡改。 +通过后调用 `revealApiKey`,内部在 `src/lib/services/key-manager.ts:427-449` 接收 `keyId`,查库取出记录后先 `decrypt(keyValueEncrypted)`,再用 `verifyApiKey(decryptedKey, keyHash)` 做 bcrypt 二次校验,防止数据库被篡改。 `ALLOW_KEY_REVEAL` 默认 `false`(`config.ts:31`),即使管理员通过鉴权也无法揭示,需要显式开启。 ::: tip 历史 Legacy Key -存量数据中可能有只存了 `key_hash`、没有 `key_value_encrypted` 的 Legacy Key(早期版本)。`revealApiKey` 在 `auth.ts:84-86` 直接抛 `LegacyApiKeyError`,揭示路由返回 400「Legacy keys (bcrypt-only) cannot be revealed」。 +存量数据中可能有只存了 `key_hash`、没有 `key_value_encrypted` 的 Legacy Key(早期版本)。`revealApiKey` 在 `src/lib/services/key-manager.ts:436-441` 抛 `LegacyApiKeyError`(当 `keyValueEncrypted` 为空时),揭示路由返回 400「Legacy keys (bcrypt-only) cannot be revealed」。 ::: ## 上游 API Key Fernet 加密 @@ -173,9 +173,9 @@ base64 解码后必须恰好 32 字节,前 16 字节作 HMAC signing key,后 | ---------------- | ------------------------------------------------------------------------------ | | 创建时加密 | `src/lib/services/upstream-crud.ts:480`(`encrypt(apiKey)`) | | 更新时加密 | `upstream-crud.ts:575`(仅在请求带 `apiKey` 时改写) | -| 转发时解密 | `src/lib/services/proxy-client.ts:1293`(`decrypt(upstream.apiKeyEncrypted)`) | +| 转发时解密 | `src/lib/services/proxy-client.ts:1313`(`decrypt(upstream.apiKeyEncrypted)`) | | 健康检查解密 | `src/lib/services/health-checker.ts:283,559` | -| 管理面板掩码展示 | `upstream-crud.ts:759`(取明文后做星号掩码再返回) | +| 管理面板掩码展示 | `upstream-crud.ts:760`(取明文后做星号掩码再返回) | ### `ENCRYPTION_KEY` 丢失的影响 diff --git a/docs/guide/architecture/upstream-model.md b/docs/guide/architecture/upstream-model.md index 14a02e96..402f1114 100644 --- a/docs/guide/architecture/upstream-model.md +++ b/docs/guide/architecture/upstream-model.md @@ -74,7 +74,7 @@ interface UpstreamModelRule { - `regex`:`new RegExp(rule.value).test(model)` 全字段正则匹配 ::: 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。 +两者解析出的「目标模型名」只用于**过滤候选**、**写日志** 和 **计费价格解析** 三件事,**不会**改写客户端请求 body 里的 `model` 字段。`forwardRequest` 把原始 model 原样发给上游(`src/lib/services/proxy-client.ts:1004, 1116`),唯一会改写 body 的路径是 CLIProxyAPI 上游:当 `selectedUpstream.cliproxyAuthFileName` 存在时,代理层构造 `cliproxyModelOverride` 传给 `forwardRequest`(`route.ts:1519-1530, 1540`),由 `applyModelOverride` 改写 body。 这意味着:给一个普通 OpenAI 上游配置 `model_redirects: { "gpt-4o-mini": "gpt-4o" }`,客户端发 `gpt-4o-mini`,候选筛选与日志会按 `gpt-4o` 来,但实际打到上游的 body 里仍是 `gpt-4o-mini`。需要真正的服务端 model 改写时,应当在客户端层面解决,或者走 CLIProxyAPI 集成。 ::: @@ -115,12 +115,12 @@ API Key 的加解密统一通过 `src/lib/utils/encryption.ts` 提供的 `encryp `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`。 +路由层判断 provider 的依据是 `route_capabilities`,不是某个独立列。`getPrimaryProviderByCapabilities()`(`route-capabilities.ts:223`)按能力前缀映射出 `anthropic` / `openai` / `google`。 ::: ## 候选池构建:第一阶段(按 RouteCapability + 模型规则) -候选池的构建发生在 `handleProxy`(`src/app/api/proxy/v1/[...path]/route.ts:2434`)内部,按「能力 → API Key 授权 → 模型规则」三层过滤,最终交给 `selectFromUpstreamCandidates`。 +候选池的构建发生在 `handleProxy`(`src/app/api/proxy/v1/[...path]/route.ts:2440`)内部,按「能力 → API Key 授权 → 模型规则」三层过滤,最终交给 `selectFromUpstreamCandidates`。 ::: tip 关于 routeByModel `src/lib/services/model-router.ts:306` 的 `routeByModel(model)` 实现了一套基于模型名前缀(`claude-` / `gpt-` / `gemini-`)推断 provider type 再过滤候选的算法,但**当前运行期没有任何生产路径调用它**——全仓库 `routeByModel(` 仅匹配定义本身。代理路径采用的是下文描述的 `resolveRouteCapabilityCandidatePool` + `filterCandidatesByModelRules`,按客户端**请求路径**解析出的 `RouteCapability` 与 `model_rules` 进行匹配,与模型名前缀无关。阅读源码时如果落到 `routeByModel` 上,可以视为历史代码。 @@ -128,7 +128,7 @@ API Key 的加解密统一通过 `src/lib/utils/encryption.ts` 提供的 `encryp ### 步骤 1:按 RouteCapability + API Key 授权构建候选池 -`resolveRouteCapabilityCandidatePool`(`route.ts:661`)签名: +`resolveRouteCapabilityCandidatePool`(`route.ts:662`)签名: ```ts function resolveRouteCapabilityCandidatePool( @@ -139,9 +139,9 @@ function resolveRouteCapabilityCandidatePool( ): RouteCapabilityCandidatePool; ``` -`activeUpstreams` 是数据库查出的全部 `is_active=true` 上游(`route.ts:2648`);`allowedUpstreamIdSet` 在 `restricted` 模式下取 API Key 绑定的 `api_key_upstreams` 集合,`unrestricted` 模式下取全集(`route.ts:2651-2655`)。 +`activeUpstreams` 是数据库查出的全部 `is_active=true` 上游(`route.ts:2654`);`allowedUpstreamIdSet` 在 `restricted` 模式下取 API Key 绑定的 `api_key_upstreams` 集合,`unrestricted` 模式下取全集(`route.ts:2657-2661`)。 -过滤逻辑(`route.ts:667-668`): +过滤逻辑(`route.ts:668-670`): ```ts const capabilityCandidates = activeUpstreams.filter((upstream) => @@ -149,17 +149,17 @@ const capabilityCandidates = activeUpstreams.filter((upstream) => ); ``` -随后再用 `allowedUpstreamIdSet` 做授权过滤(`route.ts:670-672`),得到 `authorizedCapabilityCandidates`,并把这一层结果命名输出在 `RouteCapabilityCandidatePool`(`route.ts:653-659`): +随后再用 `allowedUpstreamIdSet` 做授权过滤(`route.ts:671-673`),得到 `authorizedCapabilityCandidates`,并把这一层结果命名输出在 `RouteCapabilityCandidatePool`(`route.ts:654-660`): - `capabilityCandidates`:能力匹配但不限授权 - `authorizedCapabilityCandidates`:能力匹配 + API Key 授权 - `candidateUpstreamIds`:上一层 ID 列表,是后续函数的实际输入 -主候选池在 `route.ts:2657` 构建。如果客户端命中的是 CLI 窄能力(`codex_cli_responses` / `claude_code_messages`),代理还会在 `route.ts:2665` 用 `getFallbackRouteCapability` 解析出的通用能力构建第二个 fallback 池,由 `shouldPreferGenericFallbackPool` 决定使用哪个。 +主候选池在 `route.ts:2663` 构建。如果客户端命中的是 CLI 窄能力(`codex_cli_responses` / `claude_code_messages`),代理还会在 `route.ts:2669` 用 `getFallbackRouteCapability` 解析出的通用能力构建第二个 fallback 池,由 `shouldPreferGenericFallbackPool` 决定使用哪个。 ### 步骤 2:按 model_rules 过滤候选 -`filterCandidatesByModelRules`(`route.ts:591`)以请求 body 里的 `model` 字段为输入: +`filterCandidatesByModelRules`(`route.ts:592`)以请求 body 里的 `model` 字段为输入: ```ts function filterCandidatesByModelRules( @@ -176,11 +176,11 @@ function filterCandidatesByModelRules( - 未命中且上游有显式规则(`hasExplicitRules: true`)→ 加入 `excluded`,理由 `"model_not_allowed"` - 未命中且上游没有任何规则(`hasExplicitRules: false`)→ **仍加入 `allowed`**(视为「不限制」) -这步调用在 `route.ts:2749`,紧跟主候选池构建之后;fallback 池切换时第二次调用在 `route.ts:3062`。 +这步调用在 `route.ts:2755`,紧跟主候选池构建之后;fallback 池切换时第二次调用在 `route.ts:3068`。 ### 步骤 3:resolvePathRoutingModelForUpstream 与规则合并 -每个候选上游被 `filterCandidatesByModelRules` 调用时,最终落到 `resolvePathRoutingModelForUpstream`(`route.ts:557`),它内部调用 `matchUpstreamModelRules` 完成实际匹配,返回: +每个候选上游被 `filterCandidatesByModelRules` 调用时,最终落到 `resolvePathRoutingModelForUpstream`(`route.ts:558`),它内部调用 `matchUpstreamModelRules` 完成实际匹配,返回: ```ts { @@ -197,7 +197,7 @@ function filterCandidatesByModelRules( ### 步骤 4:resolvedModel 的真实用途 -`resolvePathRoutingModelForUpstream` 返回的 `resolvedModel` 在四处被消费(`route.ts:2847, 3080, 3142, 3897`): +`resolvePathRoutingModelForUpstream` 返回的 `resolvedModel` 在四处被消费(`route.ts:2853, 3085, 3148, 3903`): 1. 决定 API Key 配额检查时用哪个 model 名(计费维度对齐) 2. 写入 `request_logs` 与 `RoutingDecisionLog.resolved_model` @@ -208,7 +208,7 @@ function filterCandidatesByModelRules( ### 步骤 5:候选 ID 列表交给 load-balancer -走到这里得到 `candidateUpstreamIds`(已通过 capability、API Key 授权、model_rules 三重过滤),由 `handleProxy` 在 `route.ts:3039` / `route.ts:3094`(fallback 路径)传给 `forwardWithFailover`,后者在 `route.ts:1380` 调用 `selectFromUpstreamCandidates` 进入第二阶段。 +走到这里得到 `candidateUpstreamIds`(已通过 capability、API Key 授权、model_rules 三重过滤),由 `handleProxy` 在 `route.ts:3045` / `route.ts:3100`(fallback 路径)传给 `forwardWithFailover`,后者在 `route.ts:1386` 调用 `selectFromUpstreamCandidates` 进入第二阶段。 ## load-balancer 选上游:第二阶段(按 tier + 加权) @@ -243,13 +243,13 @@ 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` 加权随机。 +最近一次记录的 `latency_ms`(来自 `upstream_health` 表)越大、分越低。但要注意:当前 `markHealthy` 调用点写入的 latency 固定为 `100`(`src/lib/services/health-checker.ts` + `route.ts:1601, 2072`),不是实测值。因此 `score` 在当前实现里基本恒为 1.0,加权采样近似等价于按 `upstream.weight` 加权随机。 加权抽样完成后输出的 `selectedUpstream` 即为本次实际转发目标。 ### Session affinity 与迁移 -`selectFromUpstreamPool`(`load-balancer.ts:795-939`)会先按 `(apiKeyId, routeCapability, sessionId)` 查 session 缓存: +`selectFromUpstreamPool`(`load-balancer.ts:764-950`)会先按 `(apiKeyId, routeCapability, sessionId)` 查 session 缓存: - 命中且目标可用 → 直接返回该上游,标记 `affinityHit: true` - 命中但当前优先级更高的上游可用 → 按 `upstream.affinityMigration.metric`(`tokens` 或 `length`)累计、与 `threshold` 比较,达到阈值才允许迁移,标记 `affinityMigrated: true` @@ -262,7 +262,7 @@ effectiveWeight = upstream.weight * score | `NoHealthyUpstreamsError` | 所有 tier 全部过滤后仍为空 | | `AllCandidatesConcurrencyFullError` | 候选池存在但全部 `concurrency_full`,可能携带等待句柄 | -错误类定义在 `load-balancer.ts:29, 39, 49`。`AllCandidatesConcurrencyFullError` 携带的 `waitableCandidate` 会被代理入口拿去做队列等待(`route.ts:1403-1463`),等待超时则抛 `UpstreamQueueWaitTimeoutError` 转 504,详见 [失败转移与熔断](./failover-circuit)。 +错误类定义在 `load-balancer.ts:29, 39, 49`。`AllCandidatesConcurrencyFullError` 携带的 `waitableCandidate` 会被代理入口拿去做队列等待(`route.ts:1409-1469`),等待超时则抛 `UpstreamQueueWaitTimeoutError` 转 504,详见 [失败转移与熔断](./failover-circuit)。 ## 健康状态与路由的关系 @@ -270,8 +270,8 @@ effectiveWeight = upstream.weight * score | 写入函数 | 触发点 | | ------------------------------------ | ------------------------------------------------------------------------------------------------- | -| `markHealthy(upstreamId, latencyMs)` | 请求成功(`route.ts:1595` 非流式;`route.ts:2066` 流式完成) | -| `markUnhealthy(upstreamId, reason)` | HTTP 非 2xx(`route.ts:1553`)、网络/超时错误(`route.ts:1716`)、流式中途错误(`route.ts:2097`) | +| `markHealthy(upstreamId, latencyMs)` | 请求成功(`route.ts:1601` 非流式;`route.ts:2072` 流式完成) | +| `markUnhealthy(upstreamId, reason)` | HTTP 非 2xx(`route.ts:1559`)、网络/超时错误(`route.ts:1722`)、流式中途错误(`route.ts:2103`) | ::: warning is_healthy 不直接参与路由 `load-balancer.ts:1033` 的 `filterByExclusions` 注释明确写着: @@ -285,13 +285,13 @@ effectiveWeight = upstream.weight * score | 入口 | 行号 | 作用 | | ------------------------------------------------------------------------------ | --------- | ---------------------------------- | -| `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/app/api/proxy/v1/[...path]/route.ts` `handleProxy` | 2440 | 代理主流程容器 | +| ↳ `resolveRouteCapability(method, path, headers)` | 2504 | 路径 → RouteCapability | +| ↳ `resolveRouteCapabilityCandidatePool` | 2663 | 按主能力 + API Key 授权构建候选池 | +| ↳ `getFallbackRouteCapability` + 副候选池 | 2669-2678 | CLI 能力降级路径 | +| ↳ `filterCandidatesByModelRules` | 2755 | 按 `model_rules` 过滤候选 | +| ↳ `forwardWithFailover(... candidateUpstreamIds ...)` | 3045 | 故障转移主循环 | +| `src/app/api/proxy/v1/[...path]/route.ts` `resolvePathRoutingModelForUpstream` | 558 | 实际匹配规则、产出 `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 过滤 + 加权抽样 | diff --git a/docs/guide/deployment/database.md b/docs/guide/deployment/database.md index 154a4b49..fdc18ce9 100644 --- a/docs/guide/deployment/database.md +++ b/docs/guide/deployment/database.md @@ -15,7 +15,7 @@ AutoRouter 同时维护 PostgreSQL 与 SQLite 两份 Drizzle schema,但二者 | ----------------------------- | ------------------------------------------------------- | -------------------------------------------- | | 适用场景 | 生产部署、所有公开发行版本 | 本地开发、单机演示、E2E 测试 | | Drizzle dialect | `postgresql` | `sqlite` | -| Schema 入口 | `src/lib/db/schema-pg.ts` | `src/lib/db/schema-sqlite.ts` | +| Schema 入口 | `src/lib/db/schema.ts`(barrel,经其导入 schema-pg.ts) | `src/lib/db/schema-sqlite.ts` | | Migration 目录 | `drizzle/` | `drizzle-sqlite/` | | Drizzle config | `drizzle.config.ts` | `drizzle-sqlite.config.ts` | | 连接来源 | `DATABASE_URL` | `SQLITE_DB_PATH`(默认 `./data/dev.sqlite`) | @@ -26,12 +26,12 @@ AutoRouter 同时维护 PostgreSQL 与 SQLite 两份 Drizzle schema,但二者 | 部署形态 | `docker-compose.yml` 默认启动 `postgres:16-alpine` 容器 | 应用进程直接读写本地文件 | ::: warning SQLite 不是平替 -`src/lib/db/index.ts:14` 的注释明确指出:SQLite 在结构上对常规 CRUD 兼容,但 `PERCENTILE_CONT` 等 PG 专用 SQL 在 SQLite 上不可用,统计聚合(`/api/admin/stats/*`)会有部分查询直接报错。任何生产部署都必须使用 PostgreSQL。SQLite 仅服务本地开发,避免在没有 Docker 的环境下也能跑 E2E。 +`src/lib/db/index.ts:13` 的注释明确指出:SQLite 在结构上对常规 CRUD 兼容;`index.ts:71` 进一步说明 `PERCENTILE_CONT` 等 PG 专用 SQL 在 SQLite 上不可用,统计聚合(`/api/admin/stats/*`)会有部分查询直接报错。任何生产部署都必须使用 PostgreSQL。SQLite 仅服务本地开发,避免在没有 Docker 的环境下也能跑 E2E。 ::: ## DB_TYPE 自动推断与 fail-fast -`src/lib/utils/config.ts:13` 把 `dbType` 默认为「有 `DATABASE_URL` 时取 `postgres`,否则取 `sqlite`」。也就是说: +`src/lib/utils/config.ts:72` 的 `loadConfig()` 函数把 `dbType` 默认为「有 `DATABASE_URL` 时取 `postgres`,否则取 `sqlite`」。也就是说: - 设置了 `DATABASE_URL` 而未显式声明 `DB_TYPE`:按 PostgreSQL 处理。 - 未设置 `DATABASE_URL` 也未显式声明 `DB_TYPE`:按 SQLite 处理。 @@ -104,7 +104,7 @@ Drizzle 把 schema 变更通过 SQL 迁移文件管理。常用命令对照: | 命令 | 作用 | | --------------------------- | ----------------------------------------------------------------------------- | -| `pnpm db:generate` | 比对 `schema-pg.ts` 与 `drizzle/` 现状,生成新的 PG 迁移文件 | +| `pnpm db:generate` | 比对 `schema.ts`(PG dialect)与 `drizzle/` 现状,生成新的 PG 迁移文件 | | `pnpm db:generate:sqlite` | 比对 `schema-sqlite.ts` 与 `drizzle-sqlite/` 现状,生成新的 SQLite 迁移文件 | | `pnpm db:migrate` | 把 `drizzle/` 下未 apply 的迁移按序施加到 `DATABASE_URL` 指向的 PG | | `pnpm db:migrate:sqlite` | 把 `drizzle-sqlite/` 下未 apply 的迁移施加到 `SQLITE_DB_PATH` 文件 | diff --git a/docs/guide/deployment/env-reference.md b/docs/guide/deployment/env-reference.md index 93480fbc..8e92af05 100644 --- a/docs/guide/deployment/env-reference.md +++ b/docs/guide/deployment/env-reference.md @@ -72,12 +72,12 @@ openssl rand -hex 32 ## 日志与可观测 -| 变量 | 必填 | 默认值 | 重启 | 说明 | -| -------------------- | ---- | ---------------------------------------- | ---- | ------------------------------------------------------------------------------------------------------ | -| `LOG_LEVEL` | 否 | 生产 `info`,开发 `debug` | 重启 | Pino 日志级别。可取值 `fatal` / `error` / `warn` / `info` / `debug` / `trace` | -| `LOG_RETENTION_DAYS` | 否 | `90` | 重启 | 请求日志保留天数。后台清理任务以该值为界 | -| `DEBUG_LOG_HEADERS` | 否 | `false` | 重启 | 是否在日志中输出请求头。仅排障时短时开启;含敏感字段,长期开启有泄露风险 | -| `CORS_ORIGINS` | 否 | `http://localhost:3000`(代码 fallback) | 重启 | 允许的跨域来源,逗号分隔;为空时退化到上述代码 fallback。`docker-compose.yml` 默认透传空值,由代码兜底 | +| 变量 | 必填 | 默认值 | 重启 | 说明 | +| -------------------- | ---- | ---------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LOG_LEVEL` | 否 | 生产 `info`,开发 `debug` | 重启 | Pino 日志级别。可取值 `fatal` / `error` / `warn` / `info` / `debug` / `trace` | +| `LOG_RETENTION_DAYS` | 否 | `90` | 重启 | 请求日志保留天数。后台清理任务以该值为界 | +| `DEBUG_LOG_HEADERS` | 否 | `false` | 重启 | 是否在日志中输出请求头。仅排障时短时开启;含敏感字段,长期开启有泄露风险 | +| `CORS_ORIGINS` | 否 | `http://localhost:3000`(代码 fallback) | 重启 | 逗号分隔的跨域来源列表;为空时退化到上述代码 fallback。**当前该变量仅被 `config.ts` 解析,未接入任何 CORS 中间件或响应头写入逻辑,设置后在运行期无实际效果。**`docker-compose.yml` 默认透传空值 | `HEALTH_CHECK_INTERVAL` 与 `HEALTH_CHECK_TIMEOUT` 是代码默认值 `30` 秒与 `10` 秒,目前未在 `.env.example` 中暴露,确有需要可通过 `.env` 注入。 @@ -106,9 +106,9 @@ openssl rand -hex 32 唯一仍生效的录制相关 env var 是文件目录: -| 变量 | 必填 | 默认值 | 重启 | 说明 | -| ----------------------- | ---- | ---------------- | ---- | ------------------------------------------------------------------------------------------------------------------- | -| `RECORDER_FIXTURES_DIR` | 否 | `tests/fixtures` | 重启 | 录制文件落盘目录。由 `resolveRecordingRoot()` / `getTrafficRecordingRoot()` 直接读取 env var,未走 Runtime Settings | +| 变量 | 必填 | 默认值 | 重启 | 说明 | +| ----------------------- | ---- | ------------------------------------------------------------------------------ | ---- | ------------------------------------------------------------------------------------------------------------------- | +| `RECORDER_FIXTURES_DIR` | 否 | 代码默认 `data/traffic-recordings`;docker-compose.yml 中默认 `tests/fixtures` | 重启 | 录制文件落盘目录。由 `resolveRecordingRoot()` / `getTrafficRecordingRoot()` 直接读取 env var,未走 Runtime Settings | ## CLIProxyAPI Sidecar(可选) diff --git a/docs/guide/deployment/github-actions.md b/docs/guide/deployment/github-actions.md index d799e64c..ce3a3573 100644 --- a/docs/guide/deployment/github-actions.md +++ b/docs/guide/deployment/github-actions.md @@ -11,13 +11,13 @@ outline: deep ## 工作流总览 -| 工作流文件 | 触发方式 | 职责 | -| --------------------------------------- | ---------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | -| `.github/workflows/release.yml` | 向 `master` 推送形如 `v*` 的 tag | 校验 tag 形式、生产构建、推送镜像到 `ghcr.io/g1331/autorouter`、生成 release | -| `.github/workflows/deploy-personal.yml` | 在 GitHub Actions 页面手工触发 `workflow_dispatch` | 拉取目标 release tag 对应的 `docker-compose.yml`,通过 SSH 在远端启动并 smoke | -| `.github/workflows/verify.yml` | 向 `master` 推送相关源码 / 配置 / 工作流变更,或对 `master` 开 PR 时 | ESLint、Prettier、`tsc`、Vitest、迁移一致性、代理稳定性、Playwright E2E | -| `.github/workflows/docs.yml` | `docs/**` / `README*` / `docs.yml` 自身 / `package.json` / `pnpm-lock.yaml` 变更时 | 构建 VitePress 站点,master 推送时部署到 GitHub Pages | -| `.github/workflows/dependabot-fix.yml` | Dependabot 在 `package.json` 上开 PR 时 | 重新生成 `pnpm-lock.yaml` 并回推到 PR 分支 | +| 工作流文件 | 触发方式 | 职责 | +| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| `.github/workflows/release.yml` | 向 `master` 推送形如 `v*` 的 tag | 校验 tag 形式、生产构建、推送镜像到 `ghcr.io/g1331/autorouter`、生成 release | +| `.github/workflows/deploy-personal.yml` | 在 GitHub Actions 页面手工触发 `workflow_dispatch` | 拉取目标 release tag 对应的 `docker-compose.yml`,通过 SSH 在远端启动并 smoke | +| `.github/workflows/verify.yml` | 向 `master` 推送相关源码 / 配置 / 工作流变更,或对 `master` 开 PR 时 | ESLint、Prettier、`tsc`、Vitest、迁移一致性、代理稳定性、Playwright E2E | +| `.github/workflows/docs.yml` | `docs/**` 等路径在 master 上的 push 或对 master 的 PR 时;亦可手工触发(workflow_dispatch)。Pages 部署仅在 push 到 master 时执行 | 构建 VitePress 站点,master 推送时部署到 GitHub Pages | +| `.github/workflows/dependabot-fix.yml` | Dependabot 在 `package.json` 上开 PR 时 | 重新生成 `pnpm-lock.yaml` 并回推到 PR 分支 | `release.yml` 与 `deploy-personal.yml` 是一对:前者把镜像发布出去,后者把镜像部署上线。`verify.yml` 与 `docs.yml` 在主干上做质量保障。`dependabot-fix.yml` 解决 Dependabot 不能正确处理 pnpm workspace 时 lockfile 不同步的问题。 @@ -87,11 +87,11 @@ permissions: 只能通过 GitHub Actions 页面手工触发 `workflow_dispatch`。三个输入字段(`.github/workflows/deploy-personal.yml:4-18`): -| 输入 | 含义 | 形式 | -| -------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `image_ref` | 要部署的镜像引用 | `v0.1.0`(自动补全 `ghcr.io/g1331/autorouter:` 前缀);或完整 `ghcr.io/...` 引用;或 `sha256:...` digest | -| `environment_name` | GitHub Environment 名称 | 默认 `personal-production`;作业会绑定到该 environment 的 secrets 与审批策略 | -| `confirm_release_id` | 用于二次确认的 release tag | 例如 `v0.1.0`。流水线会校验该 tag 存在、tag 指向的 commit 在 `origin/master` 路径上、对应 GitHub Release 也存在 | +| 输入 | 含义 | 形式 | +| -------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------- | +| `image_ref` | 要部署的镜像引用 | `v0.1.0`(自动补全 `ghcr.io/g1331/autorouter:` 前缀);或完整 `ghcr.io/...` 引用;或 `sha256:...` digest | +| `environment_name` | GitHub Environment 名称 | 默认 `personal-production`;作业会绑定到该 environment 的 secrets 与审批策略 | +| `confirm_release_id` | 用于二次确认的 release tag | 例如 `v0.1.0`。流水线会校验该 tag 存在、对应 GitHub Release 也存在 | `confirm_release_id` 是一道防呆——必须填写当前要部署的 release tag,且与 `image_ref` 配套。误输入会让流水线在 `validate` 阶段直接拒绝,避免把错版本推上服务器。 diff --git a/docs/guide/deployment/https-proxy.md b/docs/guide/deployment/https-proxy.md index b160781d..c3c14f6b 100644 --- a/docs/guide/deployment/https-proxy.md +++ b/docs/guide/deployment/https-proxy.md @@ -16,7 +16,7 @@ AutoRouter 的应用进程只在容器内监听明文 HTTP `3000` 端口,宿 1. **明文 HTTP 上游**:容器内监听 `3000`,宿主机映射到 `${PORT:-3331}`。反向代理与 AutoRouter 之间用明文 HTTP 即可,没必要再做一层 TLS。 2. **SSE / 流式响应**:`/api/proxy/v1/*` 当请求体携带 `stream: true` 时按 `text/event-stream` 返回长连接流。反向代理必须关闭对该路径的缓冲(`proxy_buffering off`),并把读超时调到分钟级,否则首字延迟会被代理缓冲、流式片段丢失或连接被提前关。 3. **长上传 / 大上下文请求体**:聊天接口的请求体在多轮长对话或携带 `tool_calls` 时可能突破默认上限(Nginx 默认 `client_max_body_size 1m`、Caddy 默认 32 MiB),需要按业务上调。 -4. **CORS**:AutoRouter 自身代码当前**不会**输出 `Access-Control-Allow-*` 响应头。`.env` 中的 `CORS_ORIGINS` 在 `src/lib/utils/config.ts:40-45` 里只是被解析了一次,没有运行期效果。需要跨域时一律由反向代理层注入响应头。 +4. **CORS**:AutoRouter 自身代码当前**不会**输出 `Access-Control-Allow-*` 响应头。`.env` 中的 `CORS_ORIGINS` 在 `src/lib/utils/config.ts:42-45` 里只是被解析了一次,没有运行期效果。需要跨域时一律由反向代理层注入响应头。 ::: warning CORS_ORIGINS 当前无运行期效果 当前代码里 `corsOrigins` 字段只在 config 解析阶段被读取,没有任何 route handler 或 middleware 据此输出 `Access-Control-Allow-Origin` / `Access-Control-Allow-Methods` 等响应头。若需要从浏览器跨域调用 `/api/proxy/v1/*` 或 `/api/admin/*`,必须在反向代理层(Nginx / Caddy)显式 `add_header Access-Control-Allow-Origin ""`。这是部署里最容易踩到的环节之一。 diff --git a/docs/guide/deployment/overview.md b/docs/guide/deployment/overview.md index 3456356c..b98eef81 100644 --- a/docs/guide/deployment/overview.md +++ b/docs/guide/deployment/overview.md @@ -41,11 +41,11 @@ image: ${AUTOROUTER_IMAGE:-ghcr.io/g1331/autorouter:latest} 适合个人长期服务器的「按 release 升级 / 按 tag 回滚」节奏。部署的触发方式不是 push,而是手工在 GitHub Actions 页面对 `deploy-personal.yml` 触发 `workflow_dispatch`,输入三个参数: -| 输入 | 含义 | 形式 | -| -------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------- | -| `image_ref` | 要部署的镜像引用 | `v0.1.0`(自动补全 `ghcr.io/g1331/autorouter:` 前缀),或 `ghcr.io/...` 完整引用,或 `sha256:...` digest | -| `environment_name` | GitHub Environment 名称 | 默认 `personal-production`,作业会绑定到该 environment 的 secrets 与审批策略 | -| `confirm_release_id` | 用于二次确认的 release tag | 例如 `v0.1.0`;作业会校验该 tag 存在并在 `origin/master` 路径上 | +| 输入 | 含义 | 形式 | +| -------------------- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `image_ref` | 要部署的镜像引用 | `v0.1.0`(自动补全 `ghcr.io/g1331/autorouter:` 前缀),或 `ghcr.io/...` 完整引用,或 `sha256:...` digest | +| `environment_name` | GitHub Environment 名称 | 默认 `personal-production`,作业会绑定到该 environment 的 secrets 与审批策略 | +| `confirm_release_id` | 用于二次确认的 release tag | 例如 `v0.1.0`;作业会校验该 tag 在本地可解析(`git rev-parse refs/tags/`)并且 GitHub 上存在对应 release(`gh release view`),不校验 tag 是否位于 `origin/master` 路径上 | 工作流通过 `appleboy/ssh-action` 登录目标服务器、`mkdir -p ${DEPLOY_DIR}`(默认 `/opt/autorouter`,可由 secret `DEPLOY_DIR` 覆盖)、`curl` 拉对应 tag 的 `docker-compose.yml`、首次部署时自动生成 `.env`(`POSTGRES_PASSWORD` / `ENCRYPTION_KEY` 由 `openssl rand` 随机填充,`ADMIN_TOKEN` 来自 GitHub secret),最后执行 `docker pull` 与 `docker compose up -d --remove-orphans`。完成后还会通过 `/api/health` 与一次完整代理 smoke test 校验部署可用性。 diff --git a/docs/guide/deployment/quickstart.md b/docs/guide/deployment/quickstart.md index 5237d2c6..faccff44 100644 --- a/docs/guide/deployment/quickstart.md +++ b/docs/guide/deployment/quickstart.md @@ -149,7 +149,9 @@ http://:3331/ 下面只列出最常见的几类启动期错误。完整排障路径参见后续「常见部署问题排查」与 [故障排查手册](../usage/troubleshooting)。 -`autorouter` 容器反复重启、日志含 `ENCRYPTION_KEY is required` 或 `ADMIN_TOKEN is required`:第三步的两个密钥未在 `.env` 中正确设置,或 `.env` 文件不在 `docker-compose.yml` 同目录下。 +`autorouter` 容器反复重启、日志含 `ENCRYPTION_KEY is required`:`ENCRYPTION_KEY` 未在 `.env` 中正确设置,或 `.env` 文件不在 `docker-compose.yml` 同目录下。 + +未设置 `ADMIN_TOKEN` 时容器仍会正常启动,但登录页输入任何 token 均会返回认证失败;请检查 `.env` 中 `ADMIN_TOKEN` 是否已正确填写。 `db` 容器 healthcheck 长时间不通过、日志含 `FATAL: password authentication failed`:`.env` 中 `POSTGRES_PASSWORD` 与 `DATABASE_URL` 内的密码不一致。`docker-compose.yml` 把两者分开读取,二者必须严格相同。 diff --git a/docs/guide/deployment/troubleshooting.md b/docs/guide/deployment/troubleshooting.md index 5ad49952..9b0a3a44 100644 --- a/docs/guide/deployment/troubleshooting.md +++ b/docs/guide/deployment/troubleshooting.md @@ -44,7 +44,7 @@ docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml logs --tail= #### `ENCRYPTION_KEY is required` 或长度校验失败 -`src/lib/utils/config.ts:23` 强制 `encryptionKey` 长度 44(base64 编码的 32 字节)。诊断: +`src/lib/utils/config.ts:23` 在 `ENCRYPTION_KEY` **已设置但长度不等于 44 字符**时于启动期触发 Zod 校验错误,导致容器退出。若 `ENCRYPTION_KEY` **完全缺失**,config.ts 的校验不会触发,容器可以正常启动,但第一次执行加密或解密操作时(例如创建上游、验证 API Key 等)才会抛出 `ENCRYPTION_KEY is required`(来源:`src/lib/utils/encryption.ts:51-55`)。诊断: ```bash grep "^ENCRYPTION_KEY=" .env @@ -60,9 +60,9 @@ grep "^ENCRYPTION_KEY=" .env 首次部署时 `ENCRYPTION_KEY` 是一次性事件——它锁定数据库中所有已加密字段的解密能力。如果当前数据库已经有上游配置,换一个新密钥意味着所有上游 API Key 都无法再解密。补救路径只剩下「逐条手工重填」。这种情况下优先从备份恢复原 `.env`,详见 [数据持久化与备份](./persistence-backup)。 ::: -#### `ADMIN_TOKEN is required` +#### 管理端所有请求返回 401 -`src/lib/utils/config.ts:25` 强制 `adminToken` 至少 1 个字符。修复方式同上,缺失就补。 +`src/lib/utils/config.ts:25` 将 `adminToken` 定义为 `.optional()`,缺少 `ADMIN_TOKEN` **不会导致容器退出**,容器可以正常启动。但 `validateAdminToken` 在 `adminToken` 为 `undefined` 时直接返回 `false`,因此所有 `/api/admin/*` 请求均返回 401。修复方式:在 `.env` 中补充 `ADMIN_TOKEN=<任意非空字符串>` 后重启。 #### `DATABASE_URL is required in production` @@ -202,9 +202,9 @@ docker compose -f docker-compose.yml -f docker-compose.cliproxy.yml exec cliprox `ENCRYPTION_KEY` 用 Fernet 算法加密下面这些字段,落地到 PG: -- `upstreams.api_key`:上游 provider 的 API Key -- `apiKeys.key_value`:客户端 API Key 的明文备份 -- `cliproxyInstances.client_api_key`、`cliproxyInstances.management_key`:CLIProxyAPI 凭据 +- `upstreams.api_key_encrypted`:上游 provider 的 API Key +- `apiKeys.key_value_encrypted`:客户端 API Key 的明文备份 +- `cliproxyInstances.client_api_key_encrypted`、`cliproxyInstances.management_key_encrypted`:CLIProxyAPI 凭据 - 其他敏感字段(按 schema 演进可能新增) ### 现象 diff --git a/docs/guide/deployment/upgrade-rollback.md b/docs/guide/deployment/upgrade-rollback.md index 669b9404..ae59caf6 100644 --- a/docs/guide/deployment/upgrade-rollback.md +++ b/docs/guide/deployment/upgrade-rollback.md @@ -117,7 +117,7 @@ docker compose up -d docker compose logs autorouter | grep -E '\[AutoRouter\] (Applying|Migrations completed)' ``` -日志中会看到 `Applying migration: _*.sql` 与 `Migrations completed` 两类行,确认迁移已经走到末尾即可。 +日志中会看到 `[AutoRouter] Applying migration: ` 与 `[AutoRouter] Migrations completed` 两类行,确认迁移已经走到末尾即可。 ### 破坏性迁移 @@ -199,15 +199,16 @@ docker compose up -d 正常的升级 / 回滚操作只动 `AUTOROUTER_IMAGE` 一行。其余字段保持原样: -| 字段 | 升级 / 回滚时是否需要变更 | -| ---------------------------------- | --------------------------------------------------------------------- | -| `AUTOROUTER_IMAGE` | 是。切到目标 tag 或 digest | -| `POSTGRES_*` / `DATABASE_URL` | 否。改这些会让新容器连不上现有数据库 | -| `ENCRYPTION_KEY` | 否。改这些会让原本加密的字段全部不可解 | -| `ADMIN_TOKEN` | 否。除非主动轮换;CI 部署模式下会被 secret 覆盖 | -| `PORT` | 否。除非有端口冲突需要换 | -| `CLIPROXY_*` | 否。除非随版本调整凭据 | -| `RECORDER_*`(已废弃为运行时配置) | 否。这些已经不再影响运行期行为,运行期开关在管理后台 Runtime Settings | +| 字段 | 升级 / 回滚时是否需要变更 | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| `AUTOROUTER_IMAGE` | 是。切到目标 tag 或 digest | +| `POSTGRES_*` / `DATABASE_URL` | 否。改这些会让新容器连不上现有数据库 | +| `ENCRYPTION_KEY` | 否。改这些会让原本加密的字段全部不可解 | +| `ADMIN_TOKEN` | 否。除非主动轮换;CI 部署模式下会被 secret 覆盖 | +| `PORT` | 否。除非有端口冲突需要换 | +| `CLIPROXY_*` | 否。除非随版本调整凭据 | +| `RECORDER_ENABLED` / `RECORDER_MODE` / `RECORDER_REDACT_SENSITIVE` | 否。这三个变量已迁移为运行时配置,由管理后台 Runtime Settings 控制,不再读取环境变量 | +| `RECORDER_FIXTURES_DIR` | 否。仍有效,控制录制文件存储目录;升级 / 回滚时通常无需改动 | 任何「需要顺手改一下密码 / 密钥」的需求与升级 / 回滚解耦:先单独完成密钥轮换并验证可用,再做版本切换。混在一起做出问题时难以定位是版本还是密钥的问题。 diff --git a/docs/guide/usage/admin-overview.md b/docs/guide/usage/admin-overview.md index c1eeae10..afc21fee 100644 --- a/docs/guide/usage/admin-overview.md +++ b/docs/guide/usage/admin-overview.md @@ -72,7 +72,7 @@ outline: deep ### 请求录制 -打开后,符合规则的请求会被写入 `RECORDER_FIXTURES_DIR`(默认 `tests/fixtures`)。录制的 fixture 可在非生产环境通过 `/api/mock/` 端点回放,用于在没有真实上游配额的情况下复现某次请求。`RECORDER_REDACT_SENSITIVE` 控制是否在录制时脱敏敏感字段;环境变量层是 fallback,本页面提供的 Runtime Settings 是更高优先级的运行期开关。 +打开后,符合规则的请求会被写入 `RECORDER_FIXTURES_DIR`(默认 `data/traffic-recordings`)。录制的 fixture 可在非生产环境通过 `/api/mock/` 端点回放,用于在没有真实上游配额的情况下复现某次请求。`RECORDER_REDACT_SENSITIVE` 控制是否在录制时脱敏敏感字段;环境变量层是 fallback,本页面提供的 Runtime Settings 是更高优先级的运行期开关。 ### 全局失败规则 diff --git a/docs/guide/usage/circuit-breaker-config.md b/docs/guide/usage/circuit-breaker-config.md index af62d788..7bbd975b 100644 --- a/docs/guide/usage/circuit-breaker-config.md +++ b/docs/guide/usage/circuit-breaker-config.md @@ -85,7 +85,7 @@ API 字段(管理 API 层接收以秒为单位的输入并转换为毫秒存 ### 规则命中的效果 -`matchFailureRule`(`upstream-failure-rules.ts:307`)按 `priority` 升序找第一条命中规则,返回 `MatchedFailureRule | null`。返回非 null 时: +`matchFailureRule`(`upstream-failure-rules.ts:342`)按 `priority` 升序找第一条命中规则,返回 `MatchedFailureRule | null`。返回非 null 时: - **failover 仍发生**:请求会换下一条上游继续重试。 - **熔断不计数**:`route.ts:1549-1557` 显式判断 `matchedFailureRule === null`,命中规则时跳过 `recordFailure(upstream, errorType)`。 @@ -94,11 +94,11 @@ API 字段(管理 API 层接收以秒为单位的输入并转换为毫秒存 ### 管理 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` | 列 / 建上游局部规则 | +| 方法 | 路径 | 用途 | +| ---------------- | ----------------------------------------- | ------------------- | +| `GET` / `POST` | `/api/admin/upstream-failure-rules` | 列 / 建全局规则 | +| `PUT` / `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`。 @@ -144,7 +144,7 @@ POST body 字段对应 `match` 结构(`upstream-failure-rules.ts:16-22`、`fai 源码:`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` 查询缓存。 +**UI 入口**:上游列表页(`src/app/[locale]/(dashboard)/upstreams/page.tsx:86`)可按 `circuit_open` 状态过滤;`useForceCircuitBreaker()` hook(`src/hooks/use-circuit-breaker.ts:33-58`)封装单个 mutation,通过 `action: 'open' | 'close'` 参数区分两种操作,按钮点击后自动 invalidate 该上游的 circuit-breaker detail 查询与 `upstreams` 查询缓存。 `force-open` 与 `force-close` 都不需要 body,仅需 `Authorization: Bearer ` 头。 diff --git a/docs/guide/usage/client-keys.md b/docs/guide/usage/client-keys.md index 117e6e29..535dab05 100644 --- a/docs/guide/usage/client-keys.md +++ b/docs/guide/usage/client-keys.md @@ -52,13 +52,13 @@ outline: deep | `description` | null | 备注信息,便于后续维护时回忆 Key 的用途 | | `spending_rules` | null | 该 Key 的消费限额规则。支持 `daily` / `monthly` / `rolling` 三种周期 | -过期判定(`src/app/api/proxy/v1/[...path]/route.ts:2463`)发生在每次代理请求鉴权时:`expiresAt && expiresAt < new Date()` 即返回 401。无需周期任务介入。 +过期判定(`src/app/api/proxy/v1/[...path]/route.ts:2469`)发生在每次代理请求鉴权时:`expiresAt && expiresAt < new Date()` 即返回 401。无需周期任务介入。 `spending_rules` 与上游的 `spending_rules` 含义类似,但作用对象是「该 Key 的累计消费」而非「该上游的累计消费」。 ## 保存:什么时候能看到完整密钥 -点击「保存」后,浏览器向 `POST /api/admin/keys` 发请求。服务端流程(`src/lib/services/key-manager.ts:254`): +点击「保存」后,浏览器向 `POST /api/admin/keys` 发请求。服务端流程(`src/lib/services/key-manager.ts:262`): 1. 生成密钥:`sk-auto-<43 字符 base64url>`,例如 `sk-auto-h3z9...`。前 12 个字符(含 `sk-auto-` 前缀)作为「key prefix」存储,便于日志展示。 2. 完整密钥用 bcrypt(12 轮)哈希后存入 `keyHash` 列,用于后续鉴权比对。 @@ -119,7 +119,7 @@ POST /api/admin/keys//reveal | 撤销 / 删除 | `DELETE /api/admin/keys/[id]` | UI 上的「撤销」按钮调用 DELETE 接口,由 `deleteApiKey` 从数据库移除该 Key 记录。不可恢复,但请求日志中的 Key 字段会以历史快照形式保留以便追溯 | ::: warning 撤销 = 删除记录 -当前实现里「撤销」与「删除」是同一个操作,调用 `DELETE /api/admin/keys/[id]` 把数据库记录真实抹掉(`src/app/api/admin/keys/[id]/route.ts:45`、`src/lib/services/key-manager.ts` 的 `deleteApiKey`)。如果你的合规或审计流程需要保留 Key 记录以便日后查阅,请使用「停用」(`is_active=false`)而不是「撤销」。 +当前实现里「撤销」与「删除」是同一个操作,调用 `DELETE /api/admin/keys/[id]` 把数据库记录真实抹掉(`src/app/api/admin/keys/[id]/route.ts:47`、`src/lib/services/key-manager.ts` 的 `deleteApiKey`)。如果你的合规或审计流程需要保留 Key 记录以便日后查阅,请使用「停用」(`is_active=false`)而不是「撤销」。 ::: ## 使用密钥发请求 @@ -136,7 +136,7 @@ curl -X POST http://:3331/api/proxy/v1/chat/completions \ }' ``` -AutoRouter 也支持额外两种 header 名称(`src/app/api/proxy/v1/[...path]/route.ts:2249`): +AutoRouter 也支持额外两种 header 名称(`src/app/api/proxy/v1/[...path]/route.ts:2255`): ``` Authorization: Bearer diff --git a/docs/guide/usage/cliproxy-egress-proxy.md b/docs/guide/usage/cliproxy-egress-proxy.md index 041931dd..41a4adfb 100644 --- a/docs/guide/usage/cliproxy-egress-proxy.md +++ b/docs/guide/usage/cliproxy-egress-proxy.md @@ -26,7 +26,7 @@ outline: deep └───────────────────────────────────────────────────────────────┘ ``` -`src/lib/services/proxy-client.ts:12-21` 的 `UpstreamForProxy` 接口字段里只有 `id` / `name` / `providerType` / `baseUrl` / `apiKey` / `timeout`,没有任何代理字段;同文件 `:139` 的 fetch 调用是 Node.js 原生 `fetch`,没有传 `dispatcher` 也没有读 `process.env.HTTP_PROXY`。这意味着即使在宿主机设了 `HTTPS_PROXY`,AutoRouter 的转发也不会走代理(Node `fetch` 默认行为)。 +`src/lib/services/proxy-client.ts:12-21` 的 `UpstreamForProxy` 接口字段里只有 `id` / `name` / `providerType` / `baseUrl` / `apiKey` / `timeout`,没有任何代理字段;同文件 `:1149` 的 fetch 调用是 Node.js 原生 `fetch`,没有传 `dispatcher` 也没有读 `process.env.HTTP_PROXY`。这意味着即使在宿主机设了 `HTTPS_PROXY`,AutoRouter 的转发也不会走代理(Node `fetch` 默认行为)。 也就是说: diff --git a/docs/guide/usage/cliproxy-modes.md b/docs/guide/usage/cliproxy-modes.md index 9dd39519..47106bef 100644 --- a/docs/guide/usage/cliproxy-modes.md +++ b/docs/guide/usage/cliproxy-modes.md @@ -85,7 +85,7 @@ if (mode === "external") { ### `enabled` 字段的实际语义 -`enabled` 字段在 schema、UI、管理后台 badge 都存在(`src/lib/db/schema-pg.ts:731`、`src/components/admin/cliproxy-instances-table.tsx:79-80`),但**当前版本任何路由 / 调度代码都没有读它**: +`enabled` 字段在 schema、UI、管理后台 badge 都存在(`src/lib/db/schema-pg.ts:729`、`src/components/admin/cliproxy-instances-table.tsx:79-80`),但**当前版本任何路由 / 调度代码都没有读它**: - 上游选路(`load-balancer.ts`)只看 `upstreams.is_active`,不看 `cliproxy_instances.enabled`。 - 池上游创建、连通性测试、OAuth 登录、账号同步等管理 API 也不在入口处检查 `enabled`。 @@ -95,7 +95,7 @@ if (mode === "external") { ### 删除实例的影响 -`DELETE /api/admin/cliproxy/instances/:id` 路由调用 `deleteCliproxyInstance`(`src/lib/services/cliproxy-instance-crud.ts:290-320`),删除前依次做两轮引用校验: +`DELETE /api/admin/cliproxy/instances/:id` 路由调用 `deleteCliproxyInstance`(`src/lib/services/cliproxy-instance-crud.ts:290-324`),删除前依次做两轮引用校验: ```ts // 1) 缓存账号引用校验 diff --git a/docs/guide/usage/first-upstream.md b/docs/guide/usage/first-upstream.md index 6cd82e79..1740533c 100644 --- a/docs/guide/usage/first-upstream.md +++ b/docs/guide/usage/first-upstream.md @@ -78,12 +78,12 @@ outline: deep ## 保存:发生了什么 -点击「保存」后,浏览器向 `POST /api/admin/upstreams` 发请求。服务端流程(`src/lib/services/upstream-crud.ts`): +点击「保存」后,浏览器向 `POST /api/admin/upstreams` 发请求。服务端流程(`src/app/api/admin/upstreams/route.ts` 校验层 + `src/lib/services/upstream-crud.ts` 持久化层): -1. Zod 校验通过的字段写入 `upstreams` 表。 -2. `api_key` 字段用 `ENCRYPTION_KEY` 做 Fernet 加密后写入 `apiKeyEncrypted` 列;明文不进数据库。 -3. 校验 `route_capabilities` 同 provider,规则不通过会返回 400。 -4. 写入成功后立即出现在上游列表中,状态为活跃。 +1. Zod 校验(含 `route_capabilities` 同 provider 校验),不通过直接返回 400,不会触碰数据库。 +2. 名称唯一性检查,已存在同名上游时返回错误。 +3. `api_key` 字段用 `ENCRYPTION_KEY` 做 Fernet 加密后写入 `apiKeyEncrypted` 列;明文不进数据库。 +4. 字段写入 `upstreams` 表,写入成功后立即出现在上游列表中,状态为活跃。 数据库约束之外,没有副作用——保存动作本身不会去触达上游,因此即使 base URL 写错或 API Key 失效,也能保存成功,问题要靠下一步连通性测试发现。 diff --git a/docs/guide/usage/invoke-models.md b/docs/guide/usage/invoke-models.md index 1cea94f8..389be65f 100644 --- a/docs/guide/usage/invoke-models.md +++ b/docs/guide/usage/invoke-models.md @@ -26,7 +26,7 @@ Content-Type: application/json ## 鉴权 header 支持的三种形式 -AutoRouter 按以下顺序尝试解析客户端 Key(`src/app/api/proxy/v1/[...path]/route.ts:2249`): +AutoRouter 按以下顺序尝试解析客户端 Key(`src/app/api/proxy/v1/[...path]/route.ts:2255`): ``` Authorization: Bearer @@ -58,7 +58,7 @@ AutoRouter 把客户端请求路径解析为「路由能力」,再从声明了 ## 流式与非流式 -OpenAI 协议下用请求体的 `stream` 字段切换(`src/app/api/proxy/v1/[...path]/route.ts:2409`): +OpenAI 协议下用请求体的 `stream` 字段切换(`src/app/api/proxy/v1/[...path]/route.ts:2415`): | `stream` 值 | 行为 | | --------------- | ------------------------------------------------------------------------------------------------------------------------ | @@ -206,16 +206,16 @@ print(response.text) ## 响应行为:透传 + 改写 -正常 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` 的两道处理: +正常 2xx 响应:AutoRouter 把上游响应体透传给调用方,响应 header 在 `src/app/api/proxy/v1/[...path]/route.ts:3198` 处由 `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. **去 hop-by-hop**:`proxy-client.ts:1168-1173` 的 inline 循环按 `HOP_BY_HOP_HEADERS` 集合过滤上游响应头,剔除 `connection`、`keep-alive`、`transfer-encoding` 等不应跨连接传递的字段(与 `filterHeaders` 处理请求侧 inbound header 是两段不同代码,不要混淆)。 +2. **去解压元数据**:当 undici 已经自动解压响应体时,`proxy-client.ts:1177-1180` 会同时删除 `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 通过管理后台的「请求日志」回查。响应体本身格式与上游完全一致,调用方不需要任何兼容层。 +也就是说调用方拿到的不是 1:1 的上游 header 副本。SSE 流式分支额外强制写入 `Content-Type: text/event-stream`、`Cache-Control: no-cache`、`Connection: keep-alive` 三个标准头(`route.ts:3563-3565`)。代理层**不会**追加 `X-AutoRouter-Request-Id` / `X-AutoRouter-Upstream-Id` 之类的自定义头;本次请求的 ID 与命中上游 ID 通过管理后台的「请求日志」回查。响应体本身格式与上游完全一致,调用方不需要任何兼容层。 错误响应分两类,调用方需要分别识别: -**鉴权阶段**(`src/app/api/proxy/v1/[...path]/route.ts:2446-2473`):发生在统一错误包装之前,响应体格式较朴素: +**鉴权阶段**(`src/app/api/proxy/v1/[...path]/route.ts:2452-2479`):发生在统一错误包装之前,响应体格式较朴素: ```json { "error": "Missing API key" } @@ -236,7 +236,7 @@ print(response.text) 状态码与错误码映射关系定义在 `src/lib/services/unified-error.ts`;以上仅列最常见者,完整枚举以源文件 `UnifiedErrorCode` 与 `STATUS_CODE_MAP` 为准。 -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、错误类型与时间戳。 +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` 看到本次请求的 `failover_history` 字段,记录每次尝试的上游 ID、错误类型与时间戳。 ## 模型字段的写法约束 diff --git a/docs/guide/usage/load-balancing.md b/docs/guide/usage/load-balancing.md index 052f4835..bd63d2ce 100644 --- a/docs/guide/usage/load-balancing.md +++ b/docs/guide/usage/load-balancing.md @@ -22,7 +22,7 @@ outline: deep | `affinity_migration` | `json` | `null` | Session Affinity Migration | 见下文 | | `is_active` | `boolean` | `true` | Active | 开关 | -`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` 字段不是凭名字猜的——它真实存在于 schema 并有专属索引(`src/lib/db/schema-pg.ts:126`)。UI 提示直接说明(`src/messages/en.json:744`):「Lower number = higher priority. Tier 0 is tried first, then tier 1, etc.」;权重的语义是(`src/messages/en.json:747`):「Higher weight = more requests routed to this upstream within the same tier」。 简记:**priority 决定优先级层、weight 决定同层内的比例**。 @@ -108,10 +108,10 @@ effectiveWeight = upstream.weight * score ### 与负载均衡的顺序 -`selectFromUpstreamPool`(`load-balancer.ts:795`)的顺序: +`selectFromUpstreamPool`(`load-balancer.ts:764`)的顺序: 1. 先看亲和缓存——命中且可用就返回。 -2. 命中但目标更高 priority 上游可用时,按 `shouldMigrate`(`:413`)判断是否迁移(具体由上游的 `affinity_migration` 字段控制,例如同一 tier 不迁移、跨 tier 迁移、内容长度阈值之类)。 +2. 命中但目标更高 priority 上游可用时,按 `shouldMigrate`(`session-affinity.ts:413`,由 `load-balancer.ts:973` 调用)判断是否迁移(具体由上游的 `affinity_migration` 字段控制,例如同一 tier 不迁移、跨 tier 迁移、内容长度阈值之类)。 3. 亲和未命中或不可用——降级到 `performTieredSelection`。 ## 熔断与并发对选路的影响 diff --git a/docs/guide/usage/logs-stats.md b/docs/guide/usage/logs-stats.md index fad4ee29..9b98edef 100644 --- a/docs/guide/usage/logs-stats.md +++ b/docs/guide/usage/logs-stats.md @@ -48,7 +48,7 @@ AutoRouter 的可观测性建立在两张表上:`request_logs` 记录每一次 | `cache_creation_1h_tokens` | Anthropic 1 小时 ephemeral cache 写入 | | `cache_read_tokens` | Anthropic cache 命中 | -Token 数据由 `extractNormalizedUsage`(`src/lib/services/proxy-client.ts:450`)从多家协议的 `usage` / `usageMetadata` 字段统一抽取,覆盖 OpenAI / Anthropic / Gemini / OpenAI Responses streaming。 +Token 数据由 `extractNormalizedUsage`(`src/lib/services/proxy-client.ts:468`)从多家协议的 `usage` / `usageMetadata` 字段统一抽取,覆盖 OpenAI / Anthropic / Gemini / OpenAI Responses streaming。 ### 路由与决策审计 @@ -87,7 +87,7 @@ Token 数据由 `extractNormalizedUsage`(`src/lib/services/proxy-client.ts:450 ``` client request ↓ -proxy route 决策完毕(route.ts:2959) +proxy route 决策完毕(route.ts:2965) ↓ logRequestStart() — INSERT 一行,status_code=NULL,duration_ms=NULL ↓ @@ -100,7 +100,7 @@ calculateAndPersistRequestBillingSnapshot() — 在 request_billing_snapshots publishRequestLogLiveUpdate() — 广播 SSE 事件给 /api/admin/logs/live 订阅者 ``` -部分非流式入口直接调 `logRequest()`(`request-logger.ts:467-519`)一次性 INSERT,跳过 in-progress 中间态。 +部分非流式入口直接调 `logRequest()`(`request-logger.ts:504-557`)一次性 INSERT,跳过 in-progress 中间态。 ### duration_ms 与 routing_duration_ms 的 clamp @@ -108,11 +108,11 @@ publishRequestLogLiveUpdate() — 广播 SSE 事件给 /api/admin/logs/live 订 Math.min(Math.max(0, input.durationMs), INT4_MAX); // INT4_MAX = 2_147_483_647 ``` -源码见 `request-logger.ts:21,411-417,489-491`。这层保护是 PR #170 / #171 的修复:早期版本 `duration_ms` 没有上界,长时间 stuck 的流式请求写入会超过 PostgreSQL `INT4` 上限直接 INSERT 失败,整条 log 丢失。clamp 之后超时请求虽然 `duration_ms` 失真为 24.8 天封顶,但日志能正常写入。 +源码见 `request-logger.ts:21,441-448,526-531`。这层保护是 PR #170 / #171 的修复:早期版本 `duration_ms` 没有上界,长时间 stuck 的流式请求写入会超过 PostgreSQL `INT4` 上限直接 INSERT 失败,整条 log 丢失。clamp 之后超时请求虽然 `duration_ms` 失真为 24.8 天封顶,但日志能正常写入。 ### Stale reconcile:520 兜底 -如果 in-progress 行长时间没被 update 回填(服务重启 / 进程 crash / 异常路径漏写),会留下永远 `status_code IS NULL` 的孤儿行。`request-logger.ts:524-569` 的 `reconcileStaleInProgressRequestLogs` 做兜底: +如果 in-progress 行长时间没被 update 回填(服务重启 / 进程 crash / 异常路径漏写),会留下永远 `status_code IS NULL` 的孤儿行。`request-logger.ts:562-607` 的 `reconcileStaleInProgressRequestLogs` 做兜底: | 常量 | 值 | 说明 | | ------------------------------ | --- | ------------------------------------------- | @@ -120,7 +120,7 @@ Math.min(Math.max(0, input.durationMs), INT4_MAX); // INT4_MAX = 2_147_483_647 | `REQUEST_LOG_STALE_SCAN_LIMIT` | 200 | 单次扫描上限,避免一次性处理过多行 | | stale status code | 520 | 标记为 HTTP 520 + `errorMessage` 写明超时窗 | -触发时机:每次 `listRequestLogs()` 与各 stats 函数被调用前自动跑(非 test 环境)。失败仅 warn 不中断(`:706-710`)。 +触发时机:每次 `listRequestLogs()` 与各 stats 函数被调用前自动跑(非 test 环境)。失败仅 warn 不中断(`:744-750`)。 读到 `status_code = 520` 不代表上游真返了 520,而是 reconcile 兜底标记,需要人工排查上一次重启 / crash 时是否有未回填的日志。 diff --git a/docs/guide/usage/model-routing.md b/docs/guide/usage/model-routing.md index 89db94bc..a642cdfb 100644 --- a/docs/guide/usage/model-routing.md +++ b/docs/guide/usage/model-routing.md @@ -32,12 +32,14 @@ AutoRouter 选择上游的决策依据并非「模型名前缀映射」这类预 | ----------------------------------------------------- | ----------------------------- | | `POST .../messages` | `messages`(先记下) | | `POST .../responses` | `responses`(先记下) | -| `GET\|POST .../chat/completions` 或 `GET v1/models` | `openai_chat_compatible` | +| `GET v1/models` | `openai_chat_compatible` | +| `POST .../chat/completions` | `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` | +| `POST v1internal:streamGenerateContent` | `gemini_code_assist_internal` | | 其他 / 含路径遍历 | `null` → 直接拒绝 | ### 步骤 2:客户端 profile 升级 @@ -75,17 +77,17 @@ AutoRouter 选择上游的决策依据并非「模型名前缀映射」这类预 ### 三种 rule type 的语义 -| `type` | 语义 | 是否改写模型名 | -| ------- | ------------------------------------------------------- | -------------- | -| `exact` | 客户端模型名严格等于 `value` 时匹配 | 否 | -| `regex` | 客户端模型名匹配 `value` 中的正则时匹配 | 否 | -| `alias` | 同上述任一形式匹配后,转发时把模型名换为 `target_model` | 是 | +| `type` | 语义 | 是否改写模型名 | +| ------- | ------------------------------------------------------------------------ | -------------- | +| `exact` | 客户端模型名严格等于 `value` 时匹配 | 否 | +| `regex` | 客户端模型名匹配 `value` 中的正则时匹配 | 否 | +| `alias` | 客户端模型名与 `value` 精确相等时匹配,转发时把模型名换为 `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`),返回四个字段: +`resolvePathRoutingModelForUpstream(originalModel, upstream)`(`src/app/api/proxy/v1/[...path]/route.ts:558`)是路由层使用的统一出口。内部调用 `matchUpstreamModelRules`(`upstream-model-rules.ts:326`),返回四个字段: | 字段 | 含义 | | ------------------ | ------------------------------------------------------------ | @@ -96,18 +98,18 @@ AutoRouter 选择上游的决策依据并非「模型名前缀映射」这类预 ### 「未显式拒绝即默认放行」语义 -整体过滤逻辑在 `filterCandidatesByModelRules`(`route.ts:591-624`): +整体过滤逻辑在 `filterCandidatesByModelRules`(`route.ts:592-625`): ```ts -// 摘自 route.ts:591-624 +// 摘自 route.ts:592-625 if (!originalModel) return { allowed: candidates, excluded: [] }; // 模型缺失 → 全部放行 for (const candidate of candidates) { - const r = resolvePathRoutingModelForUpstream(originalModel, candidate); - if (r.matched) { + const modelResolution = resolvePathRoutingModelForUpstream(originalModel, candidate); + if (modelResolution.matched) { allowed.push(candidate); continue; } - if (r.hasExplicitRules) { + if (modelResolution.hasExplicitRules) { excluded.push({ id: candidate.id, name: candidate.name, reason: "model_not_allowed" }); continue; } @@ -149,9 +151,9 @@ for (const candidate of candidates) { 客户端 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`)。 +`isModelAllowedByApiKey(requestedModel, allowedModels)`(`src/lib/api-key-models.ts:16`):`allowedModels` 为空或 null 直接放行;否则做精确字符串 `includes` 检查,命中失败的请求直接返回错误码 `API_KEY_MODEL_NOT_ALLOWED`(`route.ts:2513`)。 -`getApiKeyVisibleModelList`(`route.ts:626`)仅在 `GET /v1/models` 这种返回模型列表的请求里触发:对 Key 的 `allowedModels` 做过滤,保留其中**能被至少一个候选上游接受**的模型名(用 `resolvePathRoutingModelForUpstream(model, candidate).matched` 判断),返回交集。 +`getApiKeyVisibleModelList`(`route.ts:627`)仅在 `GET /v1/models` 这种返回模型列表的请求里触发:对 Key 的 `allowedModels` 做过滤,保留其中**能被至少一个候选上游接受**的模型名(用 `resolvePathRoutingModelForUpstream(model, candidate).matched` 判断),返回交集。 叠加规则三条: diff --git a/docs/guide/usage/request-recording.md b/docs/guide/usage/request-recording.md index ef449442..fd6dbfe0 100644 --- a/docs/guide/usage/request-recording.md +++ b/docs/guide/usage/request-recording.md @@ -26,7 +26,7 @@ outline: deep shouldRecordTraffic(outcome) === enabled && (mode === "all" || mode === outcome); ``` -每次代理请求单独调一次 `getTrafficRecordingSettings()`(`route.ts:2481`,每请求新查 DB,无 in-memory 缓存),所以**改设置立即生效,不需要重启**。 +每次代理请求单独调一次 `getTrafficRecordingSettings()`(`route.ts:2487`,每请求新查 DB,无 in-memory 缓存),所以**改设置立即生效,不需要重启**。 入口:管理后台 **系统 → 流量录制**(`/system/traffic-recording`,页面文件 `src/app/[locale]/(dashboard)/system/traffic-recording/page.tsx`)。 @@ -48,7 +48,7 @@ shouldRecordTraffic(outcome) === enabled && (mode === "all" || mode === outcome) {RECORDER_FIXTURES_DIR}/{provider}/{route}/{timestamp}.json ``` -- `provider` 与 `route` 经 `sanitizePathSegment()` 处理:非字母数字字符 → `_` +- `provider` 与 `route` 经 `sanitizePathSegment()` 处理:非字母数字及非 `.` `_` `-` 字符 → `_`(保留字母、数字、`.`、`_`、`-`,其余替换为 `_`) - `timestamp` 来自 `fixture.meta.createdAt`,`:` 与 `.` → `-` - 同目录额外写 `latest.json`,每次覆盖,永远指向最新一次录制 @@ -94,13 +94,13 @@ shouldRecordTraffic(outcome) === enabled && (mode === "all" || mode === outcome) | 行 | 行为 | | --------- | --------------------------------------------------------------------------------------------------- | -| 2481 | `await getTrafficRecordingSettings()` —— 每请求一次 DB 查询 | -| 2482-2485 | 计算 `shouldRecordSuccess` / `shouldRecordFailure` / `recorderEnabled` | -| 2485 | `recorderEnabled === true` 时才 `await readRequestBody(request)` 把请求体读进内存 | -| 3202 | `teeStreamForRecording(originalStream)` —— `ReadableStream.tee()` 分叉流,一路给 client,一路给录制 | -| 3597 | 流式成功路径:`return recordTrafficFixture(...)`,落盘在后台 `.then()` 里,client 响应已先行返回 | -| 3796 | 非流式成功路径:`void recordTrafficFixture(...).catch(...)` 显式 fire-and-forget | -| 4034 | 失败路径:`void recordTrafficFixture(...).catch(...)` 同上 | +| 2487 | `await getTrafficRecordingSettings()` —— 每请求一次 DB 查询 | +| 2488-2490 | 计算 `shouldRecordSuccess` / `shouldRecordFailure` / `recorderEnabled` | +| 2491 | `recorderEnabled === true` 时才 `await readRequestBody(request)` 把请求体读进内存 | +| 3208 | `teeStreamForRecording(originalStream)` —— `ReadableStream.tee()` 分叉流,一路给 client,一路给录制 | +| 3603 | 流式成功路径:`return recordTrafficFixture(...)`,落盘在后台 `.then()` 里,client 响应已先行返回 | +| 3802 | 非流式成功路径:`void recordTrafficFixture(...).catch(...)` 显式 fire-and-forget | +| 4040 | 失败路径:`void recordTrafficFixture(...).catch(...)` 同上 | **所有落盘均为 fire-and-forget**,client 端不阻塞等磁盘写入。读取请求体只在 `recorderEnabled === true` 时才发生,关闭录制时**不会**多产生 body 读取开销。 @@ -163,13 +163,13 @@ POST /api/admin/traffic-recordings/cleanup 查询参数: -| 参数 | 行为 | -| -------------------------- | -------------------------------------- | -| `provider=` | 切换 provider,默认 `"default"` | -| `mock_stream=1` | 按 SSE chunks 回放 | -| `mock_error=429` | 直接以指定状态码失败响应 | -| `mock_delay_ms=` | 在响应前注入延迟 | -| `mock_interrupt_after=` | 流式模式专用,回放 `n` 个 chunk 后中断 | +| 参数 | 行为 | +| -------------------------- | -------------------------------------------------------------- | +| `provider=` | 切换 provider,默认 `"default"` | +| `mock_stream=1` | 按 SSE chunks 回放 | +| `mock_error=429` | 仅支持 `429`;传入后固定返回 429 Rate Limited 错误,其他值无效 | +| `mock_delay_ms=` | 在响应前注入延迟 | +| `mock_interrupt_after=` | 流式模式专用,回放 `n` 个 chunk 后中断 | 主要用于: diff --git a/docs/guide/usage/troubleshooting.md b/docs/guide/usage/troubleshooting.md index 835b51cb..099327e7 100644 --- a/docs/guide/usage/troubleshooting.md +++ b/docs/guide/usage/troubleshooting.md @@ -105,11 +105,11 @@ outline: deep ### duration_ms 显示约 24.8 天 -`Math.min(Math.max(0, durationMs), INT4_MAX)` 的 clamp 上限是 2,147,483,647 ms ≈ 24.8 天(`request-logger.ts:21,411-417`)。读到这个值通常意味着原始 duration 异常大或溢出过 INT4,clamp 之后才能写库,**不是真的跑了 24.8 天**。配合 status_code 一起看,多半是 520(见下)或上游长时间 stuck。 +`Math.min(Math.max(0, durationMs), INT4_MAX)` 的 clamp 上限是 2,147,483,647 ms ≈ 24.8 天(`request-logger.ts:21,441-443`)。读到这个值通常意味着原始 duration 异常大或溢出过 INT4,clamp 之后才能写库,**不是真的跑了 24.8 天**。配合 status_code 一起看,多半是 520(见下)或上游长时间 stuck。 ### `status_code = 520`:stale 兜底 -`reconcileStaleInProgressRequestLogs`(`request-logger.ts:524-569`)把 15 分钟内仍是 `status_code IS NULL` 的非流式行标记为 520,`errorMessage = "Request did not settle before the stale reconciliation timeout window"`。 +`reconcileStaleInProgressRequestLogs`(`request-logger.ts:562-607`)把 15 分钟内仍是 `status_code IS NULL` 的非流式行标记为 520,`errorMessage = "Request did not settle before the stale reconciliation timeout window"`。 读到 520 **不是上游真的返了 520**,而是 reconcile 兜底。排查: @@ -134,7 +134,7 @@ outline: deep | 症状 | 根因 / 排查 | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/logs` 列表偶发卡顿,第一页 1-2 秒才出 | `listRequestLogs` 调用前会先跑一次 `reconcileStaleInProgressRequestLogs`(`request-logger.ts:706-710`),若上次 stale 行较多会拖慢首响应。失败仅 warn,不影响列表本身 | +| `/logs` 列表偶发卡顿,第一页 1-2 秒才出 | `listRequestLogs` 调用前会先跑一次 `reconcileStaleInProgressRequestLogs`(`request-logger.ts:744-749`),若上次 stale 行较多会拖慢首响应。失败仅 warn,不影响列表本身 | | Live 模式连不上 / 一直 `fallback` | `/api/admin/logs/live` SSE 是进程内 pub/sub,**多副本部署**下不跨实例。检查 LB 是否启用了粘性会话;本机直连测试 SSE 端点 | | 列表里出现一批 520 + duration_ms ≈ 24.8 天 | 见上一节「status_code = 520」 | | 列表过滤参数说不支持 model | `/api/admin/logs` 的 query 参数确实没有 `model`(`route.ts`)。按模型查只能走 leaderboard 或客户端 | diff --git a/openspec/project.md b/openspec/project.md index 76437304..f98aa178 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -2,50 +2,66 @@ ## Purpose -搭建基于 FastAPI(后端)与 Next.js(前端)的全栈脚手架,提供健康检查示例,支持在 monorepo 中快速扩展业务 API 与前端页面。 +AutoRouter 是一个 AI API 网关(AI API Gateway),面向多上游治理场景,提供客户端 API Key 分发与管理、按模型与请求能力的多上游自动路由、负载均衡、熔断与故障转移、并发与配额控制、请求日志与统计、按请求计费等能力。它是单一的 Next.js 全栈应用:同一进程同时承担管理后台 UI、管理 API 与面向调用方的 AI 代理 API,没有独立的后端服务。 ## Tech Stack -- 后端:Python 3.12.x、FastAPI、Uvicorn、SQLAlchemy(预置依赖)、Alembic(迁移) -- 前端:Next.js 16(App Router)、React 19、TypeScript 5 -- 构建与依赖:后端 hatchling + uv(虚拟环境与依赖管理);前端 pnpm(workspace) -- 质量工具:后端 Ruff(lint/format)、Pyright(strict 类型检查);前端 ESLint(with @typescript-eslint、next/core-web-vitals)、Prettier +- 框架:Next.js 16(App Router,standalone 构建)、React 19、TypeScript 5。后端逻辑由 Next.js API Routes 承担,前后端同属一个应用、同一域名。 +- 数据库与 ORM:Drizzle ORM。支持双数据库方言,PostgreSQL 16(默认,生产推荐)与 SQLite(本地开发沙箱)。迁移工具为 drizzle-kit,PG 迁移目录 `drizzle/`,SQLite 迁移目录 `drizzle-sqlite/`。 +- 国际化:next-intl,支持简体中文(`zh-CN`,默认)与英文(`en`),中间件文件为 `src/proxy.ts`。 +- 前端:shadcn/ui 组件体系、Tailwind CSS 4、TanStack Query(数据获取)、recharts(图表)、react-hook-form 与 zod(表单与校验)。 +- 安全:客户端 Key 使用 bcryptjs 哈希;上游凭据使用自实现的 Fernet 兼容加密(`src/lib/utils/encryption.ts`,密钥 44 字符 base64);配置校验使用 zod。 +- 日志:pino(结构化日志)。 +- 测试:Vitest(单元与组件测试,jsdom 环境)、Playwright(端到端测试)。 +- 构建与依赖:pnpm@9.12.0(无 monorepo workspace,依赖集中在根目录 `package.json`)。运行与构建使用 Node.js 22。 +- 质量工具:ESLint(`eslint.config.mjs`,flat config,集成 `eslint-config-next` 与 `@typescript-eslint`)、Prettier(printWidth 100)、`tsc --noEmit` 类型检查。 ## Project Conventions ### Code Style -- Python:Ruff 配置见 `apps/api/ruff.toml`,行宽 100,double quotes,target Python 3.12,isort 首方包 `app`。 -- TypeScript/React:ESLint 结合 `eslint-config-next` + `@typescript-eslint`,限制深层相对路径;Prettier 统一格式(printWidth 100,arrowParens=always,LF)。 -- 默认 UTF-8;禁止使用 Emoji。 +- TypeScript 全量 strict。ESLint 采用 flat config(`eslint.config.mjs`),Prettier 统一格式(printWidth 100,LF 行尾)。 +- 默认 UTF-8。仓库文档与产出物默认使用简体中文撰写;代码标识符、CLI 命令、日志与错误消息保留原始语言。 +- 数据库 schema 修改时,`src/lib/db/schema-pg.ts` 与 `src/lib/db/schema-sqlite.ts` 必须同步保持字段一致;业务代码统一按 PostgreSQL 类型编写。 ### Architecture Patterns -- Monorepo:`apps/api`(FastAPI 服务)、`apps/web`(Next.js 前端),`packages/` 预留复用包。 -- 配置集中:工作区定义 `pnpm-workspace.yaml`,后端配置集中在 `app/core/config.py`;环境变量通过 `.env`(示例待补充)。 -- 接口约定:后端 API 前缀 `/api`,示例路由 `/api/health`;前端通过 `NEXT_PUBLIC_API_BASE_URL` 与后端联调。 +- 单一 Next.js 应用:`src/app/api/` 为后端 API Routes(`admin/` 管理接口、`proxy/v1/[...path]` 代理入口、`health/` 健康探针、`mock/[...path]` 录制回放);`src/app/[locale]/(dashboard)/` 为需登录的管理页面,`src/app/[locale]/(auth)/login/` 为登录页。 +- 运行期业务逻辑集中在 `src/lib/services/`(代理转发、上游管理、能力路由与负载均衡、熔断与健康检查、计费、流量录制、后台同步、CLIProxyAPI 集成等)。 +- 数据访问层在 `src/lib/db/`:`schema.ts` 作为 barrel,按 `config.dbType` 在导入时分派到 `schema-pg.ts` 或 `schema-sqlite.ts`;`index.ts` 提供惰性初始化、方言感知的 `db` 客户端。 +- 配置集中在 `src/lib/utils/config.ts`,用 zod schema 加载并校验所有环境变量,导出单例 `config`。环境变量示例见仓库根目录 `.env.example`。 +- 代理选路以能力路由为主:`route-capability-matcher` 把请求路径映射为 `RouteCapability`,结合上游能力声明、Key 授权、模型规则、健康与熔断状态构建候选集,再由 `load-balancer` 做加权随机选择并处理并发与队列准入,`session-affinity` 维持会话粘性,失败时按 `failover` 配置转移到下一个候选上游。 ### Testing Strategy -- 后端:`pytest`(示例 `apps/api/tests/test_health.py`),`uv run ruff check`,`uv run pyright`。 -- 前端:`pnpm --filter web lint`,`pnpm --filter web format:check`。端到端或组件测试可后续引入 Vitest/Playwright(当前未加入)。 +- 单元与组件测试使用 Vitest:`pnpm test`(watch)、`pnpm test:run`(单次)、`pnpm test:run --coverage`(覆盖率)。 +- 端到端测试使用 Playwright:`pnpm e2e`(会自动起 SQLite 与 dev server)。 +- 类型检查使用 `pnpm exec tsc --noEmit`;静态检查与格式检查为 `pnpm lint` 与 `pnpm format:check`。 +- 数据库迁移一致性由 `pnpm db:check:consistency` 校验。涉及代码改动的任务必须补充对应测试。 ### Git Workflow -- 采用 trunk/主干开发:建议功能分支 -> PR -> 合并;提交信息遵循 Conventional Commits(已用 `chore: ...` 作为首个提交)。 -- 行尾换行:默认 LF(当前仓库在 Windows,如需保持 LF 可配置 `.gitattributes`)。 +- 主干开发:默认分支 `master`,功能分支 → PR → 合并;提交信息遵循 Conventional Commits。 +- 提交前由 `.pre-commit-config.yaml` 执行 prettier、eslint `--fix`、`tsc --noEmit` 等钩子;提交时不得跳过 pre-commit。 +- 涉及代码改动的 OpenSpec 任务,每个阶段(phase)完成后应提交代码,提交需通过质量门禁。 +- CI 工作流 `.github/workflows/verify.yml` 在 PR 上运行 lint、格式检查、类型检查、Vitest 覆盖率、生产构建、迁移一致性、代理稳定性冒烟与 Playwright E2E。 ## Domain Context -- 目前为通用脚手架,尚未绑定具体业务域;需根据后续产品需求补充领域模型与用例。 +- 上游(upstream)指具体的 AI 服务提供方或中转,例如 OpenAI、Anthropic、Google Gemini,以及通过 CLIProxyAPI 承接的 Codex / Claude / Gemini OAuth 账号池。上游配置(base URL、加密凭据、能力声明、权重、优先级、模型规则、失败规则等)持久化在数据库中。 +- 客户端 API Key 绑定可访问的上游集合与过期时间,并可设置消费配额;代理请求按 Key 鉴权后进入选路流程。 +- 计费按每次请求计算并写入费用快照,单价来源由后台同步任务维护,可叠加手动覆盖、阶梯规则与按上游倍率。 ## Important Constraints -- 后端依赖增删必须使用 `uv add/remove` 或 `uv lock`,不要手动编辑 `pyproject.toml` 依赖段。 -- Python 版本固定 3.12.x;前端依赖使用 pnpm 管理。 -- `.env`/密钥不提交;遵守 UTF-8,无 Emoji。 +- 生产环境若未显式设置 `DB_TYPE`,则必须设置 `DATABASE_URL`,否则启动时 fast-fail,不会静默回退到 SQLite。 +- `ENCRYPTION_KEY` 必须为 44 字符 base64(解码后 32 字节),可通过 `ENCRYPTION_KEY_FILE` 从挂载文件读入。该密钥一旦丢失,所有以 Fernet 加密的上游凭据将无法解密,必须安全备份。 +- 删除数据库文件、清空数据、重置状态等破坏性操作必须先与协作者确认后再执行。 +- 依赖增删使用 `pnpm add` / `pnpm remove`;不存在 Python 运行时、`uv` 或 `pyproject.toml`。 +- 许可证为 AGPL-3.0。 ## External Dependencies -- 开源库:FastAPI、Uvicorn、SQLAlchemy、Alembic、Pydantic Settings、Next.js、React、ESLint、Prettier。 -- 目前无外部第三方服务;若接入数据库/云服务需在此补充连接方式、认证方式与最小权限要求。 +- 核心运行时依赖(见 `package.json`):`next`、`react` / `react-dom`、`drizzle-orm`、`postgres`、`next-intl`、`zod`、`pino`、`bcryptjs`、`@tanstack/react-query`、`recharts`、`react-hook-form`、`date-fns`、`lucide-react`,以及 `@radix-ui/*` 与 `tailwindcss` 相关 UI 依赖。 +- 开发依赖:`@libsql/client`(SQLite 驱动)、`drizzle-kit`、`vitest` 与 `@vitest/coverage-v8`、`@playwright/test`、`@testing-library/*`、`eslint` 与 `eslint-config-next`、`prettier`、`tsx`、`vitepress`(文档站)。 +- 外部服务:上游 AI 服务由运行时在管理后台登记,凭据加密存储;可选的 CLIProxyAPI 以外部实例或受管 sidecar 形态接入。文档站基于 VitePress 构建并发布到 GitHub Pages。