|
| 1 | +# API Key Rotation & Throttle Suppression |
| 2 | + |
| 3 | +## 用户使用方式 |
| 4 | + |
| 5 | +### 配置多个 API Key |
| 6 | + |
| 7 | +在 `OPENCODE_API_KEY` 中用英文逗号分隔多个 key: |
| 8 | + |
| 9 | +```bash |
| 10 | +export OPENCODE_API_KEY="sk-key1,sk-key2,sk-key3" |
| 11 | +opencode run "帮我写一个排序函数" |
| 12 | +``` |
| 13 | + |
| 14 | +opencode 会依次从 key1 开始尝试。遇到限额(429)或无效 key(401)时自动切换到下一个,对用户完全透明。 |
| 15 | + |
| 16 | +### 相关环境变量 |
| 17 | + |
| 18 | +| 变量 | 默认值 | 说明 | |
| 19 | +|---|---|---| |
| 20 | +| `OPENCODE_API_KEY` | 无 | 逗号分隔的 API key 列表 | |
| 21 | +| `OPENCODE_THROTTLE_ENABLE` | `true` | 设为 `false` 可禁用跨进程限流记录 | |
| 22 | +| `OPENCODE_THROTTLE_DURATION` | `120`(分钟) | 限流记录的有效期 | |
| 23 | +| `OPENCODE_WELAN_LOG` | `true` | 设为 `false` 可禁用 welan.txt 日志 | |
| 24 | + |
| 25 | +### 跨进程限流(多个 opencode 进程共存时) |
| 26 | + |
| 27 | +限流状态保存在 `~/.config/opencode/throttle.json`。当某个 key 触发 429 时,当前进程把该 key 写入此文件并附上失效时间。其他进程启动时读取该文件,自动跳过仍在限流期内的 key——无需任何手动操作。 |
| 28 | + |
| 29 | +``` |
| 30 | +~/.config/opencode/ |
| 31 | +├── throttle.json # 跨进程限流状态(自动管理) |
| 32 | +└── welan-log.txt # key 轮转决策日志 |
| 33 | +``` |
| 34 | + |
| 35 | +### 日志(welan.txt) |
| 36 | + |
| 37 | +`~/.config/opencode/welan-log.txt` 记录每次 key 轮转的决策,方便排查问题: |
| 38 | + |
| 39 | +``` |
| 40 | +[012300000Z] [2026-07-10T09:23:00.000] [INFO] key-rotation: start, 2 key(s) configured |
| 41 | +[012300000Z] [2026-07-10T09:23:05.000] [WARN] key-rotation: key ***key-1 throttled for 120 minutes, writing throttle record |
| 42 | +[012300000Z] [2026-07-10T09:23:05.000] [WARN] key-rotation: key ***key-1 quota_limit, trying next |
| 43 | +[012300000Z] [2026-07-10T09:23:05.000] [INFO] key-rotation: attempt 2 with key ***key-2 |
| 44 | +[012300000Z] [2026-07-10T09:23:10.000] [INFO] key-rotation: success with key ***key-2 |
| 45 | +``` |
| 46 | + |
| 47 | +Key 在日志中脱敏,只显示最后 6 位。第二列(如 `[012300000Z]`)是进程级前缀,同一 CLI 进程的所有日志共享该值,方便在多进程并发时区分来源。 |
| 48 | + |
| 49 | +--- |
| 50 | + |
| 51 | +## 内部实现 |
| 52 | + |
| 53 | +### 模块结构 |
| 54 | + |
| 55 | +``` |
| 56 | +packages/opencode/src/ |
| 57 | +├── provider/ |
| 58 | +│ ├── throttle-store.ts # 读写 throttle.json,Flock 写锁 |
| 59 | +│ ├── rotation-logger.ts # 追加写 welan-log.txt,fire-and-forget |
| 60 | +│ └── key-rotator.ts # 解析 key 列表,管理轮转状态 |
| 61 | +├── session/ |
| 62 | +│ └── retry.ts # isInvalidKeyAPIError(401 检测) |
| 63 | +└── cli/cmd/ |
| 64 | + └── run.ts # runWithKeyRotation 轮转主循环 |
| 65 | +``` |
| 66 | + |
| 67 | +这三个新模块没有任何 Effect / Provider / Session 依赖,可以单独使用。 |
| 68 | + |
| 69 | +### 轮转流程 |
| 70 | + |
| 71 | +``` |
| 72 | +opencode run "prompt" |
| 73 | + │ |
| 74 | + ▼ |
| 75 | +runWithKeyRotation(createSdk) |
| 76 | + │ |
| 77 | + ├─ KeyRotator.selectKey() |
| 78 | + │ ├─ 读 throttle.json(无锁) |
| 79 | + │ ├─ 跳过限流期内的 key |
| 80 | + │ └─ 跳过本进程已标记无效的 key |
| 81 | + │ |
| 82 | + ├─ process.env.OPENCODE_API_KEY = selectedKey |
| 83 | + ├─ disposeInstance(directory) ← 仅在第 2 次以后调用 |
| 84 | + ├─ Server.Default.reset() ← 仅在第 2 次以后调用 |
| 85 | + │ └─ 清除目录级 InstanceState 与惰性 Server,下次 fetch 读新 key |
| 86 | + │ |
| 87 | + ├─ execute(sdk, overrideSessionID) ← 透传上一次的 sessionID |
| 88 | + │ └─ 遇到可轮换错误时抛出 KeyRotationRetry |
| 89 | + │ |
| 90 | + ├─ quota_limit → KeyRotator.recordThrottle(key) → 写 throttle.json → 换 key |
| 91 | + ├─ invalid_key → KeyRotator.markInvalid(key) → 进程内跳过 → 换 key |
| 92 | + └─ success → 结束 |
| 93 | +``` |
| 94 | + |
| 95 | +### 关键设计决策 |
| 96 | + |
| 97 | +**disposeInstance() + Server.Default.reset()** — Provider key 缓存在目录级 `InstanceState` 中。换 key 前先清除当前目录的 InstanceState,再 reset 惰性 Server,下一次 HTTP 请求会用新的 `process.env.OPENCODE_API_KEY` 重建本地执行路径。这样不需要在 Provider/Env 层监听全局 env 变化。 |
| 98 | + |
| 99 | +**KeyRotationRetry** — `execute()` 保留原来的成功/失败返回语义。只有 429/quota/rate-limit 和 401 这两类可轮换错误会抛出 `KeyRotationRetry`,由外层 `runWithKeyRotation()` 捕获并换 key。 |
| 100 | + |
| 101 | +**Session ID 透传** — `overrideSessionID` 在轮转后传给下一次 `execute()`,使新 key 的请求继续使用同一个 SQLite session,保留上下文。 |
| 102 | + |
| 103 | +**锁策略** — `isThrottled`(读)不加锁,宁可偶发读到旧数据也不阻塞 API 调用。`addThrottle` / `cleanExpired`(写)使用 `Flock.acquire`,超时 2s 后放弃写入(宁漏记,不阻塞)。进程崩溃导致的僵尸锁通过 `staleMs: 10_000` 自动清理。 |
| 104 | + |
| 105 | +**throttleEnabled 默认开启** — `OPENCODE_THROTTLE_ENABLE !== "false"`,未设置时视为开启。`retry.ts` 中的同名判断也用同一逻辑,确保 429 能直接冒泡到外层轮转循环,而不是在 SDK 内部重试同一个已限流的 key。 |
0 commit comments