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
24 changes: 17 additions & 7 deletions docs/key-rotation.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ key 轮转决策会写两份日志:

```
[012300000Z] [2026-07-10T09:23:00.000] [INFO] key-rotation: start, 2 key(s) configured
[012300000Z] [2026-07-10T09:23:05.000] [WARN] key-rotation: key ***key-1 throttled for 120 minutes, writing throttle record
[012300000Z] [2026-07-10T09:23:05.000] [WARN] key-rotation: key ***key-1 quota_limit, trying next
[012300000Z] [2026-07-10T09:23:05.000] [ERROR] key-rotation: key ***key-1 throttled for 120 minutes, writing throttle record
[012300000Z] [2026-07-10T09:23:05.000] [ERROR] key-rotation: key ***key-1 quota_limit, trying next
[012300000Z] [2026-07-10T09:23:05.000] [INFO] key-rotation: attempt 2 with key ***key-2
[012300000Z] [2026-07-10T09:23:10.000] [INFO] key-rotation: success with key ***key-2
```
Expand All @@ -54,9 +54,19 @@ Key 在日志中脱敏,只显示最后 6 位。第二列(如 `[012300000Z]`
`opencode.log` 示例:

```
timestamp=2026-07-10T09:23:05.000Z level=WARN run=abcd1234 message="key-rotation: key ***key-1 quota_limit, trying next"
timestamp=2026-07-10T09:23:05.000Z level=ERROR run=abcd1234 message="key-rotation: key ***key-1 quota_limit, trying next"
```

### CLI 错误输出

单 key 命中已识别的 quota/rate-limit 时,CLI 在 stderr 输出:

```text
Error: OPENCODE_QUOTA_LIMIT: <provider 或 retry 消息>
```

多 key 轮转中,某个 key 命中 quota 而后续 key 成功时,CLI 不输出该中间错误。所有 key 最终耗尽时,CLI 只输出一次 `OPENCODE_QUOTA_LIMIT` 前缀;若最后一次请求有 provider 消息则保留该消息,否则输出 `all configured API keys are exhausted or throttled`。最后终态是无效 key 时,输出 `OPENCODE_INVALID_API_KEY: <message>`。

---

## 内部实现
Expand All @@ -72,7 +82,7 @@ packages/opencode/src/
├── session/
│ └── retry.ts # isInvalidKeyAPIError(401 检测)
└── cli/cmd/
└── run.ts # runWithKeyRotation 轮转主循环
└── run/key-rotation.ts # runWithKeyRotation 轮转主循环
```

这三个新模块没有任何 Effect / Provider / Session 依赖,可以单独使用。
Expand All @@ -83,7 +93,7 @@ packages/opencode/src/
opencode run "prompt"
runWithKeyRotation(createSdk)
runWithKeyRotation({ createSdk, execute, reset, onExhausted })
├─ KeyRotator.selectKey()
│ ├─ 读 throttle.json(无锁)
Expand All @@ -95,7 +105,7 @@ runWithKeyRotation(createSdk)
├─ Server.Default.reset() ← 仅在第 2 次以后调用
│ └─ 清除目录级 InstanceState 与惰性 Server,下次 fetch 读新 key
├─ execute(sdk, overrideSessionID) ← 透传上一次的 sessionID
├─ execute(sdk)
│ └─ 遇到可轮换错误时抛出 KeyRotationRetry
├─ quota_limit → KeyRotator.recordThrottle(key) → 写 throttle.json → 换 key
Expand All @@ -109,7 +119,7 @@ runWithKeyRotation(createSdk)

**KeyRotationRetry** — `execute()` 保留原来的成功/失败返回语义。只有 429/quota/rate-limit 和 401 这两类可轮换错误会抛出 `KeyRotationRetry`,由外层 `runWithKeyRotation()` 捕获并换 key。

**Session ID 透传** — `overrideSessionID` 在轮转后传给下一次 `execute()`,使新 key 的请求继续使用同一个 SQLite session,保留上下文
**Session 复用** — 显式传入 session ID 时,每次 `execute()` 都按原始 CLI 参数解析并复用同一 session。未传入 session ID 时,每次轮转沿用原有 CLI 语义创建新 session;失败 key 创建但未成功执行的 session 不会被下一次尝试继承

**锁策略** — `isThrottled`(读)不加锁,宁可偶发读到旧数据也不阻塞 API 调用。`addThrottle` / `cleanExpired`(写)使用 `Flock.acquire`,超时 2s 后放弃写入(宁漏记,不阻塞)。进程崩溃导致的僵尸锁通过 `staleMs: 10_000` 自动清理。

Expand Down
46 changes: 33 additions & 13 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,19 +36,23 @@ function keyRotationActive() {
return process.env.OPENCODE_KEY_ROTATION_ACTIVE === "true"
}

function throwKeyRotation(error: unknown) {
if (SessionRetry.isKeyRotationQuotaError(error)) throw keyRotationRetry("quota_limit")
if (SessionRetry.isInvalidKeyAPIError(error)) throw keyRotationRetry("invalid_key")
function quotaError(message: string) {
return `OPENCODE_QUOTA_LIMIT: ${message}`
}

function noteRotationFromError(error: unknown, pendingRotation: { current?: KeyRotationRetry }) {
function throwKeyRotation(error: unknown, message: string) {
if (SessionRetry.isKeyRotationQuotaError(error)) throw keyRotationRetry("quota_limit", message)
if (SessionRetry.isInvalidKeyAPIError(error)) throw keyRotationRetry("invalid_key", message)
}

function noteRotationFromError(error: unknown, pendingRotation: { current?: KeyRotationRetry }, message: string) {
if (!keyRotationActive()) return false
if (SessionRetry.isKeyRotationQuotaError(error)) {
pendingRotation.current = keyRotationRetry("quota_limit")
pendingRotation.current = keyRotationRetry("quota_limit", message)
return true
}
if (SessionRetry.isInvalidKeyAPIError(error)) {
pendingRotation.current = keyRotationRetry("invalid_key")
pendingRotation.current = keyRotationRetry("invalid_key", message)
return true
}
return false
Expand Down Expand Up @@ -809,12 +813,13 @@ export const RunCommand = effectCmd({
}
error = error ? error + EOL + err : err
const limit = SessionRetry.isQuotaOrRateLimitAPIError(props.error)
if (noteRotationFromError(props.error, pendingRotation)) break
const quota = SessionRetry.isKeyRotationQuotaError(props.error)
if (noteRotationFromError(props.error, pendingRotation, err)) break
if (emit("error", { error: props.error })) {
if (limit) return error
continue
}
UI.error(err)
UI.error(quota ? quotaError(err) : err)
if (limit) return error
}

Expand All @@ -827,7 +832,7 @@ export const RunCommand = effectCmd({
break
}
if (emit("error", { error: status })) return error
UI.error(status.message)
UI.error(quotaError(status.message))
return error
}
if (status.type === "idle") {
Expand Down Expand Up @@ -890,8 +895,11 @@ export const RunCommand = effectCmd({
variant: args.variant,
})
if (result.error) {
if (keyRotationActive()) throwKeyRotation(result.error)
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
const message = formatRunError(result.error)
if (keyRotationActive()) throwKeyRotation(result.error, message)
if (!emit("error", { error: result.error })) {
UI.error(SessionRetry.isKeyRotationQuotaError(result.error) ? quotaError(message) : message)
}
process.exitCode = 1
return
}
Expand All @@ -908,8 +916,11 @@ export const RunCommand = effectCmd({
parts: [...files, { type: "text", text: message }],
})
if (result.error) {
if (keyRotationActive()) throwKeyRotation(result.error)
if (!emit("error", { error: result.error })) UI.error(formatRunError(result.error))
const message = formatRunError(result.error)
if (keyRotationActive()) throwKeyRotation(result.error, message)
if (!emit("error", { error: result.error })) {
UI.error(SessionRetry.isKeyRotationQuotaError(result.error) ? quotaError(message) : message)
}
process.exitCode = 1
return
}
Expand Down Expand Up @@ -1002,6 +1013,15 @@ export const RunCommand = effectCmd({
await disposeInstance(directory ?? root)
Server.Default.reset()
},
onExhausted: (error) => {
if (error?.reason === "invalid_key") {
UI.error(`OPENCODE_INVALID_API_KEY: ${error.message ?? "all configured API keys are invalid"}`)
return
}
UI.error(
quotaError(error?.message ?? "all configured API keys are exhausted or throttled"),
)
},
})
})
}),
Expand Down
12 changes: 9 additions & 3 deletions packages/opencode/src/cli/cmd/run/key-rotation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { isKeyRotationRetry } from "@/provider/key-rotation-retry"
import { isKeyRotationRetry, type KeyRotationRetry } from "@/provider/key-rotation-retry"
import { KeyRotator, parseKeys } from "@/provider/key-rotator"
import { RotationLogger } from "@/provider/rotation-logger"

type KeyRotationOptions<T> = {
createSdk: () => T
execute: (sdk: T) => Promise<void>
reset: () => Promise<void>
onExhausted: (error?: KeyRotationRetry) => void
}

export async function runWithKeyRotation<T>(options: KeyRotationOptions<T>): Promise<void> {
Expand All @@ -19,10 +20,12 @@ export async function runWithKeyRotation<T>(options: KeyRotationOptions<T>): Pro
RotationLogger.log("info", `key-rotation: start, ${keys.length} key(s) configured`)

let attempt = 0
let lastError: KeyRotationRetry | undefined
while (true) {
const key = await rotator.selectKey()
if (!key) {
RotationLogger.log("error", "key-rotation: all OPENCODE_API_KEY keys exhausted or throttled")
options.onExhausted(lastError)
process.exitCode = 1
return
}
Expand All @@ -39,13 +42,15 @@ export async function runWithKeyRotation<T>(options: KeyRotationOptions<T>): Pro
await options.execute(options.createSdk())
} catch (error) {
if (!isKeyRotationRetry(error)) throw error
lastError = error
if (error.reason === "quota_limit") {
await rotator.recordThrottle(key)
RotationLogger.log(
"warn",
"error",
`key-rotation: key ***${key.slice(-6)} quota_limit, ${rotator.hasAlternative(key) ? "trying next" : "no more keys"}`,
)
if (!rotator.hasAlternative(key)) {
options.onExhausted(error)
process.exitCode = 1
return
}
Expand All @@ -54,10 +59,11 @@ export async function runWithKeyRotation<T>(options: KeyRotationOptions<T>): Pro
}
rotator.markInvalid(key)
RotationLogger.log(
"warn",
"error",
`key-rotation: key ***${key.slice(-6)} invalid, ${rotator.hasAlternative(key) ? "trying next" : "no more keys"}`,
)
if (!rotator.hasAlternative(key)) {
options.onExhausted(error)
process.exitCode = 1
return
}
Expand Down
5 changes: 3 additions & 2 deletions packages/opencode/src/provider/key-rotation-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ const TAG = Symbol.for("opencode.KeyRotationRetry")
export type KeyRotationRetry = {
readonly [TAG]: true
readonly reason: RotateReason
readonly message?: string
}

export function keyRotationRetry(reason: RotateReason): KeyRotationRetry {
return { [TAG]: true, reason }
export function keyRotationRetry(reason: RotateReason, message?: string): KeyRotationRetry {
return { [TAG]: true, reason, message }
}

export function isKeyRotationRetry(value: unknown): value is KeyRotationRetry {
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/provider/key-rotator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,14 @@ export class KeyRotator {
if (!isThrottleEnabled()) return
const duration = throttleDuration()
RotationLogger.log(
"warn",
"error",
`key-rotation: key ${maskKey(key)} throttled for ${duration} minutes, writing throttle record`,
)
await this.store.addThrottle(SOURCE, key, duration)
}

markInvalid(key: string): void {
RotationLogger.log("warn", `key-rotation: key ${maskKey(key)} marked invalid, skipping for this process`)
RotationLogger.log("error", `key-rotation: key ${maskKey(key)} marked invalid, skipping for this process`)
this.invalid.add(key)
}

Expand Down
22 changes: 21 additions & 1 deletion packages/opencode/test/cli/run/key-rotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ afterEach(() => {
else process.env.OPENCODE_KEY_ROTATION_ACTIVE = env.rotationActive
if (env.welanLog === undefined) delete process.env.OPENCODE_WELAN_LOG
else process.env.OPENCODE_WELAN_LOG = env.welanLog
process.exitCode = env.exitCode
process.exitCode = env.exitCode ?? 0
})

test("runs the next key after an invalid-key rotation signal", async () => {
Expand All @@ -34,8 +34,28 @@ test("runs the next key after an invalid-key rotation signal", async () => {
reset: async () => {
resets++
},
onExhausted: () => {},
})

expect(attempted).toEqual(["key1", "key2"])
expect(resets).toBe(1)
})

test("reports the final invalid-key signal", async () => {
process.env.OPENCODE_API_KEY = "key1,key2"
process.env.OPENCODE_WELAN_LOG = "false"
let exhausted: ReturnType<typeof keyRotationRetry> | undefined

await runWithKeyRotation({
createSdk: () => undefined,
execute: async () => {
throw keyRotationRetry("invalid_key", "key2 is invalid")
},
reset: async () => {},
onExhausted: (error) => {
exhausted = error
},
})

expect(exhausted).toMatchObject({ reason: "invalid_key", message: "key2 is invalid" })
})
34 changes: 32 additions & 2 deletions packages/opencode/test/cli/run/run-key-rotation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ describe("opencode run key rotation (non-interactive subprocess)", () => {
yield* llm.success("hello from key2")

const result = yield* opencode.run("say hi", {
env: { OPENCODE_API_KEY: "key1,key2", OPENCODE_THROTTLE_ENABLE: "true" },
env: { OPENCODE_API_KEY: "key1,key2", OPENCODE_THROTTLE_ENABLE: "true", OPENCODE_PRINT_LOGS: "1" },
timeoutMs: 40_000,
})
expect(result.exitCode).toBe(0)
expect(result.stderr).not.toContain("KeyRotationRetry")
expect(result.stderr).not.toContain("/$bunfs/")
expect(result.stderr).not.toContain("OPENCODE_QUOTA_LIMIT")
expect(result.stderr).toMatch(/level=ERROR.*key-rotation: key .* quota_limit/)

const keyHash = yield* Effect.promise(() => hashKey("key1"))
const data = JSON.parse(yield* Effect.promise(() => fs.readFile(throttleFile, "utf8")))
Expand All @@ -98,13 +100,14 @@ describe("opencode run key rotation (non-interactive subprocess)", () => {
yield* llm.success("hello")

const result = yield* opencode.run("say hi", {
env: { OPENCODE_API_KEY: "key1,key2" },
env: { OPENCODE_API_KEY: "key1,key2", OPENCODE_PRINT_LOGS: "1" },
timeoutMs: 40_000,
})
expect(result.exitCode).toBe(0)
expect(result.stderr).not.toContain("KeyRotationRetry")
expect(result.stderr).not.toContain("Invalid API key")
expect(result.stderr).not.toContain("/$bunfs/")
expect(result.stderr).toMatch(/level=ERROR.*key-rotation: key .* invalid/)

// 401 does NOT write throttle.json
const exists = yield* Effect.promise(() =>
Expand Down Expand Up @@ -156,10 +159,37 @@ describe("opencode run key rotation (non-interactive subprocess)", () => {
})
expect(result.exitCode).not.toBe(0)
expect(result.durationMs).toBeLessThan(10_000)
expect(result.stderr).toContain(
"OPENCODE_QUOTA_LIMIT: all configured API keys are exhausted or throttled",
)
}),
30_000,
)

cliIt.live(
"prints one quota marker when every key returns a quota limit",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.errorForKey("key1", 429, {
type: "error",
error: { type: "RateLimitError", message: "key1 quota reached" },
})
yield* llm.errorForKey("key2", 429, {
type: "error",
error: { type: "RateLimitError", message: "key2 quota reached" },
})

const result = yield* opencode.run("say hi", {
env: { OPENCODE_API_KEY: "key1,key2", OPENCODE_THROTTLE_ENABLE: "true" },
timeoutMs: 40_000,
})
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toContain("OPENCODE_QUOTA_LIMIT: key2 quota reached")
expect(result.stderr.match(/OPENCODE_QUOTA_LIMIT/g)).toHaveLength(1)
}),
60_000,
)

cliIt.live(
"single key exits nonzero without writing throttle.json when rate limited",
({ llm, opencode, home }) =>
Expand Down
Loading
Loading