Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 144 additions & 96 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)

<!-- Badges: Community -->
Expand Down
2 changes: 1 addition & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)

<!-- Badges: Community -->
Expand Down
2 changes: 1 addition & 1 deletion docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
},
Expand Down
20 changes: 9 additions & 11 deletions docs/circuit-breaker.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,31 +27,29 @@ 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
}
```

| Parameter | Default | Description |
| ------------------- | ------- | --------------------------------------------------------------- |
| `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

Expand Down Expand Up @@ -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

Expand Down
6 changes: 3 additions & 3 deletions docs/guide/architecture/cliproxy-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`),保证同步幂等。
Expand Down Expand Up @@ -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;
Expand All @@ -158,7 +158,7 @@ if (selectedUpstream.cliproxyAuthFileName && selectedUpstream.cliproxyInstanceId

判断条件是 `cliproxyAuthFileName` 和 `cliproxyInstanceId` 同时有值,即仅单账号映射上游会进入这一分支;普通上游和池上游都会跳过。

拼接后的 `<prefix>/<model>` 形态通过 `forwardRequest` 的 `modelOverride` 参数传到 `proxy-client.ts:896` 的 `applyModelOverride` 函数:OpenAI / Anthropic 协议改写 JSON body 中的 `model` 字段;Gemini 原生协议改写 URL 路径中的模型段(`proxy-client.ts:887` 的 `GEMINI_NATIVE_MODEL_SEGMENT` 正则匹配)。
拼接后的 `<prefix>/<model>` 形态通过 `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 不再额外注入前缀。
Expand Down
6 changes: 3 additions & 3 deletions docs/guide/architecture/database-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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。
:::

## 表清单
Expand Down Expand Up @@ -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` 加字段后,标准流程:

Expand Down
14 changes: 7 additions & 7 deletions docs/guide/architecture/failover-circuit.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 做探测。
:::

### 默认阈值
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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。

### 故障转移决策日志

Expand Down
8 changes: 4 additions & 4 deletions docs/guide/architecture/i18n.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
24 changes: 13 additions & 11 deletions docs/guide/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 行),后文「请求生命周期」会逐步展开它的内部流程。

## 服务模块清单

Expand Down Expand Up @@ -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` 提供原始快照。

## 数据模型

Expand Down Expand Up @@ -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) 阶段二)。
Expand All @@ -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 |

## 接下来读什么

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/architecture/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 分组

Expand Down
Loading
Loading