@adeildo/pi-providers 5.1.0 → 5.3.0

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,278 @@
1
+ // A framed screen for a provider login: what it is doing, the URL or the code to use, and the answer
2
+ // it waits for. The provider drives it through the interaction pi would hand it.
3
+ import { spawn } from "node:child_process";
4
+
5
+ import type { AuthEvent, AuthPrompt, ProviderAuthInteraction } from "@earendil-works/pi-ai";
6
+ import {
7
+ copyToClipboard,
8
+ type ExtensionContext,
9
+ type KeybindingsManager,
10
+ type Theme,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import {
13
+ type Component,
14
+ type Focusable,
15
+ hyperlink,
16
+ Input,
17
+ Key,
18
+ matchesKey,
19
+ Text,
20
+ type TUI,
21
+ truncateToWidth,
22
+ } from "@earendil-works/pi-tui";
23
+
24
+ import { frameBottom, frameInner, frameRow, frameTop } from "./frame.ts";
25
+
26
+ const MAX_LOG = 5;
27
+
28
+ /** Pi's own Text wraps the URL and the instructions into the rows the frame draws. */
29
+ function wrap(text: string, width: number): string[] {
30
+ return new Text(text, 0, 0).render(width);
31
+ }
32
+
33
+ /** The opener pi uses, so the sign-in page opens on its own and the URL stays for the rest. */
34
+ function openBrowser(target: string): void {
35
+ const opener: [string, string[]] =
36
+ process.platform === "darwin"
37
+ ? ["open", [target]]
38
+ : process.platform === "win32"
39
+ ? ["rundll32", ["url.dll,FileProtocolHandler", target]]
40
+ : ["xdg-open", [target]];
41
+ spawn(...opener, { stdio: "ignore", detached: true })
42
+ .on("error", () => {})
43
+ .unref();
44
+ }
45
+
46
+ export interface LoginSession {
47
+ interaction: ProviderAuthInteraction;
48
+ /** Ends the screen once the login finished one way or another. */
49
+ close(): void;
50
+ }
51
+
52
+ /** Opens the framed login, or undefined when this host cannot draw it. */
53
+ export function loginScreen(
54
+ ctx: ExtensionContext,
55
+ label: string,
56
+ signal: AbortSignal,
57
+ ): Promise<LoginSession | undefined> {
58
+ if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") return Promise.resolve(undefined);
59
+ return new Promise((resolve) => {
60
+ void ctx.ui.custom<undefined>((tui, theme, keybindings, done) => {
61
+ const view = new LoginView(tui, theme, label, signal, () => done(undefined), {
62
+ keybindings,
63
+ });
64
+ resolve({ interaction: view.interaction(), close: () => view.finish() });
65
+ return view;
66
+ });
67
+ });
68
+ }
69
+
70
+ interface Pending {
71
+ prompt: AuthPrompt;
72
+ resolve: (value: string) => void;
73
+ reject: (error: Error) => void;
74
+ }
75
+
76
+ /** What the screen does outside itself: open the sign-in page and copy the URL out. */
77
+ export interface LoginActions {
78
+ keybindings?: KeybindingsManager;
79
+ open?: (url: string) => void;
80
+ copy?: (text: string) => Promise<void>;
81
+ }
82
+
83
+ export class LoginView implements Component, Focusable {
84
+ focused = true;
85
+ private readonly input = new Input();
86
+ private readonly log: string[] = [];
87
+ private link: { url: string; instructions?: string } | undefined;
88
+ private code: string | undefined;
89
+ private copyNote: string | undefined;
90
+ private pending: Pending | undefined;
91
+ private selected = 0;
92
+ private closed = false;
93
+
94
+ constructor(
95
+ private readonly tui: TUI,
96
+ private readonly theme: Theme,
97
+ private readonly label: string,
98
+ private readonly signal: AbortSignal,
99
+ private readonly closeView: () => void,
100
+ private readonly actions: LoginActions = {},
101
+ ) {
102
+ this.input.onSubmit = (value) => {
103
+ // An empty answer would cancel the callback and fail the exchange.
104
+ if (value !== "") this.settle(value);
105
+ };
106
+ this.input.onEscape = () => this.cancel();
107
+ signal.addEventListener("abort", () => this.finish(), { once: true });
108
+ }
109
+
110
+ interaction(): ProviderAuthInteraction {
111
+ return {
112
+ signal: this.signal,
113
+ prompt: async (prompt) => await this.ask(prompt),
114
+ notify: (event) => this.show(event),
115
+ };
116
+ }
117
+
118
+ invalidate(): void {}
119
+
120
+ handleInput(data: string): void {
121
+ const pending = this.pending;
122
+ if (pending === undefined) return;
123
+ if (pending.prompt.type === "select") {
124
+ this.move(data, pending.prompt.options.length);
125
+ return;
126
+ }
127
+ if (this.link !== undefined && this.copyLink(data)) return;
128
+ this.input.handleInput(data);
129
+ this.tui.requestRender();
130
+ }
131
+
132
+ render(width: number): string[] {
133
+ const inner = frameInner(width);
134
+ const rows = this.log.slice(-MAX_LOG).map((line) => this.theme.fg("muted", `· ${line}`));
135
+ if (this.link !== undefined) {
136
+ if (this.link.instructions !== undefined)
137
+ rows.push(...wrap(this.theme.fg("warning", this.link.instructions), inner));
138
+ rows.push(...wrap(this.theme.fg("accent", hyperlink(this.link.url, this.link.url)), inner));
139
+ rows.push(this.linkHint());
140
+ }
141
+ if (this.code !== undefined) rows.push(this.theme.bold(this.code));
142
+ rows.push("", ...this.promptRows(inner), "", this.hint());
143
+ return [
144
+ frameTop(this.theme, `Entrar · ${this.label}`, width),
145
+ ...rows.map((row) => frameRow(this.theme, row, inner)),
146
+ frameBottom(this.theme, width),
147
+ ];
148
+ }
149
+
150
+ /** Ends the screen; the login it was drawing ended one way or another. */
151
+ finish(): void {
152
+ if (this.closed) return;
153
+ this.closed = true;
154
+ this.cancel();
155
+ this.closeView();
156
+ }
157
+
158
+ private ask(prompt: AuthPrompt): Promise<string> {
159
+ return new Promise((resolve, reject) => {
160
+ this.pending = { prompt, resolve, reject };
161
+ this.selected = 0;
162
+ this.input.setValue("");
163
+ this.tui.requestRender();
164
+ });
165
+ }
166
+
167
+ private show(event: AuthEvent): void {
168
+ if (event.type === "auth_url") {
169
+ this.link = { url: event.url, instructions: event.instructions };
170
+ this.copyNote = undefined;
171
+ (this.actions.open ?? openBrowser)(event.url);
172
+ } else if (event.type === "device_code") {
173
+ this.code = event.userCode;
174
+ this.link = { url: event.verificationUri };
175
+ this.copyNote = undefined;
176
+ } else {
177
+ this.log.push(event.message);
178
+ }
179
+ this.tui.requestRender();
180
+ }
181
+
182
+ /** The copy key on a shown URL, which is what a session without a browser needs. */
183
+ private copyLink(data: string): boolean {
184
+ const link = this.link;
185
+ if (link === undefined) return false;
186
+ if (this.actions.keybindings?.matches(data, "app.message.copy") !== true) return false;
187
+ void (this.actions.copy ?? copyToClipboard)(link.url).then(
188
+ () => {
189
+ this.copyNote = "copied";
190
+ this.tui.requestRender();
191
+ },
192
+ (error: unknown) => {
193
+ this.copyNote = error instanceof Error ? error.message : "copy failed";
194
+ this.tui.requestRender();
195
+ },
196
+ );
197
+ return true;
198
+ }
199
+
200
+ private linkHint(): string {
201
+ const keys = this.actions.keybindings?.getKeys("app.message.copy") ?? [];
202
+ const copy = this.copyNote ?? (keys.length > 0 ? `${keys.join("/")} to copy` : "copy");
203
+ const click = process.platform === "darwin" ? "cmd+click" : "ctrl+click";
204
+ return this.theme.fg("dim", `${click} to open ${copy}`);
205
+ }
206
+
207
+ private settle(value: string): void {
208
+ const pending = this.pending;
209
+ this.pending = undefined;
210
+ this.tui.requestRender();
211
+ pending?.resolve(value);
212
+ }
213
+
214
+ private cancel(): void {
215
+ const pending = this.pending;
216
+ this.pending = undefined;
217
+ this.tui.requestRender();
218
+ pending?.reject(new Error(`${this.label}: login cancelled`));
219
+ }
220
+
221
+ private move(data: string, count: number): void {
222
+ if (count === 0) return;
223
+ if (matchesKey(data, Key.up)) this.selected = (this.selected - 1 + count) % count;
224
+ else if (matchesKey(data, Key.down)) this.selected = (this.selected + 1) % count;
225
+ else if (matchesKey(data, Key.enter)) {
226
+ const pending = this.pending;
227
+ const option =
228
+ pending?.prompt.type === "select" ? pending.prompt.options[this.selected] : undefined;
229
+ if (option !== undefined) this.settle(option.id);
230
+ return;
231
+ } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
232
+ this.cancel();
233
+ return;
234
+ }
235
+ this.tui.requestRender();
236
+ }
237
+
238
+ private promptRows(inner: number): string[] {
239
+ const pending = this.pending;
240
+ if (pending === undefined) return [this.theme.fg("dim", "waiting…")];
241
+ const room = Math.max(1, inner - 2);
242
+ const asked = this.theme.fg("text", truncateToWidth(pending.prompt.message, inner));
243
+ if (pending.prompt.type === "select") {
244
+ const options = pending.prompt.options.map((option, index) => {
245
+ const pointer = index === this.selected ? this.theme.fg("accent", "❯ ") : " ";
246
+ const label = index === this.selected ? this.theme.bold(option.label) : option.label;
247
+ return `${pointer}${truncateToWidth(label, room)}`;
248
+ });
249
+ return [asked, ...options];
250
+ }
251
+
252
+ const value = this.input.getValue();
253
+ const shown =
254
+ pending.prompt.type === "secret" && value !== "" ? "•".repeat(value.length) : value;
255
+ const field =
256
+ shown === ""
257
+ ? this.theme.fg("dim", truncateToWidth(pending.prompt.placeholder ?? "", room))
258
+ : `${shown}${this.theme.fg("accent", "▎")}`;
259
+ return [asked, `${this.theme.fg("accent", "❯ ")}${field}`];
260
+ }
261
+
262
+ private hint(): string {
263
+ const keys: [string, string][] =
264
+ this.pending?.prompt.type === "select"
265
+ ? [
266
+ ["↑↓", "move"],
267
+ ["enter", "choose"],
268
+ ["esc", "cancel"],
269
+ ]
270
+ : [
271
+ ["enter", "submit"],
272
+ ["esc", "cancel"],
273
+ ];
274
+ return keys
275
+ .map(([key, action]) => `${this.theme.fg("muted", key)} ${this.theme.fg("dim", action)}`)
276
+ .join(" ");
277
+ }
278
+ }
@@ -0,0 +1,161 @@
1
+ // Our dialogs wear the same frame as the rest of the harness. A host that cannot draw a component,
2
+ // like RPC, falls back to pi's own select.
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { Theme } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ type Component,
7
+ Key,
8
+ matchesKey,
9
+ type TUI,
10
+ truncateToWidth,
11
+ visibleWidth,
12
+ } from "@earendil-works/pi-tui";
13
+
14
+ import { frameBottom, frameInner, frameRow, frameTop } from "./frame.ts";
15
+
16
+ /** One row of a picker: a label, and a second column on the right. */
17
+ export interface PickOption {
18
+ label: string;
19
+ description?: string;
20
+ }
21
+
22
+ const POINTER = "\u276f ";
23
+ const MAX_VISIBLE = 10;
24
+ const GAP = 2;
25
+ const MIN_DESCRIPTION = 12;
26
+
27
+ /** The options in our frame; undefined when the answer is cancelled. */
28
+ export async function pick(
29
+ ctx: ExtensionContext,
30
+ title: string,
31
+ options: readonly (string | PickOption)[],
32
+ dialog: { signal?: AbortSignal } = {},
33
+ ): Promise<string | undefined> {
34
+ if (options.length === 0) return undefined;
35
+ if (ctx.mode !== "tui" || typeof ctx.ui.custom !== "function") {
36
+ return await ctx.ui.select(title, options.map(plainText), dialog);
37
+ }
38
+ return await ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
39
+ let finished = false;
40
+ const finish = (value: string | undefined) => {
41
+ if (finished) return;
42
+ finished = true;
43
+ done(value);
44
+ };
45
+ dialog.signal?.addEventListener("abort", () => finish(undefined), { once: true });
46
+ return new FramedSelect(tui, theme, title, options, finish);
47
+ });
48
+ }
49
+
50
+ /** A host that only takes lines gets the two columns on one. */
51
+ function plainText(option: string | PickOption): string {
52
+ if (typeof option === "string") return option;
53
+ return option.description === undefined ? option.label : `${option.label} ${option.description}`;
54
+ }
55
+
56
+ function labelOf(option: string | PickOption): string {
57
+ return typeof option === "string" ? option : option.label;
58
+ }
59
+
60
+ function descriptionOf(option: string | PickOption): string | undefined {
61
+ return typeof option === "string" ? undefined : option.description;
62
+ }
63
+
64
+ export class FramedSelect implements Component {
65
+ private selected = 0;
66
+
67
+ constructor(
68
+ private readonly tui: TUI,
69
+ private readonly theme: Theme,
70
+ private readonly title: string,
71
+ private readonly options: readonly (string | PickOption)[],
72
+ private readonly done: (value: string | undefined) => void,
73
+ ) {}
74
+
75
+ handleInput(data: string): void {
76
+ if (matchesKey(data, Key.up)) this.move(-1);
77
+ else if (matchesKey(data, Key.down)) this.move(1);
78
+ else if (matchesKey(data, Key.home)) this.selected = 0;
79
+ else if (matchesKey(data, Key.end)) this.selected = this.options.length - 1;
80
+ else if (matchesKey(data, Key.enter)) this.choose();
81
+ else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) this.done(undefined);
82
+ else return;
83
+ this.tui.requestRender();
84
+ }
85
+
86
+ invalidate(): void {}
87
+
88
+ render(width: number): string[] {
89
+ const inner = frameInner(width);
90
+ const { start, end } = this.window();
91
+ const position =
92
+ start > 0 || end < this.options.length
93
+ ? `${this.selected + 1}/${this.options.length}`
94
+ : undefined;
95
+ return [
96
+ frameTop(this.theme, this.title, width),
97
+ ...this.options
98
+ .slice(start, end)
99
+ .map((option, index) => this.line(this.row(option, start + index, inner), inner)),
100
+ this.line("", inner),
101
+ this.line(this.hint(position), inner),
102
+ frameBottom(this.theme, width),
103
+ ];
104
+ }
105
+
106
+ /** One line: the pointer, the label, and the description in a column shared by every row. */
107
+ private row(option: string | PickOption, index: number, inner: number): string {
108
+ const chosen = index === this.selected;
109
+ const pointer = chosen ? this.theme.fg("accent", POINTER) : " ".repeat(POINTER.length);
110
+ const room = Math.max(1, inner - POINTER.length);
111
+ const description = descriptionOf(option);
112
+ if (description === undefined) return `${pointer}${truncateToWidth(labelOf(option), room)}`;
113
+
114
+ const column = this.column(room);
115
+ const name = truncateToWidth(
116
+ chosen ? this.theme.bold(labelOf(option)) : labelOf(option),
117
+ column,
118
+ );
119
+ const gap = " ".repeat(Math.max(GAP, column - visibleWidth(name)));
120
+ const rest = truncateToWidth(description, Math.max(0, room - column - gap.length));
121
+ return `${pointer}${name}${gap}${rest}`;
122
+ }
123
+
124
+ /** Wide enough for the longest label, and never more than half of the room. */
125
+ private column(room: number): number {
126
+ const widest = Math.max(...this.options.map((option) => visibleWidth(labelOf(option))));
127
+ return Math.min(widest + GAP, Math.max(MIN_DESCRIPTION, Math.floor(room / 2)));
128
+ }
129
+
130
+ private window(): { start: number; end: number } {
131
+ const start = Math.max(
132
+ 0,
133
+ Math.min(this.selected - Math.floor(MAX_VISIBLE / 2), this.options.length - MAX_VISIBLE),
134
+ );
135
+ return { start, end: Math.min(start + MAX_VISIBLE, this.options.length) };
136
+ }
137
+
138
+ private move(step: number): void {
139
+ const count = this.options.length;
140
+ this.selected = (this.selected + step + count) % count;
141
+ }
142
+
143
+ private choose(): void {
144
+ const option = this.options[this.selected];
145
+ if (option !== undefined) this.done(labelOf(option));
146
+ }
147
+
148
+ private hint(position: string | undefined): string {
149
+ const keys = [
150
+ `${this.theme.fg("muted", "↑↓")} ${this.theme.fg("dim", "move")}`,
151
+ `${this.theme.fg("muted", "enter")} ${this.theme.fg("dim", "select")}`,
152
+ `${this.theme.fg("muted", "esc")} ${this.theme.fg("dim", "cancel")}`,
153
+ ];
154
+ if (position !== undefined) keys.push(this.theme.fg("dim", position));
155
+ return keys.join(" ");
156
+ }
157
+
158
+ private line(text: string, inner: number): string {
159
+ return frameRow(this.theme, text, inner);
160
+ }
161
+ }
@@ -0,0 +1,31 @@
1
+ // A plan's windows as every dialog of this package draws them, in fixed columns so rows line up.
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+
4
+ import type { UsageWindow } from "../usage/types.ts";
5
+
6
+ const NAME_WIDTH = 4;
7
+ const PERCENT_WIDTH = 4;
8
+ const RESET_WIDTH = 13;
9
+
10
+ /** The color a window's usage reads in: healthy, getting full, or spent. */
11
+ export function quotaTone(used: number): "success" | "warning" | "error" {
12
+ if (used >= 90) return "error";
13
+ if (used >= 70) return "warning";
14
+ return "success";
15
+ }
16
+
17
+ /** One window: `5h 62% resets in 2h `. */
18
+ export function quotaWindow(theme: Theme, window: UsageWindow): string {
19
+ const percent = theme.bold(
20
+ theme.fg(quotaTone(window.used), `${window.used}%`.padStart(PERCENT_WIDTH)),
21
+ );
22
+ const reset = (window.resetsIn === undefined ? "" : `resets in ${window.resetsIn}`).padEnd(
23
+ RESET_WIDTH,
24
+ );
25
+ return `${theme.fg("muted", window.name.padEnd(NAME_WIDTH))} ${percent} ${theme.fg("muted", reset)}`;
26
+ }
27
+
28
+ /** Every window of a plan, side by side. */
29
+ export function quotaWindows(theme: Theme, windows: readonly UsageWindow[]): string {
30
+ return windows.map((window) => quotaWindow(theme, window)).join(" ");
31
+ }
@@ -0,0 +1,150 @@
1
+ // Anthropic's plan: the unified headers on every response, the usage endpoint, and the one-token call
2
+ // for when the endpoint refuses. The endpoint is rate limited per token, so it is asked once.
3
+ import { isObject } from "@adeildo/pi-kit";
4
+
5
+ import type { AccountUsage, QuotaReader, UsageRequest, Window } from "./types.ts";
6
+
7
+ const PREFIX = "anthropic-ratelimit-unified-";
8
+ const USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
9
+ const PROBE_URL = "https://api.anthropic.com/v1/messages";
10
+ const PROBE_MODEL = "claude-haiku-4-5";
11
+ const OAUTH_BETA = "oauth-2025-04-20";
12
+ const TIMEOUT_MS = 15_000;
13
+
14
+ /** How to read Anthropic's plan, with the Claude Code version the caller reports. */
15
+ export function anthropicQuota(version: () => string): QuotaReader {
16
+ return {
17
+ read: (request) => fetchUsage(request, version()),
18
+ probe: (request) => probeUsage(request, version()),
19
+ headers: fromHeaders,
20
+ };
21
+ }
22
+
23
+ export function fromHeaders(headers: Record<string, string>): AccountUsage | undefined {
24
+ const lower: Record<string, string> = {};
25
+ for (const [name, value] of Object.entries(headers)) lower[name.toLowerCase()] = value;
26
+
27
+ const session = headerWindow(lower, "5h");
28
+ const week = headerWindow(lower, "7d");
29
+ if (session === undefined && week === undefined) return undefined;
30
+ return {
31
+ ...(session === undefined ? {} : { session }),
32
+ ...(week === undefined ? {} : { week }),
33
+ };
34
+ }
35
+
36
+ export function fromEndpoint(body: unknown): AccountUsage | undefined {
37
+ if (!isObject(body)) return undefined;
38
+
39
+ const limits = Array.isArray(body.limits) ? body.limits.filter(isObject) : [];
40
+ const session = limitWindow(limits, "session") ?? bucketWindow(body.five_hour);
41
+ const week = limitWindow(limits, "weekly_all") ?? bucketWindow(body.seven_day);
42
+ if (session === undefined && week === undefined) return undefined;
43
+ return {
44
+ ...(session === undefined ? {} : { session }),
45
+ ...(week === undefined ? {} : { week }),
46
+ };
47
+ }
48
+
49
+ /** The endpoint's answer, or undefined when it refuses or the network fails. Never throws. */
50
+ export async function fetchUsage(
51
+ request: UsageRequest,
52
+ version: string,
53
+ ): Promise<AccountUsage | undefined> {
54
+ try {
55
+ const response = await fetch(USAGE_URL, {
56
+ headers: {
57
+ Authorization: `Bearer ${request.token}`,
58
+ "anthropic-beta": OAUTH_BETA,
59
+ "anthropic-version": "2023-06-01",
60
+ Accept: "application/json",
61
+ // The endpoint gives the generous bucket to callers that look like Claude Code.
62
+ "User-Agent": `claude-cli/${version} (external, cli)`,
63
+ },
64
+ signal: usageSignal(request.signal),
65
+ });
66
+ if (!response.ok) return undefined;
67
+ return fromEndpoint(await response.json());
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ /** The plan's windows from a one-token call, for when the endpoint refuses. Never throws. */
74
+ export async function probeUsage(
75
+ request: UsageRequest,
76
+ version: string,
77
+ ): Promise<AccountUsage | undefined> {
78
+ try {
79
+ const response = await fetch(PROBE_URL, {
80
+ method: "POST",
81
+ headers: {
82
+ Authorization: `Bearer ${request.token}`,
83
+ "anthropic-beta": `claude-code-20250219,${OAUTH_BETA}`,
84
+ "anthropic-version": "2023-06-01",
85
+ "content-type": "application/json",
86
+ "x-app": "cli",
87
+ "User-Agent": `claude-cli/${version}`,
88
+ },
89
+ body: JSON.stringify({
90
+ model: PROBE_MODEL,
91
+ max_tokens: 1,
92
+ stream: false,
93
+ system: [
94
+ { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
95
+ ],
96
+ messages: [{ role: "user", content: "ok" }],
97
+ }),
98
+ signal: usageSignal(request.signal),
99
+ });
100
+ // A rejection carries the windows too, so the status does not matter here.
101
+ return fromHeaders(Object.fromEntries(response.headers));
102
+ } catch {
103
+ return undefined;
104
+ }
105
+ }
106
+
107
+ /** The request signal, cancelled by the caller or by the timeout. */
108
+ function usageSignal(signal: AbortSignal | undefined): AbortSignal {
109
+ const timeout = AbortSignal.timeout(TIMEOUT_MS);
110
+ return signal === undefined ? timeout : AbortSignal.any([signal, timeout]);
111
+ }
112
+
113
+ function headerWindow(headers: Record<string, string>, key: string): Window | undefined {
114
+ const used = numberOf(headers[`${PREFIX}${key}-utilization`]);
115
+ if (used === undefined) return undefined;
116
+ // The headers carry a fraction; a percent is what a person reads.
117
+ return percentWindow(used * 100, numberOf(headers[`${PREFIX}${key}-reset`]));
118
+ }
119
+
120
+ function limitWindow(limits: readonly Record<string, unknown>[], kind: string): Window | undefined {
121
+ const entry = limits.find((candidate) => candidate.kind === kind);
122
+ if (entry === undefined) return undefined;
123
+ return percentWindow(numberOf(entry.percent), dateOf(entry.resets_at));
124
+ }
125
+
126
+ function bucketWindow(value: unknown): Window | undefined {
127
+ if (!isObject(value)) return undefined;
128
+ return percentWindow(numberOf(value.utilization), dateOf(value.resets_at));
129
+ }
130
+
131
+ function percentWindow(used: number | undefined, resetsAt: number | undefined): Window | undefined {
132
+ if (used === undefined) return undefined;
133
+ return {
134
+ used: Math.round(Math.max(0, Math.min(100, used)) * 10) / 10,
135
+ ...(resetsAt === undefined || resetsAt <= 0 ? {} : { resetsAt: Math.round(resetsAt) }),
136
+ };
137
+ }
138
+
139
+ function numberOf(value: unknown): number | undefined {
140
+ if (value === null || value === undefined) return undefined;
141
+ if (typeof value === "string" && value.trim() === "") return undefined;
142
+ const parsed = typeof value === "number" ? value : Number(value);
143
+ return Number.isFinite(parsed) ? parsed : undefined;
144
+ }
145
+
146
+ function dateOf(value: unknown): number | undefined {
147
+ if (typeof value !== "string") return undefined;
148
+ const parsed = Date.parse(value);
149
+ return Number.isFinite(parsed) ? Math.round(parsed / 1000) : undefined;
150
+ }
@@ -0,0 +1,76 @@
1
+ // Reads a plan once per account and keeps the answer for the process. The record the caller gives
2
+ // decides the reader of each provider, so another provider is another entry.
3
+ import type { AccountUsage, QuotaReader, UsageRequest } from "./types.ts";
4
+
5
+ const RETRY_AFTER_FAILURE_MS = 15 * 60 * 1000;
6
+
7
+ export function quotaKey(providerId: string, accountId: string): string {
8
+ return `${providerId}/${accountId}`;
9
+ }
10
+
11
+ export class Quota {
12
+ private readonly readings = new Map<string, AccountUsage>();
13
+ private readonly inFlight = new Map<string, Promise<AccountUsage | undefined>>();
14
+ private readonly failedAt = new Map<string, number>();
15
+
16
+ constructor(private readonly readers: Record<string, QuotaReader | undefined>) {}
17
+
18
+ /** Whether this provider's plan can be read at all. */
19
+ reads(providerId: string): boolean {
20
+ return this.readers[providerId] !== undefined;
21
+ }
22
+
23
+ read(providerId: string, accountId: string): AccountUsage | undefined {
24
+ return this.readings.get(quotaKey(providerId, accountId));
25
+ }
26
+
27
+ /** The windows a response of this provider carries, when its reader knows them. */
28
+ fromHeaders(providerId: string, headers: Record<string, string>): AccountUsage | undefined {
29
+ return this.readers[providerId]?.headers?.(headers);
30
+ }
31
+
32
+ async ensure(
33
+ providerId: string,
34
+ accountId: string,
35
+ token: string | undefined,
36
+ ): Promise<AccountUsage | undefined> {
37
+ const reader = this.readers[providerId];
38
+ if (reader === undefined || token === undefined) return undefined;
39
+ const key = quotaKey(providerId, accountId);
40
+ const known = this.readings.get(key);
41
+ if (known !== undefined && !rolled(known, Date.now())) return known;
42
+ if (Date.now() - (this.failedAt.get(key) ?? 0) < RETRY_AFTER_FAILURE_MS) return known;
43
+
44
+ const running = this.inFlight.get(key);
45
+ if (running !== undefined) return await running;
46
+ const task = this.fill(key, reader, token);
47
+ this.inFlight.set(key, task);
48
+ try {
49
+ return await task;
50
+ } finally {
51
+ this.inFlight.delete(key);
52
+ }
53
+ }
54
+
55
+ /** The reader first; when it refuses, its fallback still carries the windows. */
56
+ private async fill(
57
+ key: string,
58
+ reader: QuotaReader,
59
+ token: string,
60
+ ): Promise<AccountUsage | undefined> {
61
+ const request: UsageRequest = { token };
62
+ const usage = (await reader.read(request)) ?? (await reader.probe?.(request));
63
+ if (usage === undefined) this.failedAt.set(key, Date.now());
64
+ else this.readings.set(key, usage);
65
+ return usage;
66
+ }
67
+ }
68
+
69
+ /** A window that already rolled over makes the reading stale. */
70
+ function rolled(usage: AccountUsage, now: number): boolean {
71
+ const seconds = Math.floor(now / 1000);
72
+ return (
73
+ (usage.session?.resetsAt !== undefined && usage.session.resetsAt <= seconds) ||
74
+ (usage.week?.resetsAt !== undefined && usage.week.resetsAt <= seconds)
75
+ );
76
+ }