@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.
- package/README.md +116 -0
- package/package.json +2 -2
- package/src/args.ts +167 -15
- package/src/cli.ts +62 -6
- package/src/commands/account-usage.ts +106 -0
- package/src/commands/account.ts +134 -1
- package/src/commands/claude-launcher.ts +183 -0
- package/src/commands/foreman-up.ts +184 -4
- package/src/commands/foreman.ts +133 -11
- package/src/foreman/account.ts +106 -13
- package/src/foreman/claude-worker-adapter.ts +100 -1
- package/src/foreman/config-skeleton.ts +167 -0
- package/src/foreman/core/run.ts +157 -7
- package/src/foreman/core/scheduler.ts +139 -0
- package/src/foreman/core/text.ts +29 -0
- package/src/foreman/credential-store.ts +247 -0
- package/src/foreman/usage-activity.ts +102 -0
- package/src/foreman/usage-format.ts +107 -0
- package/src/foreman/usage-poller.ts +489 -0
- package/src/foreman/usage-store.ts +213 -0
- package/src/foreman/usage.ts +257 -0
- package/src/foreman/vendor.ts +23 -0
- package/src/foreman/worker-adapter.ts +12 -0
|
@@ -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
|
+
}
|