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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-accounts-usage",
3
- "version": "0.1.0",
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://console.anthropic.com/v1/oauth/token"
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
- export async function refreshToken(refresh: string): Promise<{ access: string; refresh: string; expires: number }> {
32
- const res = await fetch(TOKEN_URL, {
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
- 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
- }
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 = 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) } }
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
- const fresh = await refreshToken(account.refresh)
119
- account = { ...account, ...fresh }
120
- file.accounts[index] = account
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