@xtruder/opencode-claude-max-plugin 0.1.17 → 0.2.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 +56 -18
- package/build/index.d.ts.map +1 -1
- package/build/index.js +256 -336
- package/build/model.d.ts.map +1 -1
- package/build/prompt.d.ts.map +1 -1
- package/build/server.d.ts +14 -0
- package/build/server.d.ts.map +1 -0
- package/build/server.js +1212 -0
- package/build/stream.d.ts.map +1 -1
- package/build/usage.d.ts +72 -5
- package/build/usage.d.ts.map +1 -1
- package/package.json +40 -4
- package/src/credentials.ts +156 -0
- package/src/tui.tsx +396 -0
- package/src/usage.ts +331 -0
- package/build/usage-cache.d.ts +0 -15
- package/build/usage-cache.d.ts.map +0 -1
package/src/usage.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude subscription usage tracking.
|
|
3
|
+
*
|
|
4
|
+
* Two data sources:
|
|
5
|
+
* 1. Usage API (GET /api/oauth/usage) — full per-model breakdown, rate limited
|
|
6
|
+
* 2. Response headers (anthropic-ratelimit-unified-*) — 5h/7d only, every inference call
|
|
7
|
+
*
|
|
8
|
+
* Both are persisted to a shared cache file (~/.local/state/opencode/usage-cache.json)
|
|
9
|
+
* so the TUI plugin (main process) can read data written by the server plugin (worker process).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
closeSync,
|
|
13
|
+
constants as fsConstants,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
openSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
unlinkSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from "node:fs"
|
|
21
|
+
import { homedir } from "node:os"
|
|
22
|
+
import { join } from "node:path"
|
|
23
|
+
import { readClaudeCredentials } from "./credentials.ts"
|
|
24
|
+
|
|
25
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export interface UsageWindow {
|
|
28
|
+
utilization: number // percentage (0-100)
|
|
29
|
+
resets_at: string | null // ISO timestamp
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UsageData {
|
|
33
|
+
five_hour: UsageWindow | null
|
|
34
|
+
seven_day: UsageWindow | null
|
|
35
|
+
seven_day_sonnet: UsageWindow | null
|
|
36
|
+
seven_day_opus: UsageWindow | null
|
|
37
|
+
seven_day_oauth_apps: UsageWindow | null
|
|
38
|
+
seven_day_cowork: UsageWindow | null
|
|
39
|
+
extra_usage: {
|
|
40
|
+
is_enabled: boolean
|
|
41
|
+
monthly_limit: number | null
|
|
42
|
+
used_credits: number | null
|
|
43
|
+
utilization: number | null
|
|
44
|
+
} | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Lightweight usage from response headers (5h/7d only).
|
|
49
|
+
* Updated by the server plugin's wrappedFetch on every inference call.
|
|
50
|
+
*/
|
|
51
|
+
export interface HeaderUsage {
|
|
52
|
+
fiveHourUtil?: number // 0-1 ratio
|
|
53
|
+
sevenDayUtil?: number // 0-1 ratio
|
|
54
|
+
fiveHourReset?: number // epoch seconds
|
|
55
|
+
sevenDayReset?: number // epoch seconds
|
|
56
|
+
overageStatus?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Shared cache file format. Contains the best available data from
|
|
61
|
+
* both the API and response headers, plus metadata for rate-limit-aware polling.
|
|
62
|
+
*/
|
|
63
|
+
export interface UsageCache {
|
|
64
|
+
/** Full API response (null if never fetched or no credentials) */
|
|
65
|
+
api: UsageData | null
|
|
66
|
+
/** Lightweight header data (null if no inference has run yet) */
|
|
67
|
+
headers: HeaderUsage | null
|
|
68
|
+
/** When the cache was last written (epoch ms) */
|
|
69
|
+
updatedAt: number
|
|
70
|
+
/** When the API was last successfully fetched (epoch ms, 0 = never) */
|
|
71
|
+
apiFetchedAt: number
|
|
72
|
+
/** When the API rate limit expires (epoch ms, 0 = not rate limited) */
|
|
73
|
+
apiRateLimitUntil: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── In-memory state (server plugin, worker process) ─────────────────
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* In-memory header usage, updated on every inference call.
|
|
80
|
+
* Only meaningful in the server/worker process.
|
|
81
|
+
*/
|
|
82
|
+
export const cachedUsage: HeaderUsage = {}
|
|
83
|
+
|
|
84
|
+
// ─── Cache file ──────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const XDG_STATE = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state")
|
|
87
|
+
const USAGE_CACHE_DIR = join(XDG_STATE, "opencode")
|
|
88
|
+
const USAGE_CACHE_FILE = join(USAGE_CACHE_DIR, "usage-cache.json")
|
|
89
|
+
|
|
90
|
+
function emptyCache(): UsageCache {
|
|
91
|
+
return { api: null, headers: null, updatedAt: 0, apiFetchedAt: 0, apiRateLimitUntil: 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const USAGE_LOCK_FILE = USAGE_CACHE_FILE + ".lock"
|
|
95
|
+
const USAGE_TMP_FILE = USAGE_CACHE_FILE + ".tmp"
|
|
96
|
+
|
|
97
|
+
/** Max time a lock can be held before we consider it stale (ms). */
|
|
98
|
+
const LOCK_STALE_MS = 5_000
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Acquire an exclusive lock using O_CREAT|O_EXCL (atomic on all filesystems).
|
|
102
|
+
* Returns true if acquired, false if another process holds it.
|
|
103
|
+
* Stale locks (older than LOCK_STALE_MS) are automatically broken.
|
|
104
|
+
*/
|
|
105
|
+
function acquireLock(): boolean {
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(USAGE_CACHE_DIR, { recursive: true })
|
|
108
|
+
const fd = openSync(
|
|
109
|
+
USAGE_LOCK_FILE,
|
|
110
|
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY,
|
|
111
|
+
)
|
|
112
|
+
// Write PID + timestamp so we can detect stale locks
|
|
113
|
+
writeFileSync(fd, `${process.pid}\n${Date.now()}`)
|
|
114
|
+
closeSync(fd)
|
|
115
|
+
return true
|
|
116
|
+
} catch (err: any) {
|
|
117
|
+
if (err?.code !== "EEXIST") return false
|
|
118
|
+
// Lock file exists — check if it's stale
|
|
119
|
+
try {
|
|
120
|
+
const text = readFileSync(USAGE_LOCK_FILE, "utf-8")
|
|
121
|
+
const ts = parseInt(text.split("\n")[1] ?? "0")
|
|
122
|
+
if (Date.now() - ts > LOCK_STALE_MS) {
|
|
123
|
+
// Stale lock — break it and retry
|
|
124
|
+
unlinkSync(USAGE_LOCK_FILE)
|
|
125
|
+
return acquireLock()
|
|
126
|
+
}
|
|
127
|
+
} catch {
|
|
128
|
+
// Can't read lock file — try to break it
|
|
129
|
+
try {
|
|
130
|
+
unlinkSync(USAGE_LOCK_FILE)
|
|
131
|
+
} catch {}
|
|
132
|
+
return acquireLock()
|
|
133
|
+
}
|
|
134
|
+
return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function releaseLock(): void {
|
|
139
|
+
try {
|
|
140
|
+
unlinkSync(USAGE_LOCK_FILE)
|
|
141
|
+
} catch {}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read the shared cache file. Returns empty cache if not found.
|
|
146
|
+
*/
|
|
147
|
+
export function readUsageCache(): UsageCache {
|
|
148
|
+
try {
|
|
149
|
+
const text = readFileSync(USAGE_CACHE_FILE, "utf-8")
|
|
150
|
+
const data = JSON.parse(text)
|
|
151
|
+
if (data && typeof data.updatedAt === "number") return data as UsageCache
|
|
152
|
+
} catch {
|
|
153
|
+
// File doesn't exist yet or is invalid
|
|
154
|
+
}
|
|
155
|
+
return emptyCache()
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Transactional read-modify-write of the cache file.
|
|
160
|
+
* Uses a lockfile for mutual exclusion and write-to-tmp+rename for atomicity.
|
|
161
|
+
*/
|
|
162
|
+
export function writeUsageCache(update: Partial<UsageCache>): void {
|
|
163
|
+
if (!acquireLock()) return // Another process is writing — skip this update
|
|
164
|
+
try {
|
|
165
|
+
const existing = readUsageCache()
|
|
166
|
+
const merged: UsageCache = {
|
|
167
|
+
...existing,
|
|
168
|
+
...update,
|
|
169
|
+
updatedAt: Date.now(),
|
|
170
|
+
}
|
|
171
|
+
mkdirSync(USAGE_CACHE_DIR, { recursive: true })
|
|
172
|
+
writeFileSync(USAGE_TMP_FILE, JSON.stringify(merged))
|
|
173
|
+
renameSync(USAGE_TMP_FILE, USAGE_CACHE_FILE)
|
|
174
|
+
} catch {
|
|
175
|
+
// Non-fatal — clean up tmp if it exists
|
|
176
|
+
try {
|
|
177
|
+
unlinkSync(USAGE_TMP_FILE)
|
|
178
|
+
} catch {}
|
|
179
|
+
} finally {
|
|
180
|
+
releaseLock()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Persist current in-memory header usage to the cache file.
|
|
186
|
+
* Called from the server plugin's wrappedFetch after parsing response headers.
|
|
187
|
+
*/
|
|
188
|
+
export function persistCachedUsage(): void {
|
|
189
|
+
if (cachedUsage.fiveHourUtil == null && cachedUsage.sevenDayUtil == null) return
|
|
190
|
+
writeUsageCache({ headers: { ...cachedUsage } })
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ─── Usage API ───────────────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
const USAGE_URL = "https://api.anthropic.com/api/oauth/usage"
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Fetch current Claude subscription usage from the API.
|
|
199
|
+
* Returns { data, retryAfterMs } so callers can track rate limit state.
|
|
200
|
+
* retryAfterMs > 0 means the API returned 429 with a Retry-After header.
|
|
201
|
+
*/
|
|
202
|
+
export async function fetchUsage(
|
|
203
|
+
credentialsPath?: string,
|
|
204
|
+
): Promise<{ data: UsageData | null; retryAfterMs: number }> {
|
|
205
|
+
const creds = readClaudeCredentials(credentialsPath)
|
|
206
|
+
if (!creds) return { data: null, retryAfterMs: 0 }
|
|
207
|
+
if (creds.expiresAt && creds.expiresAt < Date.now()) return { data: null, retryAfterMs: 0 }
|
|
208
|
+
|
|
209
|
+
const resp = await fetch(USAGE_URL, {
|
|
210
|
+
headers: {
|
|
211
|
+
authorization: `Bearer ${creds.accessToken}`,
|
|
212
|
+
"anthropic-beta": "claude-code-20250219,oauth-2025-04-20",
|
|
213
|
+
"content-type": "application/json",
|
|
214
|
+
"user-agent": "claude-cli/2.1.81 (external, sdk-cli)",
|
|
215
|
+
"x-app": "cli",
|
|
216
|
+
},
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
if (resp.status === 429) {
|
|
220
|
+
const retryAfter = parseInt(resp.headers.get("retry-after") ?? "60")
|
|
221
|
+
// Minimum 60s backoff — the usage API often returns retry-after: 0
|
|
222
|
+
// but keeps rejecting for much longer
|
|
223
|
+
const retryAfterMs = Math.max(60_000, retryAfter * 1000)
|
|
224
|
+
return { data: null, retryAfterMs }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!resp.ok) return { data: null, retryAfterMs: 0 }
|
|
228
|
+
const data = (await resp.json()) as UsageData
|
|
229
|
+
return { data, retryAfterMs: 0 }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Convert HeaderUsage (from response headers) to UsageData (API format).
|
|
236
|
+
* Provides a partial view — only 5h and 7d windows are available.
|
|
237
|
+
*/
|
|
238
|
+
export function headerUsageToUsageData(h: HeaderUsage): UsageData {
|
|
239
|
+
return {
|
|
240
|
+
five_hour:
|
|
241
|
+
h.fiveHourUtil != null
|
|
242
|
+
? {
|
|
243
|
+
utilization: h.fiveHourUtil * 100,
|
|
244
|
+
resets_at: h.fiveHourReset ? new Date(h.fiveHourReset * 1000).toISOString() : null,
|
|
245
|
+
}
|
|
246
|
+
: null,
|
|
247
|
+
seven_day:
|
|
248
|
+
h.sevenDayUtil != null
|
|
249
|
+
? {
|
|
250
|
+
utilization: h.sevenDayUtil * 100,
|
|
251
|
+
resets_at: h.sevenDayReset ? new Date(h.sevenDayReset * 1000).toISOString() : null,
|
|
252
|
+
}
|
|
253
|
+
: null,
|
|
254
|
+
seven_day_sonnet: null,
|
|
255
|
+
seven_day_opus: null,
|
|
256
|
+
seven_day_oauth_apps: null,
|
|
257
|
+
seven_day_cowork: null,
|
|
258
|
+
extra_usage: null,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get the best available UsageData from the cache by merging both sources.
|
|
264
|
+
* - Headers provide the freshest 5h/7d numbers (updated every inference call)
|
|
265
|
+
* - API provides per-model breakdown and extra usage (updated less frequently)
|
|
266
|
+
* When both exist, header 5h/7d values override API values since they're fresher.
|
|
267
|
+
*/
|
|
268
|
+
export function bestUsageFromCache(cache: UsageCache): UsageData | null {
|
|
269
|
+
if (!cache.api && !cache.headers) return null
|
|
270
|
+
|
|
271
|
+
const fromHeaders = cache.headers ? headerUsageToUsageData(cache.headers) : null
|
|
272
|
+
const fromApi = cache.api
|
|
273
|
+
|
|
274
|
+
if (!fromApi) return fromHeaders
|
|
275
|
+
if (!fromHeaders) return fromApi
|
|
276
|
+
|
|
277
|
+
// Merge: use fresh header data for 5h/7d, API data for everything else
|
|
278
|
+
return {
|
|
279
|
+
five_hour: fromHeaders.five_hour ?? fromApi.five_hour,
|
|
280
|
+
seven_day: fromHeaders.seven_day ?? fromApi.seven_day,
|
|
281
|
+
seven_day_sonnet: fromApi.seven_day_sonnet,
|
|
282
|
+
seven_day_opus: fromApi.seven_day_opus,
|
|
283
|
+
seven_day_oauth_apps: fromApi.seven_day_oauth_apps,
|
|
284
|
+
seven_day_cowork: fromApi.seven_day_cowork,
|
|
285
|
+
extra_usage: fromApi.extra_usage,
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** How long API data is considered fresh before re-fetching (5 minutes). */
|
|
290
|
+
const API_TTL_MS = 5 * 60_000
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Check if the API should be called.
|
|
294
|
+
* Returns true when not rate limited AND either never fetched or TTL expired.
|
|
295
|
+
*/
|
|
296
|
+
export function shouldFetchApi(cache: UsageCache): boolean {
|
|
297
|
+
// Respect rate limit backoff
|
|
298
|
+
if (cache.apiRateLimitUntil && Date.now() < cache.apiRateLimitUntil) return false
|
|
299
|
+
// Fetch if never fetched or TTL expired
|
|
300
|
+
if (!cache.apiFetchedAt) return true
|
|
301
|
+
return Date.now() - cache.apiFetchedAt > API_TTL_MS
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Format a reset timestamp as relative time.
|
|
306
|
+
*/
|
|
307
|
+
export function formatReset(resetsAt: string | null): string {
|
|
308
|
+
if (!resetsAt) return ""
|
|
309
|
+
const reset = new Date(resetsAt)
|
|
310
|
+
const now = new Date()
|
|
311
|
+
const diffMs = reset.getTime() - now.getTime()
|
|
312
|
+
|
|
313
|
+
if (diffMs <= 0) return "Reset now"
|
|
314
|
+
|
|
315
|
+
const diffMin = Math.floor(diffMs / 60000)
|
|
316
|
+
const diffHour = Math.floor(diffMin / 60)
|
|
317
|
+
|
|
318
|
+
const timeStr = reset.toLocaleTimeString("en-US", {
|
|
319
|
+
hour: "numeric",
|
|
320
|
+
minute: "2-digit",
|
|
321
|
+
timeZoneName: "short",
|
|
322
|
+
})
|
|
323
|
+
const dateStr = reset.toLocaleDateString("en-US", {
|
|
324
|
+
month: "short",
|
|
325
|
+
day: "numeric",
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
if (diffHour < 1) return `Resets in ${diffMin}m`
|
|
329
|
+
if (diffHour < 24) return `Resets ${timeStr}`
|
|
330
|
+
return `Resets ${dateStr}, ${timeStr}`
|
|
331
|
+
}
|
package/build/usage-cache.d.ts
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Cached ratelimit usage from the most recent API response headers.
|
|
3
|
-
* Updated on every successful inference call — same data Claude Code
|
|
4
|
-
* reads for its /usage display. No extra API call needed.
|
|
5
|
-
*/
|
|
6
|
-
export interface CachedUsage {
|
|
7
|
-
fiveHourUtil?: number;
|
|
8
|
-
sevenDayUtil?: number;
|
|
9
|
-
fiveHourReset?: number;
|
|
10
|
-
sevenDayReset?: number;
|
|
11
|
-
overageStatus?: string;
|
|
12
|
-
timestamp?: number;
|
|
13
|
-
}
|
|
14
|
-
export declare const cachedUsage: CachedUsage;
|
|
15
|
-
//# sourceMappingURL=usage-cache.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"usage-cache.d.ts","sourceRoot":"","sources":["../src/usage-cache.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,WAAW;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,eAAO,MAAM,WAAW,EAAE,WAAgB,CAAA"}
|