claude-accounts-usage 0.1.0 → 0.1.2
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/README.md +5 -3
- package/package.json +1 -1
- package/src/autoswitch.ts +1 -15
- package/src/constants.ts +7 -1
- package/src/debug.ts +17 -0
- package/src/usage.ts +94 -29
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
工作方式:
|
|
21
21
|
|
|
22
|
-
-
|
|
22
|
+
- **检测**:主要监听 OpenCode 的 `session.status` 的 retry 事件(同时也注册 `session.next.retried` / `session.error` 作为补充,但 TUI 插件通常只能收到 `session.status`),只在命中 Anthropic 订阅额度签名(`anthropic-ratelimit-unified-*: rejected`,或响应体/消息含 `rate_limit_error` + 额度文案,或 429 状态码)时触发;529 过载等会被排除,避免误切号。
|
|
23
23
|
- **选号**:优先按用量挑剩余额度最多的账号(用 `/usage` 时缓存的数据,TTL 10 分钟),无缓存则轮询下一个;跳过正在冷却(已知额度未恢复)的账号。
|
|
24
24
|
- **重发**:切号后将会话回退到失败的那条用户消息并用新账号重发(`revert` + `promptAsync`)。注意:若该轮中途已产生文件改动,回退会一并撤销并整轮重做。
|
|
25
25
|
- **冷却**:撞限的账号按响应头给出的 reset 时间(缺省 60 分钟)进入冷却,持久化在 `tui.json` 的 KV 中;账号下次成功使用后自动解除冷却。
|
|
@@ -41,12 +41,14 @@ TUI 插件只在 `~/.config/opencode/tui.json` 配置,**不要**放进 `opencode
|
|
|
41
41
|
```json
|
|
42
42
|
{
|
|
43
43
|
"$schema": "https://opencode.ai/tui.json",
|
|
44
|
-
"plugin": ["claude-accounts-usage"]
|
|
44
|
+
"plugin": ["claude-accounts-usage@0.1.2"]
|
|
45
45
|
}
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
OpenCode 会自动解析并安装该包,无需手动 `npm install`。
|
|
49
49
|
|
|
50
|
+
> **建议带上版本号**(如 `@0.1.2`)。OpenCode 按"含版本号的包名"建独立缓存目录:写死版本号后,以后升级只需把后缀改成新版本号;若不带版本号,会被首次安装的版本锁住,发布新版也不会自动更新。
|
|
51
|
+
|
|
50
52
|
### 方式二:本地 clone(开发/离线)
|
|
51
53
|
|
|
52
54
|
```bash
|
|
@@ -85,7 +87,7 @@ cd claude-accounts-usage && bun install
|
|
|
85
87
|
## 已知限制
|
|
86
88
|
|
|
87
89
|
- ex-machina 同一时刻只持有一个账号,所以一个新账号必须先用 ex-machina 登录过一次,插件才能在下次加载/操作时收录它。
|
|
88
|
-
- 自动切号依赖 OpenCode 的 `session.
|
|
90
|
+
- 自动切号依赖 OpenCode 的 `session.status` 事件(辅以 `session.next.retried` / `session.error`),因此只对经由 OpenCode(及 ex-machina)发出的 Anthropic 请求生效;额度恢复后的解除冷却需要该账号成功跑过一次对话。
|
|
89
91
|
|
|
90
92
|
## License
|
|
91
93
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-accounts-usage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "OpenCode TUI plugin to view multi-account Claude usage and switch the active account. Coexists with @ex-machina/opencode-anthropic-auth.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/autoswitch.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import { appendFile } from "node:fs/promises"
|
|
2
|
-
import { homedir } from "node:os"
|
|
3
|
-
import { join } from "node:path"
|
|
4
1
|
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
|
5
2
|
import { loadAccounts, readActiveId, type AccountsFile, type StoredAccount } from "./accounts.ts"
|
|
3
|
+
import { debugLog } from "./debug.ts"
|
|
6
4
|
import { collectAllUsage, switchToAccount, type AccountUsage, type UsageResponse } from "./usage.ts"
|
|
7
5
|
|
|
8
6
|
const ENABLED = true
|
|
@@ -107,18 +105,6 @@ function sleep(ms: number): Promise<void> {
|
|
|
107
105
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
108
106
|
}
|
|
109
107
|
|
|
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
108
|
export function installAutoSwitch(api: TuiPluginApi): AutoSwitchController {
|
|
123
109
|
const cooldown = new Map<string, number>()
|
|
124
110
|
const attempted = new Map<string, Set<string>>()
|
package/src/constants.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// by that plugin are accepted by the refresh endpoint below.
|
|
4
4
|
export const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
5
5
|
|
|
6
|
-
export const TOKEN_URL = "https://
|
|
6
|
+
export const TOKEN_URL = "https://platform.claude.com/v1/oauth/token"
|
|
7
7
|
|
|
8
8
|
export const USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
|
|
9
9
|
|
|
@@ -14,3 +14,9 @@ export const OAUTH_BETA = "oauth-2025-04-20"
|
|
|
14
14
|
// Refresh slightly before real expiry so neither ex-machina nor the usage call
|
|
15
15
|
// receives an already-stale access token.
|
|
16
16
|
export const TOKEN_EXPIRY_BUFFER_MS = 60_000
|
|
17
|
+
|
|
18
|
+
// When /usage runs, proactively refresh INACTIVE accounts whose access token
|
|
19
|
+
// expires within this window, so an idle account stays fresh between /usage opens
|
|
20
|
+
// (and is ready for auto-switch). Never applied to the active account — ex-machina
|
|
21
|
+
// owns and rotates that one, so racing it would cause invalid_grant.
|
|
22
|
+
export const INACTIVE_REFRESH_THRESHOLD_MS = 30 * 60_000
|
package/src/debug.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { appendFile } from "node:fs/promises"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
|
|
5
|
+
const LOG_PATH = join(homedir(), ".config", "opencode", "claude-autoswitch.log")
|
|
6
|
+
|
|
7
|
+
export function debugLog(tag: string, payload: unknown, force = false): void {
|
|
8
|
+
if (!force && !process.env.CLAUDE_AUTOSWITCH_DEBUG) return
|
|
9
|
+
let serialized: string
|
|
10
|
+
try {
|
|
11
|
+
serialized = JSON.stringify(payload)
|
|
12
|
+
} catch {
|
|
13
|
+
serialized = String(payload)
|
|
14
|
+
}
|
|
15
|
+
const line = `${new Date().toISOString()} [${tag}] ${serialized}\n`
|
|
16
|
+
void appendFile(LOG_PATH, line).catch(() => undefined)
|
|
17
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import type { AuthToken, StoredAccount } from "./accounts.ts"
|
|
2
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"
|
|
3
|
+
import { CLIENT_ID, INACTIVE_REFRESH_THRESHOLD_MS, OAUTH_BETA, TOKEN_EXPIRY_BUFFER_MS, TOKEN_URL, USAGE_ENDPOINT } from "./constants.ts"
|
|
4
|
+
import { debugLog } from "./debug.ts"
|
|
4
5
|
import { fetchProfile } from "./profile.ts"
|
|
5
6
|
|
|
7
|
+
const REFRESH_DELAY_MS = 500
|
|
8
|
+
const REFRESH_429_COOLDOWN_MS = 5 * 60_000
|
|
9
|
+
|
|
10
|
+
const inflightRefresh = new Map<string, Promise<{ access: string; refresh: string; expires: number }>>()
|
|
11
|
+
const refresh429Cooldown = new Map<string, number>()
|
|
12
|
+
|
|
6
13
|
export type UsageWindow = { utilization: number; resets_at?: string }
|
|
7
14
|
|
|
8
15
|
export type UsageResponse = {
|
|
@@ -24,23 +31,59 @@ function errorMessage(error: unknown): string {
|
|
|
24
31
|
return error instanceof Error ? error.message : String(error)
|
|
25
32
|
}
|
|
26
33
|
|
|
27
|
-
function isStale(token: { access?: string; expires?: number }): boolean {
|
|
28
|
-
return !token.access || !token.expires || token.expires < Date.now() +
|
|
34
|
+
function isStale(token: { access?: string; expires?: number }, bufferMs = TOKEN_EXPIRY_BUFFER_MS): boolean {
|
|
35
|
+
return !token.access || !token.expires || token.expires < Date.now() + bufferMs
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isRefresh429Cooldown(refresh: string): boolean {
|
|
39
|
+
const until = refresh429Cooldown.get(refresh)
|
|
40
|
+
if (!until) return false
|
|
41
|
+
if (Date.now() >= until) {
|
|
42
|
+
refresh429Cooldown.delete(refresh)
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
return true
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
|
|
48
|
+
function doRefreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
|
|
49
|
+
return fetch(TOKEN_URL, {
|
|
33
50
|
method: "POST",
|
|
34
|
-
headers: {
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "application/json",
|
|
53
|
+
Accept: "application/json, text/plain, */*",
|
|
54
|
+
"User-Agent": "axios/1.13.6",
|
|
55
|
+
},
|
|
35
56
|
body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refresh, client_id: CLIENT_ID }),
|
|
57
|
+
}).then(async (res) => {
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
const body = await res.text().catch(() => "")
|
|
60
|
+
const headers: Record<string, string> = {}
|
|
61
|
+
res.headers.forEach((value, key) => {
|
|
62
|
+
headers[key] = value
|
|
63
|
+
})
|
|
64
|
+
debugLog("refresh-failed", { status: res.status, headers, body: body.slice(0, 800) }, true)
|
|
65
|
+
if (res.status === 429) {
|
|
66
|
+
refresh429Cooldown.set(refresh, Date.now() + REFRESH_429_COOLDOWN_MS)
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`token refresh failed (${res.status})`)
|
|
69
|
+
}
|
|
70
|
+
const json = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
|
|
71
|
+
return {
|
|
72
|
+
access: json.access_token,
|
|
73
|
+
refresh: json.refresh_token,
|
|
74
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
75
|
+
}
|
|
36
76
|
})
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function refreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
|
|
80
|
+
const existing = inflightRefresh.get(refresh)
|
|
81
|
+
if (existing) return existing
|
|
82
|
+
const promise = doRefreshToken(refresh).finally(() => {
|
|
83
|
+
inflightRefresh.delete(refresh)
|
|
84
|
+
})
|
|
85
|
+
inflightRefresh.set(refresh, promise)
|
|
86
|
+
return promise
|
|
44
87
|
}
|
|
45
88
|
|
|
46
89
|
export async function fetchUsage(access: string): Promise<UsageResponse> {
|
|
@@ -70,27 +113,45 @@ export async function autoCapture(): Promise<void> {
|
|
|
70
113
|
})
|
|
71
114
|
}
|
|
72
115
|
|
|
73
|
-
async function ensureFresh(account: StoredAccount): Promise<{ access?: string; updated?: StoredAccount }> {
|
|
74
|
-
if (!isStale(account)) return { access: account.access }
|
|
116
|
+
async function ensureFresh(account: StoredAccount, bufferMs?: number): Promise<{ access?: string; updated?: StoredAccount }> {
|
|
117
|
+
if (!isStale(account, bufferMs)) return { access: account.access }
|
|
118
|
+
if (isRefresh429Cooldown(account.refresh)) {
|
|
119
|
+
debugLog("refresh-skip-429-cooldown", { label: account.label }, true)
|
|
120
|
+
return { access: account.access }
|
|
121
|
+
}
|
|
75
122
|
const fresh = await refreshToken(account.refresh)
|
|
76
123
|
return { access: fresh.access, updated: { ...account, ...fresh } }
|
|
77
124
|
}
|
|
78
125
|
|
|
126
|
+
function sleep(ms: number): Promise<void> {
|
|
127
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
128
|
+
}
|
|
129
|
+
|
|
79
130
|
export async function collectAllUsage(): Promise<{ activeId?: string; results: AccountUsage[] }> {
|
|
80
131
|
const file = await loadAccounts()
|
|
81
132
|
|
|
82
|
-
const settled =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
133
|
+
const settled: { result: AccountUsage; updated?: StoredAccount }[] = []
|
|
134
|
+
let needsRefresh = false
|
|
135
|
+
|
|
136
|
+
for (const account of file.accounts) {
|
|
137
|
+
const base = { id: account.id, label: account.label, active: account.id === file.activeId }
|
|
138
|
+
const bufferMs = account.id === file.activeId ? TOKEN_EXPIRY_BUFFER_MS : INACTIVE_REFRESH_THRESHOLD_MS
|
|
139
|
+
try {
|
|
140
|
+
const { access, updated } = await ensureFresh(account, bufferMs)
|
|
141
|
+
if (updated) needsRefresh = true
|
|
142
|
+
if (!access) {
|
|
143
|
+
settled.push({ result: { ...base, error: "missing access token" }, updated })
|
|
144
|
+
continue
|
|
91
145
|
}
|
|
92
|
-
|
|
93
|
-
|
|
146
|
+
const usage = await fetchUsage(access)
|
|
147
|
+
settled.push({ result: { ...base, usage }, updated })
|
|
148
|
+
} catch (error) {
|
|
149
|
+
settled.push({ result: { ...base, error: errorMessage(error) } })
|
|
150
|
+
}
|
|
151
|
+
if (needsRefresh && file.accounts.indexOf(account) < file.accounts.length - 1) {
|
|
152
|
+
await sleep(REFRESH_DELAY_MS)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
94
155
|
|
|
95
156
|
const updated = settled.flatMap((entry) => (entry.updated ? [entry.updated] : []))
|
|
96
157
|
if (updated.length > 0) {
|
|
@@ -115,9 +176,13 @@ export async function switchToAccount(id: string): Promise<StoredAccount> {
|
|
|
115
176
|
|
|
116
177
|
let account = file.accounts[index]
|
|
117
178
|
if (isStale(account)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
179
|
+
if (isRefresh429Cooldown(account.refresh)) {
|
|
180
|
+
debugLog("switch-skip-429-cooldown", { label: account.label }, true)
|
|
181
|
+
} else {
|
|
182
|
+
const fresh = await refreshToken(account.refresh)
|
|
183
|
+
account = { ...account, ...fresh }
|
|
184
|
+
file.accounts[index] = account
|
|
185
|
+
}
|
|
121
186
|
}
|
|
122
187
|
|
|
123
188
|
file.activeId = id
|