claude-accounts-usage 0.1.0 → 0.1.1
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/package.json +1 -1
- package/src/autoswitch.ts +1 -15
- package/src/constants.ts +1 -1
- package/src/debug.ts +17 -0
- package/src/usage.ts +83 -23
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-accounts-usage",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
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
|
|
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
3
|
import { CLIENT_ID, 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 = {
|
|
@@ -28,19 +35,51 @@ function isStale(token: { access?: string; expires?: number }): boolean {
|
|
|
28
35
|
return !token.access || !token.expires || token.expires < Date.now() + TOKEN_EXPIRY_BUFFER_MS
|
|
29
36
|
}
|
|
30
37
|
|
|
31
|
-
|
|
32
|
-
const
|
|
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
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function doRefreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
|
|
49
|
+
return fetch(TOKEN_URL, {
|
|
33
50
|
method: "POST",
|
|
34
51
|
headers: { "Content-Type": "application/json" },
|
|
35
52
|
body: JSON.stringify({ grant_type: "refresh_token", refresh_token: refresh, client_id: CLIENT_ID }),
|
|
53
|
+
}).then(async (res) => {
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
const body = await res.text().catch(() => "")
|
|
56
|
+
const headers: Record<string, string> = {}
|
|
57
|
+
res.headers.forEach((value, key) => {
|
|
58
|
+
headers[key] = value
|
|
59
|
+
})
|
|
60
|
+
debugLog("refresh-failed", { status: res.status, headers, body: body.slice(0, 800) }, true)
|
|
61
|
+
if (res.status === 429) {
|
|
62
|
+
refresh429Cooldown.set(refresh, Date.now() + REFRESH_429_COOLDOWN_MS)
|
|
63
|
+
}
|
|
64
|
+
throw new Error(`token refresh failed (${res.status})`)
|
|
65
|
+
}
|
|
66
|
+
const json = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
|
|
67
|
+
return {
|
|
68
|
+
access: json.access_token,
|
|
69
|
+
refresh: json.refresh_token,
|
|
70
|
+
expires: Date.now() + json.expires_in * 1000,
|
|
71
|
+
}
|
|
36
72
|
})
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function refreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
|
|
76
|
+
const existing = inflightRefresh.get(refresh)
|
|
77
|
+
if (existing) return existing
|
|
78
|
+
const promise = doRefreshToken(refresh).finally(() => {
|
|
79
|
+
inflightRefresh.delete(refresh)
|
|
80
|
+
})
|
|
81
|
+
inflightRefresh.set(refresh, promise)
|
|
82
|
+
return promise
|
|
44
83
|
}
|
|
45
84
|
|
|
46
85
|
export async function fetchUsage(access: string): Promise<UsageResponse> {
|
|
@@ -72,25 +111,42 @@ export async function autoCapture(): Promise<void> {
|
|
|
72
111
|
|
|
73
112
|
async function ensureFresh(account: StoredAccount): Promise<{ access?: string; updated?: StoredAccount }> {
|
|
74
113
|
if (!isStale(account)) return { access: account.access }
|
|
114
|
+
if (isRefresh429Cooldown(account.refresh)) {
|
|
115
|
+
debugLog("refresh-skip-429-cooldown", { label: account.label }, true)
|
|
116
|
+
return { access: account.access }
|
|
117
|
+
}
|
|
75
118
|
const fresh = await refreshToken(account.refresh)
|
|
76
119
|
return { access: fresh.access, updated: { ...account, ...fresh } }
|
|
77
120
|
}
|
|
78
121
|
|
|
122
|
+
function sleep(ms: number): Promise<void> {
|
|
123
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
124
|
+
}
|
|
125
|
+
|
|
79
126
|
export async function collectAllUsage(): Promise<{ activeId?: string; results: AccountUsage[] }> {
|
|
80
127
|
const file = await loadAccounts()
|
|
81
128
|
|
|
82
|
-
const settled =
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
const settled: { result: AccountUsage; updated?: StoredAccount }[] = []
|
|
130
|
+
let needsRefresh = false
|
|
131
|
+
|
|
132
|
+
for (const account of file.accounts) {
|
|
133
|
+
const base = { id: account.id, label: account.label, active: account.id === file.activeId }
|
|
134
|
+
try {
|
|
135
|
+
const { access, updated } = await ensureFresh(account)
|
|
136
|
+
if (updated) needsRefresh = true
|
|
137
|
+
if (!access) {
|
|
138
|
+
settled.push({ result: { ...base, error: "missing access token" }, updated })
|
|
139
|
+
continue
|
|
91
140
|
}
|
|
92
|
-
|
|
93
|
-
|
|
141
|
+
const usage = await fetchUsage(access)
|
|
142
|
+
settled.push({ result: { ...base, usage }, updated })
|
|
143
|
+
} catch (error) {
|
|
144
|
+
settled.push({ result: { ...base, error: errorMessage(error) } })
|
|
145
|
+
}
|
|
146
|
+
if (needsRefresh && file.accounts.indexOf(account) < file.accounts.length - 1) {
|
|
147
|
+
await sleep(REFRESH_DELAY_MS)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
94
150
|
|
|
95
151
|
const updated = settled.flatMap((entry) => (entry.updated ? [entry.updated] : []))
|
|
96
152
|
if (updated.length > 0) {
|
|
@@ -115,9 +171,13 @@ export async function switchToAccount(id: string): Promise<StoredAccount> {
|
|
|
115
171
|
|
|
116
172
|
let account = file.accounts[index]
|
|
117
173
|
if (isStale(account)) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
174
|
+
if (isRefresh429Cooldown(account.refresh)) {
|
|
175
|
+
debugLog("switch-skip-429-cooldown", { label: account.label }, true)
|
|
176
|
+
} else {
|
|
177
|
+
const fresh = await refreshToken(account.refresh)
|
|
178
|
+
account = { ...account, ...fresh }
|
|
179
|
+
file.accounts[index] = account
|
|
180
|
+
}
|
|
121
181
|
}
|
|
122
182
|
|
|
123
183
|
file.activeId = id
|