@xtruder/opencode-claude-max-plugin 0.4.5 → 2.0.0-rc.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/src/usage.ts DELETED
@@ -1,331 +0,0 @@
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
- }