agent-runway 0.2.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.
package/src/models.mjs ADDED
@@ -0,0 +1,303 @@
1
+ // Which models a given install can actually run.
2
+ //
3
+ // The question an agent needs answered before delegating is not "what models
4
+ // exist" but "what slug will this binary accept". Those differ: on the machine
5
+ // this was built against, the Codex CLI on PATH accepts four models while the
6
+ // build inside the VS Code extension accepts five, and the extra one -
7
+ // gpt-6-astra - is the model the user's config.toml selects. Spawning the CLI
8
+ // with it fails.
9
+ //
10
+ // So every answer carries the install it came from and how it was obtained:
11
+ //
12
+ // declared the binary was asked and answered
13
+ // inferred strings were read out of the binary, which is a strong signal
14
+ // and not a contract
15
+ //
16
+ // A caller that gets "inferred" should be ready for a spawn to fail anyway.
17
+
18
+ import fs from "node:fs";
19
+ import { spawn, spawnSync } from "node:child_process";
20
+
21
+ import * as cache from "./cache.mjs";
22
+ import { fingerprint } from "./installs.mjs";
23
+
24
+ const IS_WINDOWS = process.platform === "win32";
25
+
26
+ /** What to actually execute: the launcher when there is one, else the program. */
27
+ const invocable = (install) => install.launcher ?? install.path;
28
+
29
+ // ------------------------------------------------------------------- codex
30
+
31
+ /**
32
+ * Codex documents its own protocol (`codex app-server generate-json-schema`)
33
+ * and answers `model/list` over stdio. The most honest source of the three.
34
+ */
35
+ function codexModels(binary, timeoutMs = 45000) {
36
+ return new Promise((resolve) => {
37
+ let child;
38
+ try {
39
+ child = spawn(binary, ["app-server"], { windowsHide: true });
40
+ } catch (error) {
41
+ return resolve({ error: String(error?.message ?? error) });
42
+ }
43
+
44
+ let buffer = "";
45
+ let settled = false;
46
+ const finish = (value) => {
47
+ if (settled) return;
48
+ settled = true;
49
+ try { child.kill(); } catch { /* already gone */ }
50
+ resolve(value);
51
+ };
52
+ const send = (message) => {
53
+ try { child.stdin.write(JSON.stringify(message) + "\n"); } catch { /* closed */ }
54
+ };
55
+
56
+ child.on("error", (error) => finish({ error: String(error?.message ?? error) }));
57
+ setTimeout(() => finish({ error: `no answer within ${timeoutMs / 1000}s` }), timeoutMs).unref?.();
58
+
59
+ child.stdout.on("data", (chunk) => {
60
+ buffer += chunk.toString();
61
+ let index;
62
+ while ((index = buffer.indexOf("\n")) >= 0) {
63
+ const line = buffer.slice(0, index).trim();
64
+ buffer = buffer.slice(index + 1);
65
+ if (!line) continue;
66
+
67
+ let message;
68
+ try { message = JSON.parse(line); } catch { continue; }
69
+
70
+ if (message.id === 1) {
71
+ // The handshake needs the notification before anything else is served.
72
+ send({ jsonrpc: "2.0", method: "initialized", params: {} });
73
+ send({ id: 2, method: "model/list", params: { includeHidden: false } });
74
+ }
75
+ if (message.id === 2) {
76
+ if (message.error) return finish({ error: JSON.stringify(message.error).slice(0, 200) });
77
+ // The payload is under `data`; reading `models` returned zero from a
78
+ // response that carried five.
79
+ const items = message.result?.data ?? [];
80
+ finish({
81
+ models: items.map((m) => ({
82
+ id: m.id ?? m.model,
83
+ displayName: m.displayName ?? null,
84
+ description: m.description ?? null,
85
+ hidden: Boolean(m.hidden),
86
+ reasoningEfforts: (m.supportedReasoningEfforts ?? [])
87
+ .map((e) => e.reasoningEffort ?? e.effort)
88
+ .filter(Boolean),
89
+ })).filter((m) => m.id),
90
+ });
91
+ }
92
+ }
93
+ });
94
+
95
+ send({
96
+ id: 1,
97
+ method: "initialize",
98
+ params: { clientInfo: { name: "agent-runway", version: "0.1.0", title: "agent-runway" } },
99
+ });
100
+ });
101
+ }
102
+
103
+ // ------------------------------------------------------------------ copilot
104
+
105
+ /**
106
+ * The Copilot CLI has no list command, but its shell completion enumerates the
107
+ * values `--model` accepts, which is the same thing said differently.
108
+ */
109
+ function copilotModels(binary, timeoutMs = 30000) {
110
+ const result = IS_WINDOWS
111
+ ? spawnSync(`"${binary}" completion bash`, { encoding: "utf-8", windowsHide: true, timeout: timeoutMs, shell: true })
112
+ : spawnSync(binary, ["completion", "bash"], { encoding: "utf-8", windowsHide: true, timeout: timeoutMs });
113
+
114
+ if (result.error) return { error: String(result.error.message) };
115
+ if (result.status !== 0) return { error: `completion exited ${result.status}` };
116
+
117
+ const slugs = new Set(
118
+ (result.stdout ?? "").match(/gpt-[0-9][0-9a-z.-]*|claude-[a-z0-9.-]+|gemini-[a-z0-9.-]+|grok-[a-z0-9.-]+/gi) ?? []
119
+ );
120
+ return { models: [...slugs].sort().map((id) => ({ id, displayName: null, hidden: false, reasoningEfforts: [] })) };
121
+ }
122
+
123
+ // ------------------------------------------------------------------- claude
124
+
125
+ // Noise the scan below picks up: documentation filenames and alias spellings
126
+ // that are not slugs anyone can pass to --model.
127
+ const CLAUDE_NOISE = /\.(md|txt|json)$|^\S+\.\d+$/;
128
+
129
+ /**
130
+ * Claude Code exposes no list, so this reads identifiers out of the binary.
131
+ * Strong evidence, not a contract: an absent string very likely means the build
132
+ * does not know that model, but the CLI may still forward an arbitrary slug.
133
+ */
134
+ async function scanBinary(binary, pattern) {
135
+ const found = new Set();
136
+ let tail = "";
137
+ try {
138
+ for await (const chunk of fs.createReadStream(binary, { highWaterMark: 8 << 20 })) {
139
+ const text = tail + chunk.toString("latin1");
140
+ for (const match of text.matchAll(pattern)) found.add(match[0]);
141
+ tail = text.slice(-120);
142
+ }
143
+ } catch (error) {
144
+ return { error: String(error?.message ?? error) };
145
+ }
146
+
147
+ return {
148
+ models: [...found]
149
+ .filter((id) => !CLAUDE_NOISE.test(id))
150
+ .sort()
151
+ .map((id) => ({ id, displayName: null, hidden: false, reasoningEfforts: [] })),
152
+ };
153
+ }
154
+
155
+ const claudeModels = (binary) =>
156
+ scanBinary(binary, /claude-(?:opus|sonnet|haiku|fable)-[0-9][0-9a-z.-]{0,18}/g);
157
+
158
+ // --------------------------------------------------------------------- api
159
+
160
+ /** Same treatment as Claude: no list command, so read the bundle. */
161
+ const geminiModels = (binary) =>
162
+ scanBinary(binary, /gemini-[0-9][0-9a-z.-]{0,18}/g);
163
+
164
+ const READERS = {
165
+ codex: { authority: "declared", read: (install) => codexModels(install.path) },
166
+ copilot: { authority: "declared", read: (install) => copilotModels(invocable(install)) },
167
+ claude: { authority: "inferred", read: (install) => claudeModels(install.path) },
168
+ gemini: { authority: "inferred", read: (install) => geminiModels(install.path) },
169
+ };
170
+
171
+ /** Agents that can be spawned with a model argument. An IDE is not one. */
172
+ export const SPAWNABLE = Object.keys(READERS);
173
+
174
+ /**
175
+ * @returns {Promise<{agent, kind, version, path, authority, models, error, cached}>}
176
+ */
177
+ export async function modelsFor(install, { useCache = true } = {}) {
178
+ const reader = READERS[install.agent];
179
+ const base = {
180
+ agent: install.agent,
181
+ kind: install.kind,
182
+ version: install.version,
183
+ path: install.path,
184
+ authority: reader?.authority ?? null,
185
+ };
186
+
187
+ if (!reader) return { ...base, models: [], error: "no model source for this agent" };
188
+
189
+ // Keyed on the binary, not on a duration: the answer changes when the file
190
+ // does, whether that is a CLI upgrade or a new extension build. Scanning a
191
+ // 337MB binary is seconds of work that never needs repeating otherwise.
192
+ const fp = install.fingerprint ?? fingerprint(install.path);
193
+ const key = fp ? `models-${install.agent}-${fp}` : null;
194
+
195
+ if (useCache && key) {
196
+ const hit = cache.read(key, Infinity, Infinity);
197
+ if (hit) return { ...base, ...hit.value, cached: true };
198
+ }
199
+
200
+ const outcome = await reader.read(install);
201
+ const value = { models: outcome.models ?? [], error: outcome.error ?? null };
202
+ // Errors are transient - an IDE mid-update, a locked file - so only a real
203
+ // answer is worth remembering.
204
+ if (key && !value.error && value.models.length) cache.write(key, value);
205
+
206
+ return { ...base, ...value, cached: false };
207
+ }
208
+
209
+ /** Read several installs at once; one failing never costs the others. */
210
+ export async function modelsForAll(installs, options) {
211
+ return Promise.all(
212
+ installs
213
+ .filter((i) => READERS[i.agent])
214
+ .map((i) => modelsFor(i, options).catch((error) => ({
215
+ agent: i.agent, kind: i.kind, version: i.version, path: i.path,
216
+ models: [], error: String(error?.message ?? error),
217
+ })))
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Everything needed to invoke one agent correctly, in a single answer.
223
+ *
224
+ * Shaped for the caller that already knows how to build a command line and only
225
+ * lacks the two facts that make it work: which binary, and which slug that
226
+ * binary accepts. brainclaw, for instance, carries invoke templates and a
227
+ * model_flag for thirteen agents but resolves the binary with a bare `where`
228
+ * and never checks the slug against it.
229
+ *
230
+ * `contract` is here so that consumers can depend on the shape and be told when
231
+ * it changes, rather than discovering it through a crash.
232
+ */
233
+ export async function resolveAgent(agent, { installs, model = null, capacity = null } = {}) {
234
+ const mine = installs.filter((i) => i.agent === agent);
235
+ const catalogues = await modelsForAll(mine);
236
+ const usable = catalogues.filter((c) => !c.error && c.models.length);
237
+
238
+ // PATH first, because invoking the agent by name is what a spawn does. Then
239
+ // the highest version, as the most complete catalogue.
240
+ const preferred =
241
+ usable.find((c) => c.kind === "path") ??
242
+ [...usable].sort((a, b) => String(b.version ?? "").localeCompare(String(a.version ?? "")))[0] ??
243
+ null;
244
+
245
+ const answer = {
246
+ contract: 1,
247
+ agent,
248
+ resolved: Boolean(preferred),
249
+ binary: preferred?.path ?? null,
250
+ version: preferred?.version ?? null,
251
+ authority: preferred?.authority ?? null,
252
+ models: preferred?.models.map((m) => m.id) ?? [],
253
+ alternatives: catalogues
254
+ .filter((c) => c !== preferred)
255
+ .map((c) => ({ kind: c.kind, version: c.version, path: c.path, models: c.models.map((m) => m.id), error: c.error })),
256
+ capacity,
257
+ };
258
+
259
+ if (model) {
260
+ const valid = answer.models.includes(model);
261
+ answer.model = {
262
+ requested: model,
263
+ valid,
264
+ // The useful half of a rejection: the same slug often works elsewhere.
265
+ availableIn: valid ? [] : whoCanRun(model, catalogues).map((c) => ({ kind: c.kind, version: c.version, path: c.path })),
266
+ // Never invent a substitute silently; offer one and let the caller decide.
267
+ suggestion: valid ? null : answer.models[0] ?? null,
268
+ };
269
+ }
270
+ return answer;
271
+ }
272
+
273
+ /** Which of these installs will accept a given slug. */
274
+ export function whoCanRun(slug, catalogues) {
275
+ return catalogues.filter((c) => c.models.some((m) => m.id === slug));
276
+ }
277
+
278
+ /**
279
+ * Installs that know about a model their sibling does not.
280
+ *
281
+ * This is the failure that motivated the module: a model offered by the build
282
+ * behind an editor, absent from the one a delegation would spawn.
283
+ */
284
+ export function modelSkew(catalogues) {
285
+ const byAgent = new Map();
286
+ for (const c of catalogues) {
287
+ if (!byAgent.has(c.agent)) byAgent.set(c.agent, []);
288
+ byAgent.get(c.agent).push(c);
289
+ }
290
+
291
+ const skews = [];
292
+ for (const [agent, list] of byAgent) {
293
+ const usable = list.filter((c) => !c.error && c.models.length);
294
+ if (usable.length < 2) continue;
295
+
296
+ const union = new Set(usable.flatMap((c) => c.models.map((m) => m.id)));
297
+ for (const c of usable) {
298
+ const missing = [...union].filter((id) => !c.models.some((m) => m.id === id));
299
+ if (missing.length) skews.push({ agent, install: c, missing });
300
+ }
301
+ }
302
+ return skews;
303
+ }
@@ -0,0 +1,132 @@
1
+ // Google Antigravity, through the IDE's local language server.
2
+ //
3
+ // The odd one out, and the reason an adapter must be "produce windows" rather
4
+ // than "call a URL": there is no cloud endpoint. The IDE runs
5
+ // language_server.exe, which serves a Connect RPC on loopback. Both the port
6
+ // and the CSRF token change on every launch, so each read rediscovers them, and
7
+ // nothing works at all while the IDE is closed — which is reported as unknown
8
+ // rather than as empty.
9
+ //
10
+ // The header name is x-codeium-csrf-token; the product descends from Codeium.
11
+
12
+ import { spawnSync } from "node:child_process";
13
+
14
+ import { fromRemainingFraction, makeWindow, ok, unavailable } from "./shared.mjs";
15
+
16
+ export const id = "antigravity";
17
+ export const label = "Antigravity";
18
+
19
+ const IS_WINDOWS = process.platform === "win32";
20
+ const METHOD = "/exa.language_server_pb.LanguageServerService/GetUserStatus";
21
+ const PROC = "language_server";
22
+
23
+ const run = (cmd, args, timeout) =>
24
+ spawnSync(cmd, args, { encoding: "utf-8", timeout, windowsHide: true });
25
+
26
+ /** The CSRF token is an argument of the running server; read it from the process table. */
27
+ function findCsrfToken(timeoutMs) {
28
+ const r = IS_WINDOWS
29
+ ? run("powershell", ["-NoProfile", "-NonInteractive", "-Command",
30
+ `(Get-CimInstance Win32_Process -Filter "Name='${PROC}.exe'").CommandLine`], timeoutMs)
31
+ : run("ps", ["-eo", "args="], timeoutMs);
32
+
33
+ const line = (r.stdout ?? "").split(/\r?\n/).find((l) => l.includes("--csrf_token")) ?? "";
34
+ return line.match(/--csrf_token[= ]+(\S+)/)?.[1] ?? null;
35
+ }
36
+
37
+ /** The server listens on two loopback ports; only one speaks plain HTTP. */
38
+ function findPorts(timeoutMs) {
39
+ if (IS_WINDOWS) {
40
+ const r = run("powershell", ["-NoProfile", "-NonInteractive", "-Command",
41
+ `Get-Process ${PROC} -ErrorAction SilentlyContinue | ForEach-Object { ` +
42
+ `Get-NetTCPConnection -State Listen -OwningProcess $_.Id -ErrorAction SilentlyContinue } | ` +
43
+ `Select-Object -ExpandProperty LocalPort`], timeoutMs);
44
+ return [...new Set((r.stdout ?? "").split(/\r?\n/).map((l) => Number(l.trim())).filter(Boolean))];
45
+ }
46
+ // POSIX: lsof is the portable-enough option. Untested on macOS and Linux.
47
+ const r = run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-c", PROC], timeoutMs);
48
+ return [...new Set(
49
+ [...(r.stdout ?? "").matchAll(/:(\d+)\s+\(LISTEN\)/g)].map((m) => Number(m[1]))
50
+ )];
51
+ }
52
+
53
+ export async function read({ fetchImpl = globalThis.fetch, timeoutMs = 8000 } = {}) {
54
+ const csrf = findCsrfToken(timeoutMs);
55
+ const ports = findPorts(timeoutMs);
56
+
57
+ if (!csrf || !ports.length) {
58
+ // Not "no quota left" — genuinely not knowable right now.
59
+ return unavailable(id, "unreachable", "the Antigravity IDE is not running, so its quota cannot be read");
60
+ }
61
+
62
+ let body = null;
63
+ for (const port of ports) {
64
+ try {
65
+ const response = await fetchImpl(`http://127.0.0.1:${port}${METHOD}`, {
66
+ method: "POST",
67
+ headers: {
68
+ "Content-Type": "application/json",
69
+ "Connect-Protocol-Version": "1",
70
+ "x-codeium-csrf-token": csrf,
71
+ },
72
+ body: "{}",
73
+ signal: AbortSignal.timeout(timeoutMs),
74
+ });
75
+ if (!response.ok) continue; // the sibling port serves HTTPS and 400s here
76
+ body = await response.json();
77
+ break;
78
+ } catch {
79
+ /* try the next port */
80
+ }
81
+ }
82
+
83
+ if (!body) return unavailable(id, "unreachable", "the local language server did not answer");
84
+
85
+ const status = body.userStatus ?? {};
86
+ const plan = status.planStatus ?? {};
87
+ const info = plan.planInfo ?? {};
88
+ const windows = [];
89
+
90
+ // Per-model weekly quotas. Every model config repeats the same reset time, so
91
+ // collapse them rather than emitting one window per model.
92
+ const quotas = (status.cascadeModelConfigData?.clientModelConfigs ?? [])
93
+ .map((c) => c.quotaInfo)
94
+ .filter((q) => q && Number.isFinite(q.remainingFraction));
95
+
96
+ if (quotas.length) {
97
+ const worst = quotas.reduce((a, b) => (a.remainingFraction <= b.remainingFraction ? a : b));
98
+ windows.push(
99
+ makeWindow({
100
+ kind: "model_quota",
101
+ label: "Model quota",
102
+ percentUsed: fromRemainingFraction(worst.remainingFraction),
103
+ resetsAt: worst.resetTime ?? null,
104
+ })
105
+ );
106
+ }
107
+
108
+ // Credits are a separate currency from the window above.
109
+ const credits = [
110
+ ["prompt_credits", "Prompt credits", plan.availablePromptCredits, info.monthlyPromptCredits],
111
+ ["flow_credits", "Flow credits", plan.availableFlowCredits, info.monthlyFlowCredits],
112
+ ];
113
+ for (const [kind, label_, available, monthly] of credits) {
114
+ if (!Number.isFinite(available) || !Number.isFinite(monthly) || monthly <= 0) continue;
115
+ windows.push(
116
+ makeWindow({
117
+ kind,
118
+ label: label_,
119
+ percentUsed: Math.max(0, Math.min(100, Math.round(100 - (available / monthly) * 100))),
120
+ remaining: available,
121
+ entitlement: monthly,
122
+ unit: "credits",
123
+ })
124
+ );
125
+ }
126
+
127
+ return ok(id, {
128
+ plan: info.planName ?? null,
129
+ windows,
130
+ detail: info.canBuyMoreCredits ? "more credits can be purchased" : null,
131
+ });
132
+ }
@@ -0,0 +1,51 @@
1
+ // Claude Code / claude.ai subscription.
2
+ //
3
+ // Verified shape: a `limits` array of {kind, percent, severity, resets_at,
4
+ // scope, is_active}. Window durations are NOT reported, so they are inferred
5
+ // from the kind and marked as derived.
6
+
7
+ import { fetchUsage, UsageError } from "../core.mjs";
8
+ import { fromUsedPercent, makeWindow, ok, unavailable } from "./shared.mjs";
9
+
10
+ export const id = "claude";
11
+ export const label = "Claude";
12
+
13
+ // Not reported by the API. The field names (five_hour, seven_day) and the
14
+ // observed reset cadence support these, but they remain an inference.
15
+ const DERIVED_SECONDS = { session: 5 * 3600, weekly_all: 7 * 86400, weekly_scoped: 7 * 86400 };
16
+ const LABELS = { session: "Session (5h)", weekly_all: "Weekly - all models", weekly_scoped: "Weekly" };
17
+
18
+ export async function read({ env = process.env, fetchImpl = globalThis.fetch } = {}) {
19
+ let usage;
20
+ try {
21
+ usage = await fetchUsage({ env, fetchImpl });
22
+ } catch (error) {
23
+ if (error instanceof UsageError) {
24
+ const status =
25
+ error.code === "NO_TOKEN" ? "no_credentials"
26
+ : error.code === "AUTH" ? "no_credentials"
27
+ : "unreachable";
28
+ return unavailable(id, status, error.message);
29
+ }
30
+ return unavailable(id, "error", String(error?.message ?? error));
31
+ }
32
+
33
+ const windows = usage.windows.map((w) => {
34
+ const seconds = DERIVED_SECONDS[w.id] ?? null;
35
+ return makeWindow({
36
+ kind: w.id,
37
+ label: LABELS[w.id] ?? w.label,
38
+ percentUsed: fromUsedPercent(w.percent),
39
+ resetsAt: w.resetsAt,
40
+ windowSeconds: seconds,
41
+ windowSource: seconds ? "derived" : null,
42
+ severity: w.severity,
43
+ model: w.model,
44
+ });
45
+ });
46
+
47
+ // Extra credits mean a window at 100% is not necessarily a wall.
48
+ const detail = usage.extraUsage?.enabled ? "extra usage credits enabled" : null;
49
+
50
+ return ok(id, { windows, detail });
51
+ }
@@ -0,0 +1,119 @@
1
+ // OpenAI Codex, via the ChatGPT backend.
2
+ //
3
+ // The best-instrumented of the four: it reports the window duration
4
+ // (limit_window_seconds) rather than making us infer it, and exposes `allowed`
5
+ // and `limit_reached` directly, which is a real gate signal instead of a
6
+ // percentage we have to interpret.
7
+
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ import { fromUsedPercent, makeWindow, ok, unavailable } from "./shared.mjs";
13
+
14
+ export const id = "codex";
15
+ export const label = "OpenAI Codex";
16
+
17
+ const ENDPOINT = "https://chatgpt.com/backend-api/codex/usage";
18
+
19
+ const WINDOW_LABELS = {
20
+ primary_window: "Session",
21
+ secondary_window: "Weekly",
22
+ };
23
+
24
+ function readAuth(home) {
25
+ try {
26
+ const auth = JSON.parse(fs.readFileSync(path.join(home, ".codex", "auth.json"), "utf8"));
27
+ const token = auth?.tokens?.access_token;
28
+ const account = auth?.tokens?.account_id;
29
+ return token && account ? { token, account } : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export async function read({
36
+ home = os.homedir(),
37
+ fetchImpl = globalThis.fetch,
38
+ timeoutMs = 10000,
39
+ } = {}) {
40
+ const auth = readAuth(home);
41
+ if (!auth) {
42
+ return unavailable(id, "no_credentials", "~/.codex/auth.json missing or incomplete - run `codex login`");
43
+ }
44
+
45
+ let response;
46
+ try {
47
+ response = await fetchImpl(ENDPOINT, {
48
+ headers: {
49
+ Authorization: `Bearer ${auth.token}`,
50
+ "chatgpt-account-id": auth.account,
51
+ Accept: "application/json",
52
+ // Without a User-Agent, Cloudflare answers with an HTML challenge page
53
+ // instead of JSON. Found the hard way.
54
+ "User-Agent": "agent-runway",
55
+ },
56
+ signal: AbortSignal.timeout(timeoutMs),
57
+ });
58
+ } catch (error) {
59
+ return unavailable(id, "unreachable", `chatgpt.com unreachable: ${error?.message ?? error}`);
60
+ }
61
+
62
+ const text = await response.text();
63
+ let body;
64
+ try {
65
+ body = JSON.parse(text);
66
+ } catch {
67
+ return unavailable(id, "unreachable", `expected JSON, got ${response.status} ${text.slice(0, 60)}`);
68
+ }
69
+
70
+ if (response.status === 401 || response.status === 403) {
71
+ return unavailable(id, "no_credentials", "token rejected - run `codex login`");
72
+ }
73
+ if (!response.ok) {
74
+ return unavailable(id, "error", `HTTP ${response.status}`);
75
+ }
76
+
77
+ const rate = body.rate_limit ?? {};
78
+ const windows = [];
79
+
80
+ for (const key of ["primary_window", "secondary_window"]) {
81
+ const w = rate[key];
82
+ if (!w) continue;
83
+ windows.push(
84
+ makeWindow({
85
+ kind: key === "primary_window" ? "session" : "weekly_all",
86
+ label: WINDOW_LABELS[key],
87
+ percentUsed: fromUsedPercent(w.used_percent),
88
+ resetsAt: Number.isFinite(w.reset_at) ? new Date(w.reset_at * 1000).toISOString() : null,
89
+ windowSeconds: w.limit_window_seconds ?? null,
90
+ // Reported by the provider, not inferred from a field name.
91
+ windowSource: w.limit_window_seconds ? "reported" : null,
92
+ })
93
+ );
94
+ }
95
+
96
+ // Any further caps the account carries. Kept rather than dropped: the field
97
+ // exists precisely because the set is not fixed.
98
+ for (const [key, w] of Object.entries(rate.additional_rate_limits ?? {})) {
99
+ if (!w || !Number.isFinite(w.used_percent)) continue;
100
+ windows.push(
101
+ makeWindow({
102
+ kind: key,
103
+ label: key.replace(/_/g, " "),
104
+ percentUsed: fromUsedPercent(w.used_percent),
105
+ resetsAt: Number.isFinite(w.reset_at) ? new Date(w.reset_at * 1000).toISOString() : null,
106
+ windowSeconds: w.limit_window_seconds ?? null,
107
+ windowSource: w.limit_window_seconds ? "reported" : null,
108
+ })
109
+ );
110
+ }
111
+
112
+ return ok(id, {
113
+ plan: body.plan_type ?? null,
114
+ // The only provider that answers "can I proceed" without us inferring it.
115
+ allowed: typeof rate.allowed === "boolean" ? rate.allowed : null,
116
+ windows,
117
+ detail: rate.limit_reached ? "limit reached" : null,
118
+ });
119
+ }
@@ -0,0 +1,87 @@
1
+ // GitHub Copilot, through the GitHub CLI's stored credentials.
2
+ //
3
+ // Different model from the other three: a monthly allowance of requests rather
4
+ // than rolling time windows, reported as a percentage REMAINING plus raw
5
+ // counts. The trap is `has_quota: false` — on the free tier
6
+ // premium_interactions reads 0 of 0, which a naive percentage call "exhausted"
7
+ // when it actually means "not included in this plan".
8
+
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ import { fromCounts, fromRemainingPercent, makeWindow, ok, unavailable } from "./shared.mjs";
12
+
13
+ export const id = "copilot";
14
+ export const label = "GitHub Copilot";
15
+
16
+ const IS_WINDOWS = process.platform === "win32";
17
+
18
+ const LABELS = {
19
+ chat: "Chat requests",
20
+ completions: "Completions",
21
+ premium_interactions: "Premium requests",
22
+ };
23
+
24
+ /** `gh` holds the token; shelling out avoids ever touching it ourselves. */
25
+ function ghApi(endpoint, timeoutMs) {
26
+ const base = { encoding: "utf-8", timeout: timeoutMs, windowsHide: true };
27
+ const r = IS_WINDOWS
28
+ ? spawnSync(`gh api ${endpoint}`, { ...base, shell: true })
29
+ : spawnSync("gh", ["api", endpoint], base);
30
+
31
+ if (r.error) return { ok: false, reason: r.error.code === "ENOENT" ? "not_installed" : "error" };
32
+ if (r.status !== 0) {
33
+ const err = (r.stderr ?? "").toLowerCase();
34
+ if (err.includes("auth") || err.includes("401")) return { ok: false, reason: "no_credentials" };
35
+ return { ok: false, reason: "unreachable", detail: (r.stderr ?? "").trim().slice(0, 120) };
36
+ }
37
+ try {
38
+ return { ok: true, body: JSON.parse(r.stdout) };
39
+ } catch {
40
+ return { ok: false, reason: "error", detail: "gh returned non-JSON" };
41
+ }
42
+ }
43
+
44
+ export async function read({ timeoutMs = 15000, gh = ghApi } = {}) {
45
+ const result = gh("copilot_internal/user", timeoutMs);
46
+ if (!result.ok) {
47
+ const hint = {
48
+ not_installed: "the GitHub CLI is not installed",
49
+ no_credentials: "not signed in - run `gh auth login`",
50
+ }[result.reason];
51
+ return unavailable(id, result.reason, hint ?? result.detail ?? "gh api failed");
52
+ }
53
+
54
+ const body = result.body ?? {};
55
+ const snapshots = body.quota_snapshots ?? {};
56
+ // Monthly, and reported once for the account rather than per quota.
57
+ const resetsAt = body.quota_reset_date ? `${body.quota_reset_date}T00:00:00Z` : null;
58
+
59
+ const windows = Object.entries(snapshots).map(([key, q]) => {
60
+ const entitled = Boolean(q.has_quota) && !(q.entitlement === 0);
61
+ const percentUsed = q.unlimited
62
+ ? 0
63
+ : Number.isFinite(q.percent_remaining)
64
+ ? fromRemainingPercent(q.percent_remaining)
65
+ : fromCounts(q.quota_remaining, q.entitlement);
66
+
67
+ return makeWindow({
68
+ kind: `monthly_${key}`,
69
+ label: LABELS[key] ?? key.replace(/_/g, " "),
70
+ percentUsed: entitled ? percentUsed : null,
71
+ remaining: Number.isFinite(q.quota_remaining) ? q.quota_remaining : null,
72
+ entitlement: Number.isFinite(q.entitlement) ? q.entitlement : null,
73
+ entitled,
74
+ resetsAt,
75
+ windowSeconds: null, // a calendar month, not a fixed number of seconds
76
+ unit: "requests",
77
+ });
78
+ });
79
+
80
+ const overage = Object.values(snapshots).some((q) => q.overage_permitted);
81
+
82
+ return ok(id, {
83
+ plan: body.copilot_plan ?? null,
84
+ windows,
85
+ detail: overage ? "overage permitted" : null,
86
+ });
87
+ }