@runuai/host 0.9.170 → 0.9.172

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,247 @@
1
+ /** ADR-164: OpenRouter key budgets, never account credit balance or CLI usage.
2
+ * A credential is read only while a task's current lease serves it. Several
3
+ * tasks sharing one credential/revision share one ten-minute poll. */
4
+ import type { CustomConnectionReason, OpenRouterBudget, OpenRouterBudgetFrame, OpenRouterBudgetResult } from "../src/custom-connections";
5
+ import { SafeHttpRefusal, readSafeBody, safeRequest } from "./safe-http";
6
+
7
+ export const OPENROUTER_BUDGET_URL = "https://openrouter.ai/api/v1/key";
8
+ export const OPENROUTER_BUDGET_POLL_MS = 10 * 60_000;
9
+ export const OPENROUTER_BUDGET_TIMEOUT_MS = 20_000;
10
+ export const OPENROUTER_BUDGET_MAX_BYTES = 64 * 1024;
11
+
12
+ function object(value: unknown): Record<string, unknown> | null {
13
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
14
+ }
15
+ function amount(value: unknown): value is number | null {
16
+ return value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0);
17
+ }
18
+ /** Provider timestamps are ISO instants. An interval such as "monthly" is
19
+ * deliberately not converted into an invented reset date. */
20
+ function timestamp(value: unknown): number | null | undefined {
21
+ if (value === null || value === undefined) return null;
22
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d+)?(?:Z|[+-]\d\d:\d\d)$/.test(value)) return undefined;
23
+ const ms = Date.parse(value);
24
+ return Number.isSafeInteger(ms) && ms > 0 ? ms : undefined;
25
+ }
26
+
27
+ /** Select only the bounded public values from an arbitrary /key response.
28
+ * Required nulls remain meaningful: a missing limit is not "unlimited". */
29
+ export function parseOpenRouterKeyBudget(value: unknown): OpenRouterBudget | null {
30
+ const data = object(object(value)?.data);
31
+ if (!data || !Object.hasOwn(data, "usage") || !Object.hasOwn(data, "limit") || !Object.hasOwn(data, "limit_remaining") || !amount(data.usage) || !amount(data.limit) || !amount(data.limit_remaining)) return null;
32
+ const keyExpiresAt = timestamp(data.expires_at);
33
+ if (keyExpiresAt === undefined) return null;
34
+ let resetsAt: number | null = null;
35
+ // A future API may return a timestamp instead of the documented interval.
36
+ if (typeof data.limit_reset === "string" && data.limit_reset.includes("T")) {
37
+ const reset = timestamp(data.limit_reset);
38
+ if (reset === undefined) return null;
39
+ resetsAt = reset;
40
+ }
41
+ let effectiveBudget: number | null = null;
42
+ if (data.effective_budget !== undefined && data.effective_budget !== null) {
43
+ const budget = object(data.effective_budget);
44
+ if (!budget || !Object.hasOwn(budget, "remaining_usd") || !amount(budget.remaining_usd)) return null;
45
+ effectiveBudget = budget.remaining_usd;
46
+ const reset = timestamp(budget.resets_at);
47
+ if (reset === undefined) return null;
48
+ // This reset belongs to the effective, stricter budget when it binds.
49
+ if (effectiveBudget !== null && (data.limit_remaining === null || effectiveBudget < data.limit_remaining)) resetsAt = reset;
50
+ }
51
+ return { usage: data.usage, limit: data.limit, remaining: data.limit_remaining, effectiveBudget, resetsAt, keyExpiresAt };
52
+ }
53
+
54
+ function responseReason(status: number): CustomConnectionReason {
55
+ if (status === 401) return "unauthorized";
56
+ if (status === 402 || status === 403) return "forbidden";
57
+ if (status === 429) return "rate-limited";
58
+ return status >= 500 ? "upstream-error" : "wrong-protocol";
59
+ }
60
+ function failureReason(error: unknown): CustomConnectionReason {
61
+ if (error instanceof SafeHttpRefusal) {
62
+ if (error.reason === "non_public_address") return "private-address";
63
+ if (error.reason === "timeout") return "timeout";
64
+ if (error.reason === "response_too_large" || error.reason === "url") return "wrong-protocol";
65
+ }
66
+ return "unreachable";
67
+ }
68
+ export interface ReadOpenRouterBudgetOptions {
69
+ signal?: AbortSignal;
70
+ /** Tests inject transport; production always uses the DNS-pinned client. */
71
+ request?: typeof safeRequest;
72
+ now?: () => number;
73
+ }
74
+
75
+ /** Reads neither a caller-provided URL nor any redirect. No provider text,
76
+ * key, arbitrary object or exception escapes this boundary. */
77
+ export async function readOpenRouterBudget(apiKey: string, options: ReadOpenRouterBudgetOptions = {}): Promise<OpenRouterBudgetResult> {
78
+ if (!apiKey || apiKey.length > 8192 || /[\s\u0000-\u001f\u007f]/.test(apiKey)) return { status: "error", reason: "unauthorized" };
79
+ if (options.signal?.aborted) return { status: "error", reason: "timeout" };
80
+ const controller = new AbortController();
81
+ const onAbort = (): void => controller.abort();
82
+ if (options.signal?.aborted) controller.abort();
83
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
84
+ const timeout = setTimeout(() => controller.abort(), OPENROUTER_BUDGET_TIMEOUT_MS);
85
+ timeout.unref();
86
+ try {
87
+ const response = await (options.request ?? safeRequest)({
88
+ url: OPENROUTER_BUDGET_URL,
89
+ method: "GET",
90
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
91
+ signal: controller.signal,
92
+ }, { connectMs: 10_000, headersMs: OPENROUTER_BUDGET_TIMEOUT_MS, idleMs: OPENROUTER_BUDGET_TIMEOUT_MS, maxRequestBodyBytes: 0 });
93
+ if (response.status !== 200) {
94
+ response.body.destroy();
95
+ return { status: "error", reason: responseReason(response.status) };
96
+ }
97
+ const raw = await readSafeBody(response, OPENROUTER_BUDGET_MAX_BYTES);
98
+ const budget = parseOpenRouterKeyBudget(JSON.parse(raw.toString("utf8")) as unknown);
99
+ return budget ? { status: "ok", ...budget } : { status: "error", reason: "wrong-protocol" };
100
+ } catch (error) {
101
+ return { status: "error", reason: controller.signal.aborted ? "timeout" : error instanceof SyntaxError ? "wrong-protocol" : failureReason(error) };
102
+ } finally {
103
+ clearTimeout(timeout);
104
+ options.signal?.removeEventListener("abort", onAbort);
105
+ }
106
+ }
107
+
108
+ export interface OpenRouterBudgetAccount {
109
+ credentialId: string;
110
+ revision: string;
111
+ apiKey: string;
112
+ }
113
+ interface Watcher {
114
+ identity: OpenRouterBudgetAccount;
115
+ tasks: Set<string>;
116
+ controller: AbortController | null;
117
+ timer: ReturnType<typeof setTimeout> | null;
118
+ last: { observedAt: number; result: OpenRouterBudgetResult } | null;
119
+ }
120
+ export interface OpenRouterBudgetPollerOptions {
121
+ emit: (frame: OpenRouterBudgetFrame) => void;
122
+ read?: (apiKey: string, options: ReadOpenRouterBudgetOptions) => Promise<OpenRouterBudgetResult>;
123
+ now?: () => number;
124
+ }
125
+
126
+ function identityKey(account: Pick<OpenRouterBudgetAccount, "credentialId" | "revision">): string {
127
+ return `${account.credentialId}\u0000${account.revision}`;
128
+ }
129
+
130
+ export class OpenRouterBudgetPoller {
131
+ private readonly watchers = new Map<string, Watcher>();
132
+ private readonly tasks = new Map<string, Set<string>>();
133
+ private readonly lastPoll = new Map<string, number>();
134
+ private readonly pending = new Set<Promise<void>>();
135
+ private readonly now: () => number;
136
+ private readonly read: NonNullable<OpenRouterBudgetPollerOptions["read"]>;
137
+
138
+ constructor(private readonly options: OpenRouterBudgetPollerOptions) {
139
+ this.now = options.now ?? Date.now;
140
+ this.read = options.read ?? readOpenRouterBudget;
141
+ }
142
+
143
+ /** The caller passes only OpenRouter accounts from the task's OPEN current
144
+ * lease. Passing [] ends that task's references and aborts unused reads. */
145
+ sync(taskId: string, accounts: readonly OpenRouterBudgetAccount[]): void {
146
+ this.pruneCooldowns();
147
+ const next = new Map(accounts.map(account => [identityKey(account), account]));
148
+ const previous = this.tasks.get(taskId) ?? new Set<string>();
149
+ for (const key of previous) if (!next.has(key)) this.removeTask(key, taskId);
150
+ const keys = new Set<string>();
151
+ for (const [key, identity] of next) {
152
+ keys.add(key);
153
+ let watcher = this.watchers.get(key);
154
+ if (!watcher) {
155
+ watcher = { identity: { ...identity }, tasks: new Set(), controller: null, timer: null, last: null };
156
+ this.watchers.set(key, watcher);
157
+ }
158
+ const added = !watcher.tasks.has(taskId);
159
+ watcher.tasks.add(taskId);
160
+ if (added && watcher.last) this.report(taskId, watcher, watcher.last);
161
+ if (!watcher.timer && !watcher.controller) this.schedule(key, watcher);
162
+ }
163
+ if (keys.size) this.tasks.set(taskId, keys);
164
+ else this.tasks.delete(taskId);
165
+ }
166
+
167
+ /** Removal releases the key, aborts its request when unused, and prevents a
168
+ * late response from publishing under a subsequent run or revision. */
169
+ stop(taskId?: string): void {
170
+ if (taskId !== undefined) {
171
+ for (const key of this.tasks.get(taskId) ?? []) this.removeTask(key, taskId);
172
+ this.tasks.delete(taskId);
173
+ return;
174
+ }
175
+ for (const watcher of this.watchers.values()) {
176
+ if (watcher.timer) clearTimeout(watcher.timer);
177
+ watcher.controller?.abort();
178
+ }
179
+ this.watchers.clear();
180
+ this.tasks.clear();
181
+ }
182
+
183
+ async drain(): Promise<void> {
184
+ await Promise.all([...this.pending]);
185
+ }
186
+
187
+ private removeTask(key: string, taskId: string): void {
188
+ const watcher = this.watchers.get(key);
189
+ if (!watcher) return;
190
+ watcher.tasks.delete(taskId);
191
+ if (watcher.tasks.size) return;
192
+ if (watcher.timer) clearTimeout(watcher.timer);
193
+ watcher.controller?.abort();
194
+ this.watchers.delete(key);
195
+ }
196
+
197
+ private schedule(key: string, watcher: Watcher): void {
198
+ const last = this.lastPoll.get(key);
199
+ const delay = last === undefined ? 0 : Math.max(0, last + OPENROUTER_BUDGET_POLL_MS - this.now());
200
+ if (delay === 0) {
201
+ this.start(key, watcher);
202
+ return;
203
+ }
204
+ watcher.timer = setTimeout(() => {
205
+ watcher.timer = null;
206
+ if (this.watchers.get(key) === watcher) this.start(key, watcher);
207
+ }, delay);
208
+ watcher.timer.unref();
209
+ }
210
+
211
+ private start(key: string, watcher: Watcher): void {
212
+ if (this.watchers.get(key) !== watcher || watcher.controller) return;
213
+ const controller = new AbortController();
214
+ watcher.controller = controller;
215
+ this.lastPoll.set(key, this.now());
216
+ const operation = (async (): Promise<void> => {
217
+ let result: OpenRouterBudgetResult;
218
+ try { result = await this.read(watcher.identity.apiKey, { signal: controller.signal, now: this.now }); }
219
+ catch { result = { status: "error", reason: "unreachable" }; }
220
+ if (this.watchers.get(key) !== watcher || controller.signal.aborted) return;
221
+ const observation = { observedAt: this.now(), result };
222
+ watcher.last = observation;
223
+ for (const taskId of watcher.tasks) this.report(taskId, watcher, observation);
224
+ })();
225
+ this.pending.add(operation);
226
+ void operation.finally(() => {
227
+ this.pending.delete(operation);
228
+ if (this.watchers.get(key) === watcher) {
229
+ watcher.controller = null;
230
+ this.schedule(key, watcher);
231
+ }
232
+ });
233
+ }
234
+
235
+ private report(taskId: string, watcher: Watcher, observation: NonNullable<Watcher["last"]>): void {
236
+ try {
237
+ this.options.emit({ kind: "openrouter.budget", taskId, credentialId: watcher.identity.credentialId, revision: watcher.identity.revision, observedAt: observation.observedAt, ...observation.result });
238
+ } catch {
239
+ // A disconnected transport is not an upstream response and never logs a key.
240
+ }
241
+ }
242
+
243
+ private pruneCooldowns(): void {
244
+ const before = this.now() - OPENROUTER_BUDGET_POLL_MS;
245
+ for (const [key, at] of this.lastPoll) if (at <= before && !this.watchers.has(key)) this.lastPoll.delete(key);
246
+ }
247
+ }