@opsee/cli 0.11.13 → 0.11.18

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.
@@ -0,0 +1,489 @@
1
+ import { isQuarantined, type Account } from "./account.js";
2
+ import { readCredentialText, realCredentialDeps, type CredentialDeps } from "./credential-store.js";
3
+ import type { UsageWindow } from "./usage.js";
4
+ import type { UsageStore } from "./usage-store.js";
5
+
6
+ /**
7
+ * The usage poller (the multi-account headroom design, §3).
8
+ *
9
+ * **This is the only module in the package permitted to read a vendor credential, and the only
10
+ * reason ADR-0013 was amended.** What the amendment allows is exactly what happens here: a token is
11
+ * read in order to ask that same vendor, on the user's own machine, how much of the user's own quota
12
+ * is left. It is never copied, never stored, never logged, never put in an error message, and never
13
+ * sent anywhere but `ANTHROPIC_USAGE_URL`. `usage-poller-boundary.test.ts` holds it to that.
14
+ *
15
+ * Why a poller at all, when the Worker's own stream already reports usage: in-band numbers only
16
+ * arrive for an Account that is *running*. An idle Account emits nothing, so "switch to the one with
17
+ * the most quota left" cannot be answered from in-band signals alone. This fills in the idle ones.
18
+ *
19
+ * The endpoint's shape is not invented. It is pinned from claude-swap's MIT-licensed `oauth.py`
20
+ * (`request_usage_data`, `build_usage_result`), and the one trap it holds is the scale: **this
21
+ * endpoint reports `utilization` as a percentage (0–100)**, where the in-band
22
+ * `rate_limit_info.utilization` is a fraction (0–1).
23
+ */
24
+
25
+ export const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
26
+
27
+ /** The beta header the OAuth endpoints require. */
28
+ export const OAUTH_BETA_HEADER = "oauth-2025-04-20";
29
+
30
+ /** What the poller calls itself to the vendor. */
31
+ const USER_AGENT = "opsee-foreman";
32
+
33
+ /**
34
+ * The usage endpoint has a **request cap**, and every number below exists to stay under it.
35
+ *
36
+ * Roughly 28-30 requests an hour per identity, over a **trailing window** rather than a refilling
37
+ * bucket: capacity comes back only as old requests age out of the hour behind you, so a burst
38
+ * saturates the identity for a full hour and waiting does not return it early. (Measured and
39
+ * documented by claude-swap's `poll_policy.py`, whose floors these mirror.)
40
+ *
41
+ * The first cut of this asked every two minutes — about thirty requests an hour per Account, level
42
+ * with the cap and leaving nothing for a human running a command by hand. These numbers target
43
+ * twelve an hour at worst, which leaves most of the cap unspent.
44
+ *
45
+ * "Cap" rather than "budget" deliberately: `cli/CONTEXT.md` puts *Budget* on Headroom's avoid list,
46
+ * and a second thing in this package wearing an avoided word is how the vocabulary that file exists
47
+ * to keep straight comes apart.
48
+ */
49
+
50
+ /** The hard floor. Nothing may ask about one Account more often than this, for any reason. */
51
+ export const MIN_POLL_INTERVAL_MS = 3 * 60_000;
52
+
53
+ /** How long a measured Account is left alone. Idle Accounts are what the poller is *for* — a
54
+ * running one reports in band — and an idle Account's usage moves slowly by definition. */
55
+ export const IDLE_POLL_MS = 5 * 60_000;
56
+
57
+ /** How long an Account at or past its limit is left alone: nothing about it changes until its window
58
+ * resets, so asking more often spends requests to learn the same number. */
59
+ export const EXHAUSTED_POLL_MS = 10 * 60_000;
60
+
61
+ /**
62
+ * How long an Account is left alone after the endpoint rate-limits us, when it names no interval.
63
+ *
64
+ * An hour, because that is the window that produced the refusal: with a trailing window, capacity
65
+ * returns as the burst ages out, and a retry in five minutes spends requests with no chance of
66
+ * having recovered. The endpoint's own `Retry-After` is preferred when it sends one.
67
+ */
68
+ export const RATE_LIMITED_BACKOFF_MS = 60 * 60_000;
69
+
70
+ /**
71
+ * The longest any backoff may last, whatever the endpoint asks for.
72
+ *
73
+ * `Retry-After: 999999999` is thirty-one years. Honouring it would take the Account out of every
74
+ * schedule for the rest of the machine's life, with nothing short of deleting the usage file to
75
+ * bring it back — a header from a misconfigured proxy would be indistinguishable from a permanent
76
+ * deregistration. Six hours is far past any real limit window and still recovers by itself.
77
+ */
78
+ export const MAX_BACKOFF_MS = 6 * 60 * 60_000;
79
+
80
+ /**
81
+ * How long a stored number may be printed without asking again.
82
+ *
83
+ * What `account usage --refresh` honours: a human asking to refresh still must not be able to spend
84
+ * the hour's requests by running a command repeatedly, and a number three minutes old is not one
85
+ * worth a request.
86
+ *
87
+ * The same value as `MIN_POLL_INTERVAL_MS` today, and kept separate because they answer different
88
+ * questions — one is "how old may a printed number be", the other "how often may this be asked" —
89
+ * and a future reason to move one is not a reason to move the other.
90
+ */
91
+ export const SERVE_TTL_MS = 3 * 60_000;
92
+
93
+ /** How far an Account's own schedule is nudged off every other Account's, as a fraction of its
94
+ * interval. Deterministic per name rather than random: a fleet still spreads out, and a test does
95
+ * not depend on a dice roll. */
96
+ const JITTER_FRACTION = 0.1;
97
+
98
+ /** Above this, an Account counts as exhausted for cadence purposes. Deliberately not the dispatch
99
+ * threshold: this is about how often to ask, not about whether to use the Account. */
100
+ const EXHAUSTED_PCT = 95;
101
+
102
+ /** How many Accounts are polled in one pass. A fleet should not become a burst of requests. */
103
+ export const POLL_BATCH = 2;
104
+
105
+ /** How many `--refresh` may ask about at once. Larger than a poll pass, because a human has just
106
+ * asked and is waiting — but still bounded, so a fleet is not one burst. */
107
+ export const REFRESH_BATCH = 6;
108
+
109
+ export type TokenRead = { ok: true; token: string } | { ok: false; reason: string };
110
+
111
+ export type UsageFetch =
112
+ | { ok: true; windows: Record<string, UsageWindow> }
113
+ /** `retryAfterMs` is set when the endpoint rate-limited us: the interval it named, or the window
114
+ * that produced the refusal. Nothing asks again inside it. */
115
+ | { ok: false; reason: string; retryAfterMs?: number };
116
+
117
+ /** The seam every consumer of usage sees. `FakeUsageSource` in tests, `AnthropicUsageSource` in
118
+ * the daemon; nothing above this interface needs a credential or a network. */
119
+ export interface UsageSource {
120
+ fetch(account: Account, now: number): Promise<UsageFetch>;
121
+ }
122
+
123
+ /**
124
+ * The OAuth access token of one config directory.
125
+ *
126
+ * Exported because it is the credential boundary made explicit: this function, through
127
+ * `credential-store.ts`, is the only place a credential is read. Every failure is a one-line reason
128
+ * that quotes **nothing** of what was read — a poller failure is written to the usage store,
129
+ * printed by `account usage` and logged, so a reason carrying the credential would leak a token
130
+ * into all three.
131
+ */
132
+ export async function accessTokenFrom(configDir: string, deps: CredentialDeps): Promise<TokenRead> {
133
+ // Wherever the vendor actually put it. On macOS that is a Keychain item derived from this config
134
+ // directory, not a file — the assumption that it was always a file is what made every poll on a
135
+ // Mac fail and every Account read "never observed".
136
+ const stored = await readCredentialText("claude", configDir, deps);
137
+ if (!stored.ok) return stored;
138
+ let parsed: unknown;
139
+ try {
140
+ parsed = JSON.parse(stored.text);
141
+ } catch {
142
+ return { ok: false, reason: "the stored credential is not valid JSON" };
143
+ }
144
+ const token = (parsed as { claudeAiOauth?: { accessToken?: unknown } } | null)?.claudeAiOauth?.accessToken;
145
+ if (typeof token !== "string" || token === "") {
146
+ return { ok: false, reason: "the stored credential holds no OAuth access token (an API-key login reports no usage)" };
147
+ }
148
+ return { ok: true, token };
149
+ }
150
+
151
+ export interface AnthropicUsageSourceDeps {
152
+ /** Defaults to `accessTokenFrom` over the real seams; a test hands in its own so no real
153
+ * credential is involved. */
154
+ readToken?: (configDir: string) => Promise<TokenRead> | TokenRead;
155
+ /** Defaults to `fetch`. Takes the headers separately so a test can assert where the token went. */
156
+ fetchJson?: (url: string, headers: Record<string, string>) => Promise<{ ok: true; body: unknown } | { ok: false; reason: string; retryAfterMs?: number }>;
157
+ }
158
+
159
+ export class AnthropicUsageSource implements UsageSource {
160
+ private readonly readToken: (configDir: string) => Promise<TokenRead> | TokenRead;
161
+ private readonly fetchJson: NonNullable<AnthropicUsageSourceDeps["fetchJson"]>;
162
+
163
+ constructor(deps: AnthropicUsageSourceDeps = {}) {
164
+ this.readToken = deps.readToken ?? ((configDir) => accessTokenFrom(configDir, realCredentialDeps));
165
+ this.fetchJson = deps.fetchJson ?? defaultFetchJson;
166
+ }
167
+
168
+ async fetch(account: Account, now: number): Promise<UsageFetch> {
169
+ // An API-key Account has no config directory and no OAuth token: there is nothing to read and
170
+ // nothing to ask. Refused before `readToken` is reached, so the boundary is not even approached
171
+ // for an Account this could never measure.
172
+ if (account.type !== "subscription") {
173
+ return { ok: false, reason: "an API-key Account reports no usage" };
174
+ }
175
+ const token = await this.readToken(account.configDir);
176
+ if (!token.ok) return { ok: false, reason: token.reason };
177
+
178
+ const response = await this.fetchJson(ANTHROPIC_USAGE_URL, {
179
+ Authorization: `Bearer ${token.token}`,
180
+ "anthropic-beta": OAUTH_BETA_HEADER,
181
+ "User-Agent": USER_AGENT,
182
+ });
183
+ if (!response.ok) return { ok: false, reason: response.reason, ...(response.retryAfterMs === undefined ? {} : { retryAfterMs: response.retryAfterMs }) };
184
+
185
+ const windows = windowsFromUsageResponse(response.body, new Date(now).toISOString());
186
+ // An empty success would write nothing and clear the previous failure, which reads as "measured
187
+ // and found nothing". A response with no window in it is a response we did not understand.
188
+ if (!windows) return { ok: false, reason: "the usage response held no window with both a utilization and a reset" };
189
+ return { ok: true, windows };
190
+ }
191
+ }
192
+
193
+ async function defaultFetchJson(url: string, headers: Record<string, string>): Promise<{ ok: true; body: unknown } | { ok: false; reason: string; retryAfterMs?: number }> {
194
+ try {
195
+ const response = await fetch(url, { headers, signal: AbortSignal.timeout(5_000) });
196
+ if (response.status === 429) {
197
+ // The endpoint's own interval when it names one, else the window that produced the refusal.
198
+ const named = Number(response.headers.get("retry-after"));
199
+ // Capped: `Retry-After` is whatever the far end says, and an absurd one must not be a life
200
+ // sentence (`MAX_BACKOFF_MS`). An HTTP-date rather than seconds parses as NaN and takes the
201
+ // default, which is the right answer for a header this does not understand.
202
+ const retryAfterMs = Number.isFinite(named) && named > 0 ? Math.min(named * 1_000, MAX_BACKOFF_MS) : RATE_LIMITED_BACKOFF_MS;
203
+ return { ok: false, reason: "HTTP 429", retryAfterMs };
204
+ }
205
+ if (!response.ok) return { ok: false, reason: `HTTP ${response.status}` };
206
+ return { ok: true, body: await response.json() };
207
+ } catch (error) {
208
+ // The vendor's message, not the request: nothing here may echo a header.
209
+ return { ok: false, reason: error instanceof Error ? error.message : "the usage request failed" };
210
+ }
211
+ }
212
+
213
+ interface RawWindow {
214
+ utilization?: unknown;
215
+ resets_at?: unknown;
216
+ }
217
+
218
+ /**
219
+ * The endpoint's response as usage observations, or undefined when it held nothing usable.
220
+ *
221
+ * The window keys are deliberately the vendor's own — `five_hour`, `seven_day` — because those are
222
+ * exactly the keys the in-band stream uses. If they differed, the same window would be stored twice
223
+ * and `headroom` would read whichever copy was staler as a real limit.
224
+ *
225
+ * Per-model weekly limits live in a newer `limits` array rather than under those two keys, so each
226
+ * is surfaced as `weekly_<model>`; without this, a Fable weekly limit would be invisible to every
227
+ * decision. The `extra_usage` spend axis is deliberately ignored: it is money, not a rate-limit
228
+ * window, and holding an Account back because of it would be a different product decision.
229
+ */
230
+ export function windowsFromUsageResponse(body: unknown, observedAt: string): Record<string, UsageWindow> | undefined {
231
+ if (!body || typeof body !== "object") return undefined;
232
+ const data = body as { five_hour?: RawWindow; seven_day?: RawWindow; limits?: unknown };
233
+ const windows: Record<string, UsageWindow> = {};
234
+
235
+ for (const name of ["five_hour", "seven_day"] as const) {
236
+ const window = toUsageWindow(data[name]?.utilization, data[name]?.resets_at, observedAt);
237
+ if (window) windows[name] = window;
238
+ }
239
+
240
+ if (Array.isArray(data.limits)) {
241
+ for (const limit of data.limits as { scope?: { model?: { display_name?: unknown } }; percent?: unknown; resets_at?: unknown }[]) {
242
+ if (!limit || typeof limit !== "object") continue;
243
+ const model = limit.scope?.model?.display_name;
244
+ if (typeof model !== "string" || model === "") continue;
245
+ const window = toUsageWindow(limit.percent, limit.resets_at, observedAt);
246
+ if (window) windows[`weekly_${model.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`] = window;
247
+ }
248
+ }
249
+
250
+ return Object.keys(windows).length === 0 ? undefined : windows;
251
+ }
252
+
253
+ /** One window, or undefined unless it has both a numeric utilization and a parseable reset. A
254
+ * utilization with no reset describes no interval; a reset with no utilization is not a measurement. */
255
+ function toUsageWindow(utilization: unknown, resetsAt: unknown, observedAt: string): UsageWindow | undefined {
256
+ if (typeof utilization !== "number" || !Number.isFinite(utilization)) return undefined;
257
+ if (typeof resetsAt !== "string" || !Number.isFinite(Date.parse(resetsAt))) return undefined;
258
+ return {
259
+ // Taken as given, not multiplied: this endpoint already reports a percentage. The in-band
260
+ // stream is the one that reports a fraction (claude-worker-adapter.ts `usageWindowsFrom`).
261
+ usedPct: Math.max(0, Math.min(100, Math.round(utilization * 10) / 10)),
262
+ resetsAt,
263
+ observedAt,
264
+ source: "polled",
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Which Accounts to poll in this pass, most overdue first, at most `limit` of them.
270
+ *
271
+ * Adaptive rather than a flat interval, for the reason claude-swap gives: an Account nobody has
272
+ * measured is worth a request now, a busy one is worth watching, and an exhausted one has nothing
273
+ * to say until it resets. Pure, so the cadence is testable with a fake clock.
274
+ *
275
+ * Three kinds of Account are never polled at all: an API-key Account (no window exists), a
276
+ * quarantined one (its credential is dead, so the request can only fail), and another vendor's
277
+ * (no such endpoint). A *Paused* Account is polled at the exhausted cadence — it is Paused because
278
+ * it hit a limit, and its reset is the one thing still worth confirming.
279
+ */
280
+ export function chooseToPoll(
281
+ accounts: readonly Account[],
282
+ usage: ReadonlyMap<string, PollableRecord>,
283
+ now: number,
284
+ limit: number = POLL_BATCH,
285
+ ): Account[] {
286
+ // An exhausted Account is the slowest case: a failure must never make one *more* eager, which is
287
+ // what picking a failure interval ahead of the exhausted one did.
288
+ return overdueBy(accounts, usage, now, (record) => (isExhausted(record, now) ? EXHAUSTED_POLL_MS : IDLE_POLL_MS))
289
+ .slice(0, Math.max(0, limit))
290
+ .map((d) => d.account);
291
+ }
292
+
293
+ /**
294
+ * The Accounts whose numbers are too old to print without asking again (`SERVE_TTL_MS`).
295
+ *
296
+ * What `account usage --refresh` uses. A shorter interval than the poller's, because a human has
297
+ * just said they want it now — but the same floors apply underneath: an Account inside a rate-limit
298
+ * window is not asked however explicitly it was requested, since the requests it would spend are the
299
+ * one the Foreman needs to keep working.
300
+ */
301
+ export function staleFor(accounts: readonly Account[], usage: ReadonlyMap<string, PollableRecord>, now: number, limit: number = REFRESH_BATCH): Account[] {
302
+ return overdueBy(accounts, usage, now, () => SERVE_TTL_MS)
303
+ .slice(0, Math.max(0, limit))
304
+ .map((d) => d.account);
305
+ }
306
+
307
+ /** What either schedule needs of a stored record. */
308
+ export type PollableRecord = { windows: Record<string, UsageWindow>; pollFailedAt?: string; pollBackoffUntil?: string };
309
+
310
+ /**
311
+ * Whether an Account is one the poller speaks to at all: the vendor that has a usage endpoint, a
312
+ * subscription rather than a key, and not quarantined.
313
+ *
314
+ * A predicate rather than an inline condition because it is asked in two places — here, to decide
315
+ * what to measure, and by the daemon, to decide whether "nothing can be measured" is even true. The
316
+ * daemon had its own copy, which is the one that would have gone stale the day a second vendor
317
+ * reports usage, and gone stale silently: it only decides what gets said.
318
+ */
319
+ export function pollable(account: Account): boolean {
320
+ return account.vendor === "claude" && account.type === "subscription" && !isQuarantined(account);
321
+ }
322
+
323
+ /** When an Account's endpoint-imposed rate-limit window lifts, or `undefined` if it is not in one.
324
+ * Unparseable and already-past deadlines are both "not in one", so a corrupt record cannot wedge an
325
+ * Account out of the schedule for good. */
326
+ function backoffUntil(record: PollableRecord | undefined, now: number): number | undefined {
327
+ const at = record?.pollBackoffUntil ? Date.parse(record.pollBackoffUntil) : Number.NaN;
328
+ return Number.isFinite(at) && at > now ? at : undefined;
329
+ }
330
+
331
+ /**
332
+ * When every pollable Account is inside a rate-limit window, the moment the first of them lifts.
333
+ * `undefined` when anything at all could be measured now, and when there is nothing to measure.
334
+ *
335
+ * The daemon's one question about an empty pass: is this the ordinary "nothing is due yet", or the
336
+ * state the poller cannot leave by itself? Answered here, beside the schedule that creates it,
337
+ * rather than re-derived from the store by whoever wants to print it.
338
+ */
339
+ export function allBackedOffUntil(accounts: readonly Account[], usage: ReadonlyMap<string, PollableRecord>, now: number): number | undefined {
340
+ let soonest: number | undefined;
341
+ let any = false;
342
+ for (const account of accounts) {
343
+ if (!pollable(account)) continue;
344
+ any = true;
345
+ const until = backoffUntil(usage.get(account.name), now);
346
+ if (until === undefined) return undefined;
347
+ soonest = soonest === undefined ? until : Math.min(soonest, until);
348
+ }
349
+ return any ? soonest : undefined;
350
+ }
351
+
352
+ /**
353
+ * The Accounts due under `interval`, most overdue first.
354
+ *
355
+ * Both schedules share this so the guards can only be written once: the vendors and states that are
356
+ * never polled, the hard floor, the rate-limit window, and the per-Account offset.
357
+ */
358
+ function overdueBy(
359
+ accounts: readonly Account[],
360
+ usage: ReadonlyMap<string, PollableRecord>,
361
+ now: number,
362
+ interval: (record: PollableRecord | undefined) => number,
363
+ ): { account: Account; overdue: number }[] {
364
+ const due: { account: Account; overdue: number }[] = [];
365
+ for (const account of accounts) {
366
+ if (!pollable(account)) continue;
367
+ const record = usage.get(account.name);
368
+ // A window the endpoint itself imposed. Nothing goes inside it: not the poller, not a human.
369
+ if (backoffUntil(record, now) !== undefined) continue;
370
+ const last = lastLookAt(record, now);
371
+ if (last === undefined) {
372
+ // Never observed: the most valuable request there is, since every decision about this Account
373
+ // is currently being made blind. `Infinity` puts it ahead of anything merely stale.
374
+ due.push({ account, overdue: Number.POSITIVE_INFINITY });
375
+ continue;
376
+ }
377
+ // The offset is a fraction of the wait actually used, not of the one asked for: derived from
378
+ // the un-floored interval it would silently shrink if an interval were ever set below the floor,
379
+ // collapsing the spread this exists to create.
380
+ const wait = Math.max(MIN_POLL_INTERVAL_MS, interval(record));
381
+ const overdue = now - last - (wait + offsetFor(account.name, wait));
382
+ if (overdue >= 0) due.push({ account, overdue });
383
+ }
384
+ return due.sort((a, b) => b.overdue - a.overdue || a.account.name.localeCompare(b.account.name));
385
+ }
386
+
387
+ /**
388
+ * A stable per-Account nudge, so a fleet registered at once does not then ask in lockstep for ever.
389
+ *
390
+ * Derived from the name, so it is the same on every run and in every test — a fleet spreads without
391
+ * a test depending on a dice roll. FNV-1a rather than the usual `hash * 31 + c`, which barely moves
392
+ * for short names: the obvious version put every one-letter Account within 200ms of the others,
393
+ * which is lockstep with extra steps.
394
+ */
395
+ function offsetFor(name: string, interval: number): number {
396
+ let hash = 0x811c9dc5;
397
+ for (let i = 0; i < name.length; i++) {
398
+ hash ^= name.charCodeAt(i);
399
+ hash = Math.imul(hash, 0x01000193) >>> 0;
400
+ }
401
+ // A finalizer, because FNV alone does not avalanche a short name: one round after a single
402
+ // character leaves the high bits almost unchanged, so "a" through "h" all landed within 1.5s of
403
+ // each other — lockstep with extra steps, which is the thing this is here to prevent.
404
+ hash ^= hash >>> 16;
405
+ hash = Math.imul(hash, 0x7feb352d) >>> 0;
406
+ hash ^= hash >>> 15;
407
+ hash = Math.imul(hash, 0x846ca68b) >>> 0;
408
+ hash = (hash ^ (hash >>> 16)) >>> 0;
409
+ return Math.round((hash / 0xffffffff) * interval * JITTER_FRACTION);
410
+ }
411
+
412
+ /**
413
+ * When this Account was last looked at, successfully or not; undefined when it never has been.
414
+ *
415
+ * A stamp in the future is disregarded rather than clamped: it is not evidence of a recent look at
416
+ * all, it is a skewed clock or a hand-edited record. Clamping it to `now` would still read as
417
+ * "measured this instant" and hold the Account out for a whole interval; dropping it leaves the
418
+ * record looking unobserved, which is the truth and puts the Account first in the queue.
419
+ */
420
+ function lastLookAt(record: PollableRecord | undefined, now: number): number | undefined {
421
+ if (!record) return undefined;
422
+ const times = [
423
+ ...Object.values(record.windows).map((w) => Date.parse(w.observedAt)),
424
+ ...(record.pollFailedAt ? [Date.parse(record.pollFailedAt)] : []),
425
+ ].filter((t) => Number.isFinite(t) && t <= now);
426
+ return times.length === 0 ? undefined : Math.max(...times);
427
+ }
428
+
429
+ function isExhausted(record: { windows: Record<string, UsageWindow> } | undefined, now: number): boolean {
430
+ return Object.values(record?.windows ?? {}).some((w) => Date.parse(w.resetsAt) > now && w.usedPct >= EXHAUSTED_PCT);
431
+ }
432
+
433
+ /**
434
+ * A failure reason with anything token-shaped taken out, and clipped to one readable line.
435
+ *
436
+ * Every reason this module builds is already clean — that is asserted — but a reason can also come
437
+ * from a transport's own thrown error, and those are outside our control: a library that puts the
438
+ * request headers in its message would otherwise write the token into the usage store, into
439
+ * `account usage`, and into the daemon's log at once. So nothing reaches the store unredacted.
440
+ */
441
+ export function redactReason(reason: string): string {
442
+ const cleaned = reason
443
+ .replace(/sk-[A-Za-z0-9_-]{4,}/g, "sk-***")
444
+ .replace(/Bearer\s+\S+/gi, "Bearer ***")
445
+ .replace(/\b[A-Za-z0-9_-]{40,}\b/g, "***");
446
+ const line = cleaned.replace(/\s+/g, " ").trim();
447
+ return line.length > 200 ? `${line.slice(0, 197)}...` : line;
448
+ }
449
+
450
+ export interface PollRequest {
451
+ store: UsageStore;
452
+ source: UsageSource;
453
+ accounts: readonly Account[];
454
+ now?: () => number;
455
+ }
456
+
457
+ /**
458
+ * Polls each Account handed in and writes the result, whichever way it went.
459
+ *
460
+ * A failure is recorded and nothing else: it never clears a window, never Pauses anything, and —
461
+ * the important one — never counts as a credential failure. `noteCredentialFailure` and the
462
+ * two-strike quarantine behind it exist for a Worker turn the vendor refused, which proves a login
463
+ * is dead. One Anthropic API blip would otherwise quarantine every Account on the machine, and a
464
+ * quarantine needs a human to lift.
465
+ */
466
+ export async function pollUsage(request: PollRequest): Promise<{ polled: number; failed: number }> {
467
+ const now = (request.now ?? Date.now)();
468
+ let polled = 0;
469
+ let failed = 0;
470
+ for (const account of request.accounts) {
471
+ let result: UsageFetch;
472
+ try {
473
+ result = await request.source.fetch(account, now);
474
+ } catch (error) {
475
+ // One Account's transport throwing must not end the pass: the next Account is the one a
476
+ // dispatch may be about to choose.
477
+ result = { ok: false, reason: error instanceof Error ? error.message : "the usage request failed" };
478
+ }
479
+ if (result.ok) {
480
+ request.store.observe(account.name, result.windows, now);
481
+ polled++;
482
+ } else {
483
+ const backoff = result.retryAfterMs === undefined ? undefined : new Date(now + Math.min(result.retryAfterMs, MAX_BACKOFF_MS));
484
+ request.store.noteFailure(account.name, new Date(now), redactReason(result.reason), backoff);
485
+ failed++;
486
+ }
487
+ }
488
+ return { polled, failed };
489
+ }