claude-accounts-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/package.json +60 -0
- package/src/accounts.ts +141 -0
- package/src/autoswitch.ts +486 -0
- package/src/constants.ts +16 -0
- package/src/dialogs.tsx +138 -0
- package/src/profile.ts +26 -0
- package/src/usage.ts +128 -0
- package/tui.tsx +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vince.dai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# claude-accounts-usage
|
|
2
|
+
|
|
3
|
+
一个 OpenCode **TUI 插件**,用来查看多个 Claude(Pro/Max)账号的订阅用量,并在账号之间切换。
|
|
4
|
+
|
|
5
|
+
它**不接管** `anthropic` auth provider,因此可以和 [`@ex-machina/opencode-anthropic-auth`](https://github.com/ex-machina-co/opencode-anthropic-auth) **共存** —— ex-machina 继续负责 OAuth 登录与请求注入,本插件只在工具层做"账号档案 + 切换 + 用量展示"。
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
| 命令 | 作用 |
|
|
10
|
+
|------|------|
|
|
11
|
+
| `/usage` | 弹框显示所有账号的用量(5h / 7d / 7d-Sonnet 三个窗口,带进度条与重置倒计时) |
|
|
12
|
+
| `/switch` | 弹框选择账号并切换为当前账号(立即生效) |
|
|
13
|
+
|
|
14
|
+
账号会在**插件加载时**以及每次 `/usage`、`/switch` 时**自动收录**当前 ex-machina 登录的账号,无需手动添加。
|
|
15
|
+
|
|
16
|
+
## 限流自动切号(自动重试)
|
|
17
|
+
|
|
18
|
+
当**当前账号撞到订阅额度上限**(5h 窗口或周/全模型窗口的 429)时,插件会**自动切到下一个可用账号并重发刚才失败的那条消息**,无需手动干预。该能力**始终开启**。
|
|
19
|
+
|
|
20
|
+
工作方式:
|
|
21
|
+
|
|
22
|
+
- **检测**:监听 OpenCode 的 `session.next.retried` / `session.error` 事件,只在 429 且命中 Anthropic 订阅额度签名(`anthropic-ratelimit-unified-*: rejected`,或响应体 `rate_limit_error` + 额度文案)时触发;瞬时限流、529 过载、401 鉴权错误都会被排除,避免误切号。
|
|
23
|
+
- **选号**:优先按用量挑剩余额度最多的账号(用 `/usage` 时缓存的数据,TTL 10 分钟),无缓存则轮询下一个;跳过正在冷却(已知额度未恢复)的账号。
|
|
24
|
+
- **重发**:切号后将会话回退到失败的那条用户消息并用新账号重发(`revert` + `promptAsync`)。注意:若该轮中途已产生文件改动,回退会一并撤销并整轮重做。
|
|
25
|
+
- **冷却**:撞限的账号按响应头给出的 reset 时间(缺省 60 分钟)进入冷却,持久化在 `tui.json` 的 KV 中;账号下次成功使用后自动解除冷却。
|
|
26
|
+
- **耗尽**:当所有账号都在冷却时停止切换,并弹出最近恢复时间的倒计时提示。
|
|
27
|
+
|
|
28
|
+
> 调试:设环境变量 `CLAUDE_AUTOSWITCH_DEBUG=1` 可把未命中谓词的 429 样本追加到 `~/.config/opencode/claude-autoswitch.log`,便于校准检测规则。
|
|
29
|
+
|
|
30
|
+
## 前置条件
|
|
31
|
+
|
|
32
|
+
- 已安装并使用 `@ex-machina/opencode-anthropic-auth` 登录 Claude Pro/Max。
|
|
33
|
+
- **无需移除 ex-machina**,两者共存。
|
|
34
|
+
|
|
35
|
+
## 安装
|
|
36
|
+
|
|
37
|
+
TUI 插件只在 `~/.config/opencode/tui.json` 配置,**不要**放进 `opencode.json`。
|
|
38
|
+
|
|
39
|
+
### 方式一:npm(推荐)
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
44
|
+
"plugin": ["claude-accounts-usage"]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
OpenCode 会自动解析并安装该包,无需手动 `npm install`。
|
|
49
|
+
|
|
50
|
+
### 方式二:本地 clone(开发/离线)
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git clone https://github.com/Daiwenxi798673133/claude-accounts-usage.git
|
|
54
|
+
cd claude-accounts-usage && bun install
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
然后让 `tui.json` 指向克隆下来的 `tui.tsx` 绝对路径:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"$schema": "https://opencode.ai/tui.json",
|
|
62
|
+
"plugin": ["/绝对路径/claude-accounts-usage/tui.tsx"]
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
修改配置后**完全退出并重新打开** OpenCode。
|
|
67
|
+
|
|
68
|
+
## 账号管理流程
|
|
69
|
+
|
|
70
|
+
1. 用 ex-machina 登录账号 A:`opencode auth login` → Claude Pro/Max。
|
|
71
|
+
2. 打开 OpenCode,插件自动收录账号 A(标签为其邮箱)。
|
|
72
|
+
3. 想加更多账号:用 ex-machina 登录账号 B,然后重新打开 OpenCode 或运行一次 `/usage` / `/switch`,插件自动收录 B。
|
|
73
|
+
4. 之后用 `/switch` 在账号间切换,用 `/usage` 查看全部用量。
|
|
74
|
+
|
|
75
|
+
> 标签默认是账号邮箱。想改名?直接编辑 `~/.config/opencode/claude-accounts.json` 里对应账号的 `label`(自动收录不会覆盖你改过的标签)。
|
|
76
|
+
|
|
77
|
+
## 工作原理
|
|
78
|
+
|
|
79
|
+
- 账号档案保存在 `~/.config/opencode/claude-accounts.json`(权限 `0600`),每个账号含 OAuth `refresh` / `access` / `expires`、邮箱 `label`,以及来自 Anthropic profile 的账号 `uuid`。
|
|
80
|
+
- **自动收录**:读 `auth.json` 当前账号 → 调 `oauth/profile` 拿到稳定的账号 `uuid` 和邮箱 → 按 `uuid` upsert。`uuid` 跨 token 刷新保持不变,因此同一账号只会被更新(不重复),换成新账号则自动新增。
|
|
81
|
+
- **切换**:把目标账号的 token 写入 `auth.json` 的 `anthropic` 条目。ex-machina 每次请求都会重新读取 `auth.json`,所以切换立即生效(下一条消息就用新账号),无需重启。
|
|
82
|
+
- **查看用量**:对每个账号调用 Anthropic 的 `oauth/usage` 接口;若 access token 过期,会用 refresh token 刷新并回写档案。
|
|
83
|
+
- 始终只读 / 谨慎写 `auth.json` 的 `anthropic` 一项,保留其他 provider 条目不动。
|
|
84
|
+
|
|
85
|
+
## 已知限制
|
|
86
|
+
|
|
87
|
+
- ex-machina 同一时刻只持有一个账号,所以一个新账号必须先用 ex-machina 登录过一次,插件才能在下次加载/操作时收录它。
|
|
88
|
+
- 自动切号依赖 OpenCode 的 `session.error` / `session.next.retried` 事件,因此只对经由 OpenCode(及 ex-machina)发出的 Anthropic 请求生效;额度恢复后的解除冷却需要该账号成功跑过一次对话。
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-accounts-usage",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode TUI plugin to view multi-account Claude usage and switch the active account. Coexists with @ex-machina/opencode-anthropic-auth.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "vince.dai",
|
|
8
|
+
"homepage": "https://github.com/Daiwenxi798673133/claude-accounts-usage#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Daiwenxi798673133/claude-accounts-usage.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Daiwenxi798673133/claude-accounts-usage/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"opencode-plugin",
|
|
19
|
+
"claude",
|
|
20
|
+
"anthropic",
|
|
21
|
+
"usage",
|
|
22
|
+
"quota",
|
|
23
|
+
"multi-account",
|
|
24
|
+
"tui"
|
|
25
|
+
],
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"import": "./tui.tsx"
|
|
29
|
+
},
|
|
30
|
+
"./tui": {
|
|
31
|
+
"import": "./tui.tsx"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"tui.tsx",
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"opencode": ">=1.3.13",
|
|
42
|
+
"node": ">=22"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"prepublishOnly": "tsc --noEmit"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"@opencode-ai/plugin": "^1.17.4",
|
|
53
|
+
"@opentui/solid": "^0.4.1",
|
|
54
|
+
"solid-js": "^1.9.13"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^25.9.3",
|
|
58
|
+
"typescript": "^5.9.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/accounts.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir, rename } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join, dirname } from "node:path"
|
|
4
|
+
|
|
5
|
+
export type StoredAccount = {
|
|
6
|
+
id: string
|
|
7
|
+
label: string
|
|
8
|
+
refresh: string
|
|
9
|
+
access?: string
|
|
10
|
+
expires?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AccountsFile = {
|
|
14
|
+
version: number
|
|
15
|
+
activeId?: string
|
|
16
|
+
accounts: StoredAccount[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type AnthropicOauth = {
|
|
20
|
+
type: "oauth"
|
|
21
|
+
access?: string
|
|
22
|
+
refresh?: string
|
|
23
|
+
expires?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AuthToken = {
|
|
27
|
+
refresh: string
|
|
28
|
+
access?: string
|
|
29
|
+
expires?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const ACCOUNTS_PATH = join(homedir(), ".config", "opencode", "claude-accounts.json")
|
|
33
|
+
|
|
34
|
+
function authJsonCandidates(): string[] {
|
|
35
|
+
const list: string[] = []
|
|
36
|
+
if (process.env.XDG_DATA_HOME) list.push(join(process.env.XDG_DATA_HOME, "opencode", "auth.json"))
|
|
37
|
+
list.push(join(homedir(), ".local", "share", "opencode", "auth.json"))
|
|
38
|
+
list.push(join(homedir(), "Library", "Application Support", "opencode", "auth.json"))
|
|
39
|
+
return list
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function readJson<T>(path: string): Promise<T | undefined> {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(await readFile(path, "utf8")) as T
|
|
45
|
+
} catch {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function atomicWriteJson(path: string, data: unknown): Promise<void> {
|
|
51
|
+
await mkdir(dirname(path), { recursive: true })
|
|
52
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
|
|
53
|
+
await writeFile(tmp, JSON.stringify(data, null, 2), { mode: 0o600 })
|
|
54
|
+
await rename(tmp, path)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function resolveAuthJsonPath(): Promise<string> {
|
|
58
|
+
const candidates = authJsonCandidates()
|
|
59
|
+
for (const candidate of candidates) {
|
|
60
|
+
if (await readJson(candidate)) return candidate
|
|
61
|
+
}
|
|
62
|
+
return candidates[0]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Serializes auth.json / claude-accounts.json read-modify-writes. NOT reentrant:
|
|
66
|
+
// never nest withAuthLock inside another withAuthLock or it deadlocks.
|
|
67
|
+
let authLock: Promise<unknown> = Promise.resolve()
|
|
68
|
+
|
|
69
|
+
export function withAuthLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
70
|
+
const run = authLock.then(fn, fn)
|
|
71
|
+
authLock = run.then(
|
|
72
|
+
() => undefined,
|
|
73
|
+
() => undefined,
|
|
74
|
+
)
|
|
75
|
+
return run
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function readActiveId(): Promise<string | undefined> {
|
|
79
|
+
return (await loadAccounts()).activeId
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function loadAccounts(): Promise<AccountsFile> {
|
|
83
|
+
const data = await readJson<Partial<AccountsFile>>(ACCOUNTS_PATH)
|
|
84
|
+
return {
|
|
85
|
+
version: data?.version ?? 1,
|
|
86
|
+
activeId: data?.activeId,
|
|
87
|
+
accounts: Array.isArray(data?.accounts)
|
|
88
|
+
? (data!.accounts as StoredAccount[]).filter((account) => typeof account.id === "string" && account.id.length > 0)
|
|
89
|
+
: [],
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function saveAccounts(file: AccountsFile): Promise<void> {
|
|
94
|
+
await atomicWriteJson(ACCOUNTS_PATH, file)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function readAuthAnthropic(): Promise<AnthropicOauth | undefined> {
|
|
98
|
+
const auth = await readJson<Record<string, unknown>>(await resolveAuthJsonPath())
|
|
99
|
+
const entry = auth?.["anthropic"]
|
|
100
|
+
if (entry && typeof entry === "object" && (entry as AnthropicOauth).type === "oauth") {
|
|
101
|
+
return entry as AnthropicOauth
|
|
102
|
+
}
|
|
103
|
+
return undefined
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function writeAuthAnthropic(token: AuthToken): Promise<void> {
|
|
107
|
+
const path = await resolveAuthJsonPath()
|
|
108
|
+
const auth = (await readJson<Record<string, unknown>>(path)) ?? {}
|
|
109
|
+
auth["anthropic"] = {
|
|
110
|
+
type: "oauth",
|
|
111
|
+
access: token.access ?? "",
|
|
112
|
+
refresh: token.refresh,
|
|
113
|
+
expires: token.expires ?? 0,
|
|
114
|
+
}
|
|
115
|
+
await atomicWriteJson(path, auth)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function upsertAccount(id: string, label: string, token: AuthToken): Promise<AccountsFile> {
|
|
119
|
+
const file = await loadAccounts()
|
|
120
|
+
const index = file.accounts.findIndex((account) => account.id === id)
|
|
121
|
+
if (index >= 0) {
|
|
122
|
+
file.accounts[index] = {
|
|
123
|
+
...file.accounts[index],
|
|
124
|
+
refresh: token.refresh,
|
|
125
|
+
access: token.access,
|
|
126
|
+
expires: token.expires,
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
file.accounts.push({ id, label, refresh: token.refresh, access: token.access, expires: token.expires })
|
|
130
|
+
}
|
|
131
|
+
file.activeId = id
|
|
132
|
+
await saveAccounts(file)
|
|
133
|
+
return file
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export async function setActiveId(id: string): Promise<void> {
|
|
137
|
+
const file = await loadAccounts()
|
|
138
|
+
if (!file.accounts.some((account) => account.id === id)) return
|
|
139
|
+
file.activeId = id
|
|
140
|
+
await saveAccounts(file)
|
|
141
|
+
}
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { appendFile } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
5
|
+
import { loadAccounts, readActiveId, type AccountsFile, type StoredAccount } from "./accounts.ts"
|
|
6
|
+
import { collectAllUsage, switchToAccount, type AccountUsage, type UsageResponse } from "./usage.ts"
|
|
7
|
+
|
|
8
|
+
const ENABLED = true
|
|
9
|
+
const DEFAULT_COOLDOWN_MS = 60 * 60_000
|
|
10
|
+
const USAGE_CACHE_TTL_MS = 10 * 60_000
|
|
11
|
+
const RECENT_SWITCH_GUARD_MS = 4_000
|
|
12
|
+
const IDLE_WAIT_TIMEOUT_MS = 4_000
|
|
13
|
+
const IDLE_POLL_MS = 150
|
|
14
|
+
const COOLDOWN_KV_KEY = "claude-accounts-usage.autoswitch.cooldown"
|
|
15
|
+
|
|
16
|
+
type StateParts = ReturnType<TuiPluginApi["state"]["part"]>
|
|
17
|
+
type StateMessage = ReturnType<TuiPluginApi["state"]["session"]["messages"]>[number]
|
|
18
|
+
type AssistantMsg = Extract<StateMessage, { role: "assistant" }>
|
|
19
|
+
type PromptParts = NonNullable<Parameters<TuiPluginApi["client"]["session"]["promptAsync"]>[0]["parts"]>
|
|
20
|
+
|
|
21
|
+
type RetryErrorLike = {
|
|
22
|
+
statusCode?: number
|
|
23
|
+
responseHeaders?: Record<string, string>
|
|
24
|
+
responseBody?: string
|
|
25
|
+
message?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type AutoSwitchController = {
|
|
29
|
+
dispose: () => void
|
|
30
|
+
setUsageCache: (results: AccountUsage[]) => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function lowerKeys(headers?: Record<string, string>): Record<string, string> {
|
|
34
|
+
const out: Record<string, string> = {}
|
|
35
|
+
if (headers) for (const [key, value] of Object.entries(headers)) out[key.toLowerCase()] = value
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeJson(body?: string): { error?: { type?: unknown; message?: unknown } } | undefined {
|
|
40
|
+
if (!body) return undefined
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(body)
|
|
43
|
+
return typeof value === "object" && value !== null ? (value as { error?: { type?: unknown; message?: unknown } }) : undefined
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Detects a Claude Pro/Max rate-limit / quota rejection so we can switch accounts.
|
|
50
|
+
// 529 overloads are excluded (switching won't help). Anthropic surfaces this through
|
|
51
|
+
// several shapes depending on path (unified headers, JSON body type, or just a message
|
|
52
|
+
// string like "This request would exceed your account's rate limit"), so we match on
|
|
53
|
+
// ANY of: 429 status, rate_limit_error type, or rate-limit message text — the message
|
|
54
|
+
// regex is the one maintenance point as Anthropic's wording may drift.
|
|
55
|
+
function isUsageLimit(error?: RetryErrorLike): boolean {
|
|
56
|
+
if (!error) return false
|
|
57
|
+
const body = error.responseBody ?? ""
|
|
58
|
+
if (/overloaded_error/i.test(body)) return false
|
|
59
|
+
const headers = lowerKeys(error.responseHeaders)
|
|
60
|
+
const unifiedRejected = Object.entries(headers).some(
|
|
61
|
+
([key, value]) =>
|
|
62
|
+
key.startsWith("anthropic-ratelimit-unified") && key.endsWith("status") && String(value).toLowerCase().includes("rejected"),
|
|
63
|
+
)
|
|
64
|
+
if (unifiedRejected) return true
|
|
65
|
+
const parsed = safeJson(body)?.error
|
|
66
|
+
const type = typeof parsed?.type === "string" ? parsed.type : ""
|
|
67
|
+
const text = `${typeof parsed?.message === "string" ? parsed.message : ""} ${error.message ?? ""}`.toLowerCase()
|
|
68
|
+
const rateLimitText = /rate limit|usage limit|limit reached|too many requests|out of (?:usage|quota)|5[- ]?hour|weekly limit|exceed/.test(text)
|
|
69
|
+
return error.statusCode === 429 || type === "rate_limit_error" || rateLimitText
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseResetMs(error: RetryErrorLike): number | undefined {
|
|
73
|
+
const headers = lowerKeys(error.responseHeaders)
|
|
74
|
+
const reset = Number(headers["anthropic-ratelimit-unified-reset"])
|
|
75
|
+
if (Number.isFinite(reset) && reset > 0) return reset * 1000
|
|
76
|
+
const retryAfter = Number(headers["retry-after"])
|
|
77
|
+
if (Number.isFinite(retryAfter) && retryAfter > 0) return Date.now() + retryAfter * 1000
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function toErrorData(error: unknown): RetryErrorLike | undefined {
|
|
82
|
+
if (typeof error !== "object" || error === null) return undefined
|
|
83
|
+
const candidate = error as { name?: unknown; data?: RetryErrorLike }
|
|
84
|
+
if (candidate.name === "APIError" && candidate.data && typeof candidate.data === "object") return candidate.data
|
|
85
|
+
return undefined
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function score(usage?: UsageResponse): number {
|
|
89
|
+
if (!usage) return Number.POSITIVE_INFINITY
|
|
90
|
+
return Math.max(
|
|
91
|
+
usage.five_hour?.utilization ?? 0,
|
|
92
|
+
usage.seven_day?.utilization ?? 0,
|
|
93
|
+
usage.seven_day_sonnet?.utilization ?? 0,
|
|
94
|
+
usage.seven_day_opus?.utilization ?? 0,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fmtDuration(ms: number): string {
|
|
99
|
+
const minutes = Math.max(1, Math.round(ms / 60_000))
|
|
100
|
+
if (minutes < 60) return `${minutes} 分钟`
|
|
101
|
+
const hours = Math.floor(minutes / 60)
|
|
102
|
+
const rest = minutes % 60
|
|
103
|
+
return rest > 0 ? `${hours} 小时 ${rest} 分钟` : `${hours} 小时`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sleep(ms: number): Promise<void> {
|
|
107
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function debugLog(tag: string, payload: unknown): void {
|
|
111
|
+
if (!process.env.CLAUDE_AUTOSWITCH_DEBUG) return
|
|
112
|
+
let serialized: string
|
|
113
|
+
try {
|
|
114
|
+
serialized = JSON.stringify(payload)
|
|
115
|
+
} catch {
|
|
116
|
+
serialized = String(payload)
|
|
117
|
+
}
|
|
118
|
+
const line = `${new Date().toISOString()} [${tag}] ${serialized}\n`
|
|
119
|
+
void appendFile(join(homedir(), ".config", "opencode", "claude-autoswitch.log"), line).catch(() => undefined)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function installAutoSwitch(api: TuiPluginApi): AutoSwitchController {
|
|
123
|
+
const cooldown = new Map<string, number>()
|
|
124
|
+
const attempted = new Map<string, Set<string>>()
|
|
125
|
+
const sessionLocks = new Map<string, Promise<unknown>>()
|
|
126
|
+
const repromptInFlight = new Set<string>()
|
|
127
|
+
const lastAction = new Map<string, number>()
|
|
128
|
+
const seen = new Set<string>()
|
|
129
|
+
let usageCache: { at: number; byId: Map<string, UsageResponse> } = { at: 0, byId: new Map() }
|
|
130
|
+
let refreshing = false
|
|
131
|
+
let lastSwitch: { id?: string; sessionID?: string; at: number } = { at: 0 }
|
|
132
|
+
|
|
133
|
+
const stored = api.kv.get<Record<string, number>>(COOLDOWN_KV_KEY, {})
|
|
134
|
+
if (stored) for (const [id, until] of Object.entries(stored)) cooldown.set(id, until)
|
|
135
|
+
|
|
136
|
+
function persistCooldown(): void {
|
|
137
|
+
const now = Date.now()
|
|
138
|
+
const snapshot: Record<string, number> = {}
|
|
139
|
+
for (const [id, until] of cooldown) if (until > now) snapshot[id] = until
|
|
140
|
+
api.kv.set(COOLDOWN_KV_KEY, snapshot)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function markCooldown(id: string, untilMs?: number): void {
|
|
144
|
+
cooldown.set(id, untilMs ?? Date.now() + DEFAULT_COOLDOWN_MS)
|
|
145
|
+
persistCooldown()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function clearCooldown(id: string): void {
|
|
149
|
+
if (cooldown.delete(id)) persistCooldown()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isCooled(id: string, now: number): boolean {
|
|
153
|
+
const until = cooldown.get(id)
|
|
154
|
+
return typeof until === "number" && until > now
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function setUsageCache(results: AccountUsage[]): void {
|
|
158
|
+
const byId = new Map<string, UsageResponse>()
|
|
159
|
+
for (const result of results) if (result.usage) byId.set(result.id, result.usage)
|
|
160
|
+
usageCache = { at: Date.now(), byId }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function refreshUsageInBackground(): Promise<void> {
|
|
164
|
+
if (refreshing) return
|
|
165
|
+
refreshing = true
|
|
166
|
+
try {
|
|
167
|
+
const { results } = await collectAllUsage()
|
|
168
|
+
setUsageCache(results)
|
|
169
|
+
} catch {
|
|
170
|
+
// best-effort cache warming; selection falls back to round-robin
|
|
171
|
+
} finally {
|
|
172
|
+
refreshing = false
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function dedup(id: string): boolean {
|
|
177
|
+
if (seen.has(id)) return false
|
|
178
|
+
seen.add(id)
|
|
179
|
+
if (seen.size > 1000) {
|
|
180
|
+
seen.clear()
|
|
181
|
+
seen.add(id)
|
|
182
|
+
}
|
|
183
|
+
return true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function runExclusive<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
187
|
+
const prev = sessionLocks.get(key) ?? Promise.resolve()
|
|
188
|
+
const run = prev.then(fn, fn)
|
|
189
|
+
sessionLocks.set(
|
|
190
|
+
key,
|
|
191
|
+
run.then(
|
|
192
|
+
() => undefined,
|
|
193
|
+
() => undefined,
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
return run
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function labelOf(file: AccountsFile, id?: string): string {
|
|
200
|
+
return file.accounts.find((account) => account.id === id)?.label ?? "当前账号"
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function lastAssistant(sessionID: string): AssistantMsg | undefined {
|
|
204
|
+
const messages = api.state.session.messages(sessionID)
|
|
205
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
206
|
+
const message = messages[i]
|
|
207
|
+
if (message.role === "assistant") return message
|
|
208
|
+
}
|
|
209
|
+
return undefined
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isAnthropicSession(sessionID: string): boolean {
|
|
213
|
+
const assistant = lastAssistant(sessionID)
|
|
214
|
+
return !assistant || assistant.providerID === "anthropic"
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function pickNext(file: AccountsFile, tried: Set<string>, activeId?: string): StoredAccount | undefined {
|
|
218
|
+
const now = Date.now()
|
|
219
|
+
const candidates = file.accounts.filter(
|
|
220
|
+
(account) => account.id !== activeId && !tried.has(account.id) && !isCooled(account.id, now),
|
|
221
|
+
)
|
|
222
|
+
if (candidates.length === 0) return undefined
|
|
223
|
+
|
|
224
|
+
const cacheFresh = usageCache.at > 0 && now - usageCache.at <= USAGE_CACHE_TTL_MS
|
|
225
|
+
if (cacheFresh) {
|
|
226
|
+
return [...candidates].sort((a, b) => score(usageCache.byId.get(a.id)) - score(usageCache.byId.get(b.id)))[0]
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const order = file.accounts.map((account) => account.id)
|
|
230
|
+
const start = activeId ? order.indexOf(activeId) : -1
|
|
231
|
+
for (let offset = 1; offset <= order.length; offset++) {
|
|
232
|
+
const id = order[(start + offset + order.length) % order.length]
|
|
233
|
+
const match = candidates.find((account) => account.id === id)
|
|
234
|
+
if (match) return match
|
|
235
|
+
}
|
|
236
|
+
return candidates[0]
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function standDown(file: AccountsFile): void {
|
|
240
|
+
const now = Date.now()
|
|
241
|
+
const times = file.accounts
|
|
242
|
+
.map((account) => cooldown.get(account.id))
|
|
243
|
+
.filter((until): until is number => typeof until === "number" && until > now)
|
|
244
|
+
const soonest = times.length > 0 ? Math.min(...times) : undefined
|
|
245
|
+
const message = soonest
|
|
246
|
+
? `所有账号都已达额度上限,约 ${fmtDuration(soonest - now)} 后恢复`
|
|
247
|
+
: "所有账号都已达额度上限"
|
|
248
|
+
debugLog("standdown", { accounts: file.accounts.length, soonest })
|
|
249
|
+
api.ui.toast({ variant: "error", message })
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function doSwitch(sessionID: string, error: RetryErrorLike, activeId?: string): Promise<boolean> {
|
|
253
|
+
if (activeId) markCooldown(activeId, parseResetMs(error))
|
|
254
|
+
|
|
255
|
+
const file = await loadAccounts()
|
|
256
|
+
const tried = attempted.get(sessionID) ?? new Set<string>()
|
|
257
|
+
attempted.set(sessionID, tried)
|
|
258
|
+
if (file.accounts.length <= 1) {
|
|
259
|
+
standDown(file)
|
|
260
|
+
return false
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (let i = 0; i < file.accounts.length; i++) {
|
|
264
|
+
const next = pickNext(file, tried, activeId)
|
|
265
|
+
if (!next) break
|
|
266
|
+
try {
|
|
267
|
+
const account = await switchToAccount(next.id)
|
|
268
|
+
tried.add(next.id)
|
|
269
|
+
lastSwitch = { id: account.id, sessionID, at: Date.now() }
|
|
270
|
+
debugLog("switched", { from: labelOf(file, activeId), to: account.label })
|
|
271
|
+
api.ui.toast({
|
|
272
|
+
variant: "warning",
|
|
273
|
+
message: `「${labelOf(file, activeId)}」额度已满,已切到「${account.label}」并自动重试`,
|
|
274
|
+
})
|
|
275
|
+
void refreshUsageInBackground()
|
|
276
|
+
return true
|
|
277
|
+
} catch {
|
|
278
|
+
tried.add(next.id)
|
|
279
|
+
markCooldown(next.id, undefined)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
standDown(file)
|
|
284
|
+
return false
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function toInputParts(parts: StateParts): PromptParts {
|
|
288
|
+
const out: PromptParts = []
|
|
289
|
+
for (const part of parts) {
|
|
290
|
+
if (part.type === "text") {
|
|
291
|
+
if (part.synthetic || part.ignored) continue
|
|
292
|
+
if (part.text && part.text.trim().length > 0) out.push({ type: "text", text: part.text })
|
|
293
|
+
} else if (part.type === "file") {
|
|
294
|
+
out.push({ type: "file", mime: part.mime, filename: part.filename, url: part.url, source: part.source })
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function findFailedAssistant(messages: ReturnType<TuiPluginApi["state"]["session"]["messages"]>): AssistantMsg | undefined {
|
|
301
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
302
|
+
const message = messages[i]
|
|
303
|
+
if (message.role === "assistant" && message.error) return message
|
|
304
|
+
}
|
|
305
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
306
|
+
const message = messages[i]
|
|
307
|
+
if (message.role === "assistant") return message
|
|
308
|
+
}
|
|
309
|
+
return undefined
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function findUserMessage(
|
|
313
|
+
messages: ReturnType<TuiPluginApi["state"]["session"]["messages"]>,
|
|
314
|
+
failed?: AssistantMsg,
|
|
315
|
+
): StateMessage | undefined {
|
|
316
|
+
if (failed?.parentID) {
|
|
317
|
+
const parent = messages.find((message) => message.id === failed.parentID && message.role === "user")
|
|
318
|
+
if (parent) return parent
|
|
319
|
+
}
|
|
320
|
+
const failedIndex = failed ? messages.findIndex((message) => message.id === failed.id) : messages.length
|
|
321
|
+
const from = (failedIndex < 0 ? messages.length : failedIndex) - 1
|
|
322
|
+
for (let i = from; i >= 0; i--) {
|
|
323
|
+
if (messages[i].role === "user") return messages[i]
|
|
324
|
+
}
|
|
325
|
+
return undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function waitIdle(sessionID: string): Promise<void> {
|
|
329
|
+
const deadline = Date.now() + IDLE_WAIT_TIMEOUT_MS
|
|
330
|
+
while (Date.now() < deadline) {
|
|
331
|
+
const status = api.state.session.status(sessionID)
|
|
332
|
+
if (!status || status.type === "idle") return
|
|
333
|
+
await sleep(IDLE_POLL_MS)
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function repromptFailedTurn(sessionID: string, abortFirst: boolean): Promise<void> {
|
|
338
|
+
if (repromptInFlight.has(sessionID)) return
|
|
339
|
+
repromptInFlight.add(sessionID)
|
|
340
|
+
const guidance = () => api.ui.toast({ variant: "info", message: "已切换账号,请手动重新发送上一条消息" })
|
|
341
|
+
try {
|
|
342
|
+
if (abortFirst) {
|
|
343
|
+
try {
|
|
344
|
+
await api.client.session.abort({ sessionID })
|
|
345
|
+
} catch {
|
|
346
|
+
// ignore: stream may already be settling
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
await waitIdle(sessionID)
|
|
350
|
+
|
|
351
|
+
const messages = api.state.session.messages(sessionID)
|
|
352
|
+
const failed = findFailedAssistant(messages)
|
|
353
|
+
const userMessage = findUserMessage(messages, failed)
|
|
354
|
+
if (!userMessage) return guidance()
|
|
355
|
+
|
|
356
|
+
const parts = toInputParts(api.state.part(userMessage.id))
|
|
357
|
+
if (parts.length === 0) return guidance()
|
|
358
|
+
|
|
359
|
+
const reverted = await api.client.session.revert({ sessionID, messageID: userMessage.id })
|
|
360
|
+
if (reverted.error) return guidance()
|
|
361
|
+
|
|
362
|
+
const prompted = await api.client.session.promptAsync({ sessionID, parts })
|
|
363
|
+
if (prompted.error) guidance()
|
|
364
|
+
} catch {
|
|
365
|
+
guidance()
|
|
366
|
+
} finally {
|
|
367
|
+
repromptInFlight.delete(sessionID)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function handleLimit(sessionID: string, error: RetryErrorLike, mode: "retry" | "error"): Promise<void> {
|
|
372
|
+
await runExclusive(sessionID, async () => {
|
|
373
|
+
const now = Date.now()
|
|
374
|
+
// Coalesce the burst of retry/error events a single failed turn emits: once we have
|
|
375
|
+
// acted for this session, ignore further limit events until that action settles.
|
|
376
|
+
if (now - (lastAction.get(sessionID) ?? 0) < RECENT_SWITCH_GUARD_MS) return
|
|
377
|
+
|
|
378
|
+
const activeId = await readActiveId()
|
|
379
|
+
// Cross-session race: another session just switched to this fresh account, so the
|
|
380
|
+
// failure predates the switch. Reuse the fresh account instead of cooling it again.
|
|
381
|
+
const reuseFresh =
|
|
382
|
+
!!activeId && lastSwitch.id === activeId && lastSwitch.sessionID !== sessionID && now - lastSwitch.at < RECENT_SWITCH_GUARD_MS
|
|
383
|
+
const usable = reuseFresh ? true : await doSwitch(sessionID, error, activeId)
|
|
384
|
+
if (!usable) return
|
|
385
|
+
|
|
386
|
+
lastAction.set(sessionID, Date.now())
|
|
387
|
+
await repromptFailedTurn(sessionID, mode === "retry")
|
|
388
|
+
})
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function onRetried(event: { id: string; properties: { sessionID: string; error: RetryErrorLike } }): Promise<void> {
|
|
392
|
+
const error = event.properties.error
|
|
393
|
+
debugLog("retried", {
|
|
394
|
+
sessionID: event.properties.sessionID,
|
|
395
|
+
statusCode: error?.statusCode,
|
|
396
|
+
message: error?.message,
|
|
397
|
+
headerKeys: Object.keys(error?.responseHeaders ?? {}),
|
|
398
|
+
body: (error?.responseBody ?? "").slice(0, 300),
|
|
399
|
+
})
|
|
400
|
+
if (!ENABLED || !dedup(event.id)) return
|
|
401
|
+
const matched = isUsageLimit(error)
|
|
402
|
+
const anthropic = isAnthropicSession(event.properties.sessionID)
|
|
403
|
+
debugLog("retried-decision", { matched, anthropic })
|
|
404
|
+
if (!matched || !anthropic) return
|
|
405
|
+
await handleLimit(event.properties.sessionID, error, "retry")
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function onStatus(event: {
|
|
409
|
+
id: string
|
|
410
|
+
properties: { sessionID: string; status?: { type: string; message?: string; next?: number } }
|
|
411
|
+
}): Promise<void> {
|
|
412
|
+
const status = event.properties.status
|
|
413
|
+
debugLog("status", {
|
|
414
|
+
sessionID: event.properties.sessionID,
|
|
415
|
+
type: status?.type,
|
|
416
|
+
message: status?.type === "retry" ? status.message : undefined,
|
|
417
|
+
})
|
|
418
|
+
if (status?.type !== "retry" || !ENABLED || !dedup(event.id)) return
|
|
419
|
+
const error: RetryErrorLike = { message: status.message }
|
|
420
|
+
const matched = isUsageLimit(error)
|
|
421
|
+
const anthropic = isAnthropicSession(event.properties.sessionID)
|
|
422
|
+
debugLog("status-decision", { matched, anthropic })
|
|
423
|
+
if (!matched || !anthropic) return
|
|
424
|
+
await handleLimit(event.properties.sessionID, error, "retry")
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function onError(event: { id: string; properties: { sessionID?: string; error?: unknown } }): Promise<void> {
|
|
428
|
+
const sessionID = event.properties.sessionID
|
|
429
|
+
const error = toErrorData(event.properties.error)
|
|
430
|
+
debugLog("error", {
|
|
431
|
+
sessionID,
|
|
432
|
+
raw: event.properties.error,
|
|
433
|
+
statusCode: error?.statusCode,
|
|
434
|
+
message: error?.message,
|
|
435
|
+
})
|
|
436
|
+
if (!ENABLED || !dedup(event.id) || !sessionID) return
|
|
437
|
+
const matched = !!error && isUsageLimit(error)
|
|
438
|
+
const anthropic = isAnthropicSession(sessionID)
|
|
439
|
+
debugLog("error-decision", { matched, anthropic })
|
|
440
|
+
if (!matched || !anthropic) return
|
|
441
|
+
await handleLimit(sessionID, error, "error")
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function onIdle(sessionID: string): Promise<void> {
|
|
445
|
+
const assistant = lastAssistant(sessionID)
|
|
446
|
+
if (assistant && !assistant.error) {
|
|
447
|
+
const activeId = await readActiveId()
|
|
448
|
+
if (activeId) clearCooldown(activeId)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
debugLog("installed", { enabled: ENABLED })
|
|
453
|
+
|
|
454
|
+
const offs = [
|
|
455
|
+
api.event.on("session.status", (event) => {
|
|
456
|
+
void onStatus(event)
|
|
457
|
+
}),
|
|
458
|
+
api.event.on("session.next.retried", (event) => {
|
|
459
|
+
void onRetried(event)
|
|
460
|
+
}),
|
|
461
|
+
api.event.on("session.error", (event) => {
|
|
462
|
+
void onError(event)
|
|
463
|
+
}),
|
|
464
|
+
api.event.on("session.next.prompted", (event) => {
|
|
465
|
+
attempted.delete(event.properties.sessionID)
|
|
466
|
+
lastAction.delete(event.properties.sessionID)
|
|
467
|
+
}),
|
|
468
|
+
api.event.on("session.idle", (event) => {
|
|
469
|
+
void onIdle(event.properties.sessionID)
|
|
470
|
+
}),
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
dispose: () => {
|
|
475
|
+
for (const off of offs) {
|
|
476
|
+
try {
|
|
477
|
+
off()
|
|
478
|
+
} catch {
|
|
479
|
+
// ignore unsubscribe failures during teardown
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
persistCooldown()
|
|
483
|
+
},
|
|
484
|
+
setUsageCache,
|
|
485
|
+
}
|
|
486
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Shared Anthropic OAuth client_id used by the official Claude Pro/Max flow
|
|
2
|
+
// (same value as @ex-machina/opencode-anthropic-auth), so refresh tokens stored
|
|
3
|
+
// by that plugin are accepted by the refresh endpoint below.
|
|
4
|
+
export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
5
|
+
|
|
6
|
+
export const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
|
|
7
|
+
|
|
8
|
+
export const USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
|
|
9
|
+
|
|
10
|
+
export const PROFILE_ENDPOINT = "https://api.anthropic.com/api/oauth/profile"
|
|
11
|
+
|
|
12
|
+
export const OAUTH_BETA = "oauth-2025-04-20"
|
|
13
|
+
|
|
14
|
+
// Refresh slightly before real expiry so neither ex-machina nor the usage call
|
|
15
|
+
// receives an already-stale access token.
|
|
16
|
+
export const TOKEN_EXPIRY_BUFFER_MS = 60_000
|
package/src/dialogs.tsx
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { For, Show } from "solid-js"
|
|
3
|
+
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
4
|
+
import type { StoredAccount } from "./accounts.ts"
|
|
5
|
+
import type { AccountUsage, UsageWindow } from "./usage.ts"
|
|
6
|
+
|
|
7
|
+
export type UsageState = {
|
|
8
|
+
loading: boolean
|
|
9
|
+
results: AccountUsage[]
|
|
10
|
+
updatedAt?: number
|
|
11
|
+
error?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function bar(util: number, width = 18): string {
|
|
15
|
+
const pct = Math.max(0, Math.min(100, util))
|
|
16
|
+
const fill = Math.round((pct / 100) * width)
|
|
17
|
+
return `[${"#".repeat(fill)}${"-".repeat(width - fill)}]`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function percent(util: number): string {
|
|
21
|
+
return `${Math.round(util)}%`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tone(api: TuiPluginApi, util: number) {
|
|
25
|
+
const theme = api.theme.current
|
|
26
|
+
if (util >= 85) return theme.error
|
|
27
|
+
if (util >= 60) return theme.warning
|
|
28
|
+
return theme.success
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function resetIn(iso: string): string {
|
|
32
|
+
const ms = new Date(iso).getTime() - Date.now()
|
|
33
|
+
if (ms <= 0) return "now"
|
|
34
|
+
const hours = Math.floor(ms / 3_600_000)
|
|
35
|
+
const minutes = Math.floor((ms % 3_600_000) / 60_000)
|
|
36
|
+
if (hours >= 24) return `${Math.floor(hours / 24)}d ${hours % 24}h`
|
|
37
|
+
if (hours > 0) return `${hours}h ${minutes}m`
|
|
38
|
+
return `${minutes}m`
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clockTime(ts: number): string {
|
|
42
|
+
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function WindowRow(props: { api: TuiPluginApi; name: string; win?: UsageWindow }) {
|
|
46
|
+
const theme = () => props.api.theme.current
|
|
47
|
+
return (
|
|
48
|
+
<Show when={props.win}>
|
|
49
|
+
{(win) => (
|
|
50
|
+
<box flexDirection="row" gap={1}>
|
|
51
|
+
<text fg={theme().textMuted}>{props.name.padEnd(6)}</text>
|
|
52
|
+
<text fg={tone(props.api, win().utilization)}>
|
|
53
|
+
{bar(win().utilization)} {percent(win().utilization)}
|
|
54
|
+
</text>
|
|
55
|
+
<Show when={win().resets_at}>
|
|
56
|
+
<text fg={theme().textMuted}>重置 {resetIn(win().resets_at!)}</text>
|
|
57
|
+
</Show>
|
|
58
|
+
</box>
|
|
59
|
+
)}
|
|
60
|
+
</Show>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function AccountBlock(props: { api: TuiPluginApi; item: AccountUsage }) {
|
|
65
|
+
const theme = () => props.api.theme.current
|
|
66
|
+
return (
|
|
67
|
+
<box flexDirection="column">
|
|
68
|
+
<text fg={theme().text}>
|
|
69
|
+
{props.item.active ? "●" : "○"} {props.item.label}
|
|
70
|
+
{props.item.active ? " (当前)" : ""}
|
|
71
|
+
</text>
|
|
72
|
+
<Show when={props.item.error}>
|
|
73
|
+
<text fg={theme().error}> {props.item.error}</text>
|
|
74
|
+
</Show>
|
|
75
|
+
<Show when={props.item.usage}>
|
|
76
|
+
{(usage) => (
|
|
77
|
+
<box flexDirection="column" paddingLeft={2}>
|
|
78
|
+
<WindowRow api={props.api} name="5h" win={usage().five_hour} />
|
|
79
|
+
<WindowRow api={props.api} name="7d" win={usage().seven_day} />
|
|
80
|
+
<WindowRow api={props.api} name="Sonnet" win={usage().seven_day_sonnet} />
|
|
81
|
+
</box>
|
|
82
|
+
)}
|
|
83
|
+
</Show>
|
|
84
|
+
</box>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function UsageDialog(props: { api: TuiPluginApi; state: () => UsageState }) {
|
|
89
|
+
const api = props.api
|
|
90
|
+
const theme = () => api.theme.current
|
|
91
|
+
return (
|
|
92
|
+
<box paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} gap={1} flexDirection="column">
|
|
93
|
+
<box flexDirection="row" justifyContent="space-between">
|
|
94
|
+
<text fg={theme().text}>
|
|
95
|
+
<b>Claude 账号用量</b>
|
|
96
|
+
</text>
|
|
97
|
+
<text fg={theme().textMuted}>esc 关闭</text>
|
|
98
|
+
</box>
|
|
99
|
+
<Show when={props.state().loading && props.state().results.length === 0}>
|
|
100
|
+
<text fg={theme().textMuted}>加载中…</text>
|
|
101
|
+
</Show>
|
|
102
|
+
<Show when={props.state().error}>
|
|
103
|
+
<text fg={theme().error}>{props.state().error}</text>
|
|
104
|
+
</Show>
|
|
105
|
+
<For each={props.state().results}>{(item) => <AccountBlock api={api} item={item} />}</For>
|
|
106
|
+
<Show when={props.state().updatedAt}>
|
|
107
|
+
<text fg={theme().textMuted}>更新于 {clockTime(props.state().updatedAt!)}</text>
|
|
108
|
+
</Show>
|
|
109
|
+
</box>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function openUsageDialog(api: TuiPluginApi, state: () => UsageState): void {
|
|
114
|
+
api.ui.dialog.setSize("medium")
|
|
115
|
+
api.ui.dialog.replace(() => <UsageDialog api={api} state={state} />)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function openSwitchDialog(
|
|
119
|
+
api: TuiPluginApi,
|
|
120
|
+
accounts: StoredAccount[],
|
|
121
|
+
activeId: string | undefined,
|
|
122
|
+
onSwitch: (id: string) => void | Promise<void>,
|
|
123
|
+
): void {
|
|
124
|
+
api.ui.dialog.replace(() =>
|
|
125
|
+
api.ui.DialogSelect<string>({
|
|
126
|
+
title: "切换 Claude 账号",
|
|
127
|
+
current: activeId,
|
|
128
|
+
options: accounts.map((account) => ({
|
|
129
|
+
title: account.id === activeId ? `${account.label} (当前)` : account.label,
|
|
130
|
+
value: account.id,
|
|
131
|
+
})),
|
|
132
|
+
onSelect: (option) => {
|
|
133
|
+
api.ui.dialog.clear()
|
|
134
|
+
void onSwitch(option.value)
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
)
|
|
138
|
+
}
|
package/src/profile.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { OAUTH_BETA, PROFILE_ENDPOINT } from "./constants.ts"
|
|
2
|
+
|
|
3
|
+
export type Profile = {
|
|
4
|
+
uuid: string
|
|
5
|
+
email: string
|
|
6
|
+
displayName: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function fetchProfile(access: string): Promise<Profile> {
|
|
10
|
+
const res = await fetch(PROFILE_ENDPOINT, {
|
|
11
|
+
headers: { Authorization: `Bearer ${access}`, "anthropic-beta": OAUTH_BETA },
|
|
12
|
+
})
|
|
13
|
+
if (!res.ok) throw new Error(`profile request failed (${res.status})`)
|
|
14
|
+
|
|
15
|
+
const json = (await res.json()) as {
|
|
16
|
+
account?: { uuid?: string; email?: string; display_name?: string; full_name?: string }
|
|
17
|
+
}
|
|
18
|
+
const account = json.account
|
|
19
|
+
if (!account?.uuid) throw new Error("profile response missing account uuid")
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
uuid: account.uuid,
|
|
23
|
+
email: account.email ?? account.uuid,
|
|
24
|
+
displayName: account.display_name ?? account.full_name ?? account.email ?? account.uuid,
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { AuthToken, StoredAccount } from "./accounts.ts"
|
|
2
|
+
import { loadAccounts, readAuthAnthropic, saveAccounts, upsertAccount, withAuthLock, writeAuthAnthropic } from "./accounts.ts"
|
|
3
|
+
import { CLIENT_ID, OAUTH_BETA, TOKEN_EXPIRY_BUFFER_MS, TOKEN_URL, USAGE_ENDPOINT } from "./constants.ts"
|
|
4
|
+
import { fetchProfile } from "./profile.ts"
|
|
5
|
+
|
|
6
|
+
export type UsageWindow = { utilization: number; resets_at?: string }
|
|
7
|
+
|
|
8
|
+
export type UsageResponse = {
|
|
9
|
+
five_hour?: UsageWindow
|
|
10
|
+
seven_day?: UsageWindow
|
|
11
|
+
seven_day_sonnet?: UsageWindow
|
|
12
|
+
seven_day_opus?: UsageWindow
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AccountUsage = {
|
|
16
|
+
id: string
|
|
17
|
+
label: string
|
|
18
|
+
active: boolean
|
|
19
|
+
usage?: UsageResponse
|
|
20
|
+
error?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function errorMessage(error: unknown): string {
|
|
24
|
+
return error instanceof Error ? error.message : String(error)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isStale(token: { access?: string; expires?: number }): boolean {
|
|
28
|
+
return !token.access || !token.expires || token.expires < Date.now() + TOKEN_EXPIRY_BUFFER_MS
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function refreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
|
|
32
|
+
const res = await fetch(TOKEN_URL, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refresh, client_id: CLIENT_ID }),
|
|
36
|
+
})
|
|
37
|
+
if (!res.ok) throw new Error(`token refresh failed (${res.status})`)
|
|
38
|
+
const json = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
|
|
39
|
+
return {
|
|
40
|
+
access: json.access_token,
|
|
41
|
+
refresh: json.refresh_token,
|
|
42
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function fetchUsage(access: string): Promise<UsageResponse> {
|
|
47
|
+
const res = await fetch(USAGE_ENDPOINT, {
|
|
48
|
+
headers: { Authorization: `Bearer ${access}`, "anthropic-beta": OAUTH_BETA },
|
|
49
|
+
})
|
|
50
|
+
if (!res.ok) throw new Error(`usage request failed (${res.status})`)
|
|
51
|
+
return (await res.json()) as UsageResponse
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Identify whatever account ex-machina currently holds in auth.json by its profile
|
|
55
|
+
// uuid (stable across token rotation) and upsert it: the same account is updated in
|
|
56
|
+
// place, a genuinely new login is added — so no manual /account-add is needed.
|
|
57
|
+
export async function autoCapture(): Promise<void> {
|
|
58
|
+
await withAuthLock(async () => {
|
|
59
|
+
const auth = await readAuthAnthropic()
|
|
60
|
+
if (!auth?.refresh) return
|
|
61
|
+
|
|
62
|
+
let token: AuthToken = { refresh: auth.refresh, access: auth.access, expires: auth.expires }
|
|
63
|
+
if (isStale(token)) {
|
|
64
|
+
token = await refreshToken(token.refresh)
|
|
65
|
+
await writeAuthAnthropic(token)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const profile = await fetchProfile(token.access!)
|
|
69
|
+
await upsertAccount(profile.uuid, profile.email, token)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function ensureFresh(account: StoredAccount): Promise<{ access?: string; updated?: StoredAccount }> {
|
|
74
|
+
if (!isStale(account)) return { access: account.access }
|
|
75
|
+
const fresh = await refreshToken(account.refresh)
|
|
76
|
+
return { access: fresh.access, updated: { ...account, ...fresh } }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function collectAllUsage(): Promise<{ activeId?: string; results: AccountUsage[] }> {
|
|
80
|
+
const file = await loadAccounts()
|
|
81
|
+
|
|
82
|
+
const settled = await Promise.all(
|
|
83
|
+
file.accounts.map(async (account): Promise<{ result: AccountUsage; updated?: StoredAccount }> => {
|
|
84
|
+
const base = { id: account.id, label: account.label, active: account.id === file.activeId }
|
|
85
|
+
try {
|
|
86
|
+
const { access, updated } = await ensureFresh(account)
|
|
87
|
+
if (!access) return { result: { ...base, error: "missing access token" }, updated }
|
|
88
|
+
return { result: { ...base, usage: await fetchUsage(access) }, updated }
|
|
89
|
+
} catch (error) {
|
|
90
|
+
return { result: { ...base, error: errorMessage(error) } }
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
const updated = settled.flatMap((entry) => (entry.updated ? [entry.updated] : []))
|
|
96
|
+
if (updated.length > 0) {
|
|
97
|
+
await withAuthLock(async () => {
|
|
98
|
+
const current = await loadAccounts()
|
|
99
|
+
for (const account of updated) {
|
|
100
|
+
const index = current.accounts.findIndex((existing) => existing.id === account.id)
|
|
101
|
+
if (index >= 0) current.accounts[index] = { ...current.accounts[index], ...account }
|
|
102
|
+
}
|
|
103
|
+
await saveAccounts(current)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { activeId: file.activeId, results: settled.map((entry) => entry.result) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function switchToAccount(id: string): Promise<StoredAccount> {
|
|
111
|
+
return withAuthLock(async () => {
|
|
112
|
+
const file = await loadAccounts()
|
|
113
|
+
const index = file.accounts.findIndex((account) => account.id === id)
|
|
114
|
+
if (index < 0) throw new Error("account not found")
|
|
115
|
+
|
|
116
|
+
let account = file.accounts[index]
|
|
117
|
+
if (isStale(account)) {
|
|
118
|
+
const fresh = await refreshToken(account.refresh)
|
|
119
|
+
account = { ...account, ...fresh }
|
|
120
|
+
file.accounts[index] = account
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
file.activeId = id
|
|
124
|
+
await saveAccounts(file)
|
|
125
|
+
await writeAuthAnthropic({ refresh: account.refresh, access: account.access, expires: account.expires })
|
|
126
|
+
return account
|
|
127
|
+
})
|
|
128
|
+
}
|
package/tui.tsx
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { createSignal } from "solid-js"
|
|
2
|
+
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { loadAccounts } from "./src/accounts.ts"
|
|
4
|
+
import { autoCapture, collectAllUsage, switchToAccount } from "./src/usage.ts"
|
|
5
|
+
import { installAutoSwitch } from "./src/autoswitch.ts"
|
|
6
|
+
import { openSwitchDialog, openUsageDialog, type UsageState } from "./src/dialogs.tsx"
|
|
7
|
+
|
|
8
|
+
const ID = "claude-accounts-usage"
|
|
9
|
+
|
|
10
|
+
function message(error: unknown): string {
|
|
11
|
+
return error instanceof Error ? error.message : String(error)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const tui: TuiPlugin = async (api) => {
|
|
15
|
+
const [state, setState] = createSignal<UsageState>({ loading: false, results: [] })
|
|
16
|
+
|
|
17
|
+
const autoSwitch = installAutoSwitch(api)
|
|
18
|
+
api.lifecycle.onDispose(autoSwitch.dispose)
|
|
19
|
+
|
|
20
|
+
const refreshUsage = async () => {
|
|
21
|
+
try {
|
|
22
|
+
await autoCapture()
|
|
23
|
+
const { results } = await collectAllUsage()
|
|
24
|
+
autoSwitch.setUsageCache(results)
|
|
25
|
+
setState({ loading: false, results, updatedAt: Date.now() })
|
|
26
|
+
} catch (error) {
|
|
27
|
+
setState((prev) => ({ loading: false, results: prev.results, error: message(error) }))
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
void autoCapture().catch(() => undefined)
|
|
32
|
+
|
|
33
|
+
const command = api.command
|
|
34
|
+
if (!command) {
|
|
35
|
+
api.ui.toast({ variant: "error", message: "当前 OpenCode 不支持命令注册 API,请更新 OpenCode" })
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
command.register(() => [
|
|
40
|
+
{
|
|
41
|
+
title: "Claude: 查看账号用量",
|
|
42
|
+
value: `${ID}.usage`,
|
|
43
|
+
category: "Claude",
|
|
44
|
+
slash: { name: "usage" },
|
|
45
|
+
onSelect: () => {
|
|
46
|
+
setState((prev) => ({ ...prev, loading: true, error: undefined }))
|
|
47
|
+
openUsageDialog(api, state)
|
|
48
|
+
void refreshUsage()
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
title: "Claude: 切换账号",
|
|
53
|
+
value: `${ID}.switch`,
|
|
54
|
+
category: "Claude",
|
|
55
|
+
slash: { name: "switch" },
|
|
56
|
+
onSelect: async () => {
|
|
57
|
+
await autoCapture().catch(() => undefined)
|
|
58
|
+
const file = await loadAccounts()
|
|
59
|
+
if (file.accounts.length === 0) {
|
|
60
|
+
api.ui.toast({ variant: "warning", message: "没有账号。请先用 ex-machina 登录 Claude" })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
openSwitchDialog(api, file.accounts, file.activeId, async (id) => {
|
|
64
|
+
try {
|
|
65
|
+
const account = await switchToAccount(id)
|
|
66
|
+
api.ui.toast({ variant: "success", message: `已切换到 ${account.label},下次对话生效` })
|
|
67
|
+
} catch (error) {
|
|
68
|
+
api.ui.toast({ variant: "error", message: `切换失败: ${message(error)}` })
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
])
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const plugin: TuiPluginModule & { id: string } = { id: ID, tui }
|
|
77
|
+
|
|
78
|
+
export default plugin
|