takomi 2.1.25 → 2.1.26

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.
@@ -11,11 +11,16 @@ Pi extension that auto-loads and registers an `oauth-router` provider with multi
11
11
  - supports account commands:
12
12
  - `/router-login add`
13
13
  - `/router-login list`
14
- - `/router-login remove <id>`
15
- - `/router-login refresh <id>`
14
+ - `/router-login remove [id]` / `/router-delete [id]`
15
+ - `/router-login rename [id] [label]` / `/router-rename [id] [label]`
16
+ - `/router-login relogin [id]` / `/router-relogin [id]`
17
+ - `/router-login refresh [id]`
16
18
  - `/router-status`
17
- - `/router-enable <id>`
18
- - `/router-disable <id>`
19
+ - `/router-usage [id]` / `/router-quota [id]`
20
+ - `/router-usage-raw [id]`
21
+ - `/router-refresh-usage [id|all]`
22
+ - `/router-enable [id]`
23
+ - `/router-disable [id]`
19
24
  - `/router-policy <name>`
20
25
  - routes across healthy accounts with:
21
26
  - round robin
@@ -50,8 +55,9 @@ Edit `~/.pi/agent/oauth-router/config.json` to add more upstreams, swap endpoint
50
55
  3. Add an account:
51
56
  - OAuth: `/router-login add chatgpt-codex`
52
57
  - API key fallback: `/router-login add openai-compatible`
53
- 4. Check state:
58
+ 4. Check state and local usage windows:
54
59
  - `/router-status`
60
+ - `/router-usage`
55
61
  5. Select a model:
56
62
  - `oauth-router/gpt-5.4`
57
63
  - `oauth-router/gpt-4o`
@@ -88,15 +94,20 @@ Shape:
88
94
  }
89
95
  ```
90
96
 
91
- Health state lives separately in:
97
+ Health and local usage state live separately in:
92
98
 
93
99
  - `~/.pi/agent/oauth-router/state.json`
94
100
 
101
+ The router records successful request usage per account for rolling local 5-hour and weekly windows. These are router-observed counters only. `/router-refresh-usage [id|all]` now also probes configured authenticated provider endpoints for ChatGPT/Codex quota windows, then falls back to safe token claims such as account id, token expiry, issuer, subject, and available claim keys. `/router-usage` shows a compact visual quota view; `/router-usage-raw [id]` shows detailed/raw provider data. Provider-side quota counters depend on undocumented upstream endpoints and may change; they are not normally present in the OAuth token itself.
102
+
103
+ Most account commands accept an optional account ID. When run in the Pi UI without an ID, the extension opens an account picker instead of dumping the same account list repeatedly.
104
+
95
105
  ## Security notes
96
106
 
97
107
  - credentials are stored separately from health state
98
108
  - files are written with restrictive permissions where the OS allows it
99
109
  - command output redacts secrets
110
+ - token inspection reports metadata/claim keys only, not raw access or refresh tokens
100
111
  - the extension does not log access or refresh tokens
101
112
 
102
113
  ## Known limitations
@@ -105,6 +116,8 @@ Health state lives separately in:
105
116
  - the shipped OAuth adapter reuses Pi's built-in `openai-codex` OAuth implementation; generic OpenAI-compatible upstream OAuth still requires provider-specific adapters
106
117
  - safe failover only happens before meaningful output is emitted; no unsafe mid-stream account switching is attempted
107
118
  - API key fallback is supported, but OAuth remains the primary path for subscription-style upstreams
119
+ - duplicate OAuth identities are detected after login and converted into an existing-account credential update unless explicitly allowed, because the same underlying refresh token lineage can invalidate another router/client session
120
+ - local 5-hour/weekly usage windows are not provider quota truth; they only count requests that went through this router
108
121
 
109
122
  ## Validation
110
123
 
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
2
2
  import { createAccountFromUpstream } from "./oauth-flow.ts";
3
- import type { RouterStatusRow, RoutingPolicyName } from "./types.ts";
3
+ import type { RouterStatusRow, RouterUsageSummary, RouterUsageWindowSummary, RoutingPolicyName, StoredRouterAccount } from "./types.ts";
4
4
  import { RouterRuntime } from "./provider.ts";
5
5
 
6
6
  function formatWhen(timestamp?: number): string {
@@ -42,6 +42,14 @@ function isHealthy(row: RouterStatusRow): boolean {
42
42
  return true;
43
43
  }
44
44
 
45
+ function formatHealth(row: RouterStatusRow): string {
46
+ if (isHealthy(row)) return "healthy";
47
+ if (row.authHealth !== "ok") return `auth=${row.authHealth}`;
48
+ if (row.cooldownUntil && row.cooldownUntil > Date.now()) return `cooldown ${formatRelative(row.cooldownUntil)}`;
49
+ if (row.penaltyUntil && row.penaltyUntil > Date.now()) return `penalty ${formatRelative(row.penaltyUntil)}`;
50
+ return "inactive";
51
+ }
52
+
45
53
  export function formatStatusReport(runtime: RouterRuntime): string {
46
54
  const config = runtime.getConfig();
47
55
  const rows = runtime.getStatusRows();
@@ -83,15 +91,7 @@ export function formatStatusReport(runtime: RouterRuntime): string {
83
91
  .map((row) => {
84
92
  const account = accountsById.get(row.id);
85
93
  const plan = typeof account?.meta?.planType === "string" ? account.meta.planType : "unknown";
86
- const health = isHealthy(row)
87
- ? "healthy"
88
- : row.authHealth !== "ok"
89
- ? `auth=${row.authHealth}`
90
- : row.cooldownUntil && row.cooldownUntil > Date.now()
91
- ? `cooldown ${formatRelative(row.cooldownUntil)}`
92
- : row.penaltyUntil && row.penaltyUntil > Date.now()
93
- ? `penalty ${formatRelative(row.penaltyUntil)}`
94
- : "inactive";
94
+ const health = formatHealth(row);
95
95
 
96
96
  return [
97
97
  `- ${row.id} | ${row.label} | plan=${plan}`,
@@ -118,6 +118,149 @@ export function formatStatusReport(runtime: RouterRuntime): string {
118
118
  ].join("\n");
119
119
  }
120
120
 
121
+ export function formatAccountsReport(runtime: RouterRuntime): string {
122
+ const rows = runtime.getStatusRows();
123
+ const accounts = rows.length
124
+ ? [...rows]
125
+ .sort((a, b) => a.label.localeCompare(b.label))
126
+ .map((row) => {
127
+ const lastUsed = row.lastUsedAt ? formatAgo(row.lastUsedAt) : "never";
128
+ return `- ${row.id} | ${row.label} | enabled=${row.enabled} | weight=${row.weight} | state=${formatHealth(row)} | lastUsed=${lastUsed} | 429s=${row.rateLimitCount} | authFailures=${row.authFailureCount}`;
129
+ })
130
+ : ["- No accounts configured yet. Use /router-login add."];
131
+
132
+ return [
133
+ "# oauth-router accounts",
134
+ "",
135
+ "Compact account list. Use /router-status for full routing details and /router-usage for usage windows.",
136
+ "",
137
+ ...accounts,
138
+ ].join("\n");
139
+ }
140
+
141
+ function formatNumber(value: number): string {
142
+ return Math.round(value).toLocaleString();
143
+ }
144
+
145
+ function formatCost(value: number): string {
146
+ if (!value) return "$0";
147
+ return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
148
+ }
149
+
150
+ function formatUsageWindow(window: RouterUsageWindowSummary): string {
151
+ return `${window.label}: ${window.requests} req | tokens=${formatNumber(window.totalTokens)} input=${formatNumber(window.input)} output=${formatNumber(window.output)} cache=${formatNumber(window.cacheRead + window.cacheWrite)} cost=${formatCost(window.costTotal)}`;
152
+ }
153
+
154
+ function findDuplicateAccount(account: StoredRouterAccount, accounts: StoredRouterAccount[]): StoredRouterAccount | undefined {
155
+ const accountId = typeof account.meta?.accountId === "string" ? account.meta.accountId : undefined;
156
+ if (!accountId) return undefined;
157
+ return accounts.find((candidate) => {
158
+ const candidateAccountId = typeof candidate.meta?.accountId === "string" ? candidate.meta.accountId : undefined;
159
+ return candidate.provider === account.provider && candidate.upstreamId === account.upstreamId && candidateAccountId === accountId;
160
+ });
161
+ }
162
+
163
+ async function shouldReplaceDuplicateAccount(ctx: ExtensionCommandContext, duplicate: StoredRouterAccount): Promise<boolean> {
164
+ const message = `This OAuth identity already exists as ${duplicate.id} (${duplicate.label}). Keeping both entries can make refresh tokens fight each other.`;
165
+ if (!ctx.hasUI) return true;
166
+ return ctx.ui.confirm("Duplicate OAuth account detected", `${message}\n\nUpdate the existing account with these new credentials instead?`);
167
+ }
168
+
169
+ function quotaBar(percent?: number, width = 18): string {
170
+ if (percent === undefined || !Number.isFinite(percent)) return "[??????????????????]";
171
+ const value = Math.max(0, Math.min(100, Math.round(percent)));
172
+ const filled = Math.round((value / 100) * width);
173
+ return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
174
+ }
175
+
176
+ function formatProviderQuota(window: { label: string; used?: number; limit?: number; remaining?: number; percentRemaining?: number; resetAt?: number }): string {
177
+ const pct = window.percentRemaining !== undefined ? Math.round(window.percentRemaining) : undefined;
178
+ const extra = [
179
+ window.remaining !== undefined ? `remaining=${formatNumber(window.remaining)}` : undefined,
180
+ window.used !== undefined ? `used=${formatNumber(window.used)}` : undefined,
181
+ window.limit !== undefined ? `limit=${formatNumber(window.limit)}` : undefined,
182
+ ].filter(Boolean).join(" | ");
183
+ const reset = window.resetAt ? `reset ${formatRelative(window.resetAt)}` : "reset unknown";
184
+ return `${window.label.padEnd(6)} ${quotaBar(pct)} ${pct ?? "?"}% left | ${reset}${extra ? ` | ${extra}` : ""}`;
185
+ }
186
+
187
+ function formatProviderVisual(summary: RouterUsageSummary): string[] {
188
+ const provider = summary.provider;
189
+ if (!provider) return ["Provider: not refreshed yet — run /router-refresh-usage all"];
190
+ const plan = provider.planType ? provider.planType.toUpperCase() : "UNKNOWN";
191
+ const fetched = formatAgo(provider.fetchedAt);
192
+ return [
193
+ `Provider: ${plan} | ${provider.source} | refreshed ${fetched}`,
194
+ provider.fiveHour ? formatProviderQuota(provider.fiveHour) : "5h [n/a] no provider window returned",
195
+ provider.weekly ? formatProviderQuota(provider.weekly) : "weekly [n/a] no provider window returned",
196
+ ];
197
+ }
198
+
199
+ function formatProviderRaw(summary: RouterUsageSummary): string[] {
200
+ const provider = summary.provider;
201
+ if (!provider) return ["provider: not inspected yet; run /router-refresh-usage <id>"];
202
+ const identity = [
203
+ provider.planType ? `plan=${provider.planType}` : undefined,
204
+ provider.accountId ? `account=${provider.accountId}` : undefined,
205
+ provider.email ? `email=${provider.email}` : undefined,
206
+ provider.subject ? `sub=${provider.subject}` : undefined,
207
+ ].filter(Boolean);
208
+ return [
209
+ `provider: ${provider.source} | fetched=${formatLastUsedSummary(provider.fetchedAt)}`,
210
+ identity.length > 0 ? `identity: ${identity.join(" | ")}` : "identity: no readable identity claims",
211
+ provider.fiveHour ? formatProviderQuota(provider.fiveHour) : undefined,
212
+ provider.weekly ? formatProviderQuota(provider.weekly) : undefined,
213
+ provider.endpoint ? `endpoint: ${provider.endpoint} | status=${provider.status ?? "-"}` : undefined,
214
+ provider.rateLimitHeaders && Object.keys(provider.rateLimitHeaders).length > 0 ? `headers: ${Object.entries(provider.rateLimitHeaders).map(([key, value]) => `${key}=${value}`).join(" | ")}` : undefined,
215
+ `tokenExpires=${formatWhen(provider.expires)}`,
216
+ provider.message ? `note: ${provider.message}` : undefined,
217
+ provider.claimKeys?.length ? `claimKeys: ${provider.claimKeys.join(", ")}` : undefined,
218
+ ].filter((line): line is string => Boolean(line));
219
+ }
220
+
221
+ export function formatUsageReport(runtime: RouterRuntime, accountId?: string): string {
222
+ const accounts = runtime.listAccounts();
223
+ const accountMap = new Map(accounts.map((account) => [account.id, account]));
224
+ const summaries = accountId ? [runtime.getUsageSummary(accountId)] : runtime.getUsageSummaries();
225
+
226
+ const rows = summaries.length
227
+ ? summaries.map((summary) => {
228
+ const account = accountMap.get(summary.accountId);
229
+ const title = account ? `${account.label} (${summary.accountId})` : summary.accountId;
230
+ return [
231
+ `## ${title}`,
232
+ ...formatProviderVisual(summary),
233
+ `Local: ${summary.fiveHour.requests} req / ${formatNumber(summary.fiveHour.totalTokens)} tokens in 5h · ${summary.weekly.requests} req / ${formatNumber(summary.weekly.totalTokens)} tokens weekly`,
234
+ `Raw: /router-usage-raw ${summary.accountId}`,
235
+ ].join("\n");
236
+ })
237
+ : ["No accounts configured yet. Use /router-login add."];
238
+
239
+ return [
240
+ "# oauth-router usage",
241
+ "",
242
+ "Provider bars show OpenAI/Codex reported quota remaining. Local line shows only traffic routed through this extension.",
243
+ "",
244
+ ...rows,
245
+ ].join("\n");
246
+ }
247
+
248
+ export function formatUsageRawReport(runtime: RouterRuntime, accountId?: string): string {
249
+ const accounts = runtime.listAccounts();
250
+ const accountMap = new Map(accounts.map((account) => [account.id, account]));
251
+ const summaries = accountId ? [runtime.getUsageSummary(accountId)] : runtime.getUsageSummaries();
252
+ const rows = summaries.map((summary) => {
253
+ const account = accountMap.get(summary.accountId);
254
+ return [
255
+ `## ${account ? `${account.label} (${summary.accountId})` : summary.accountId}`,
256
+ formatUsageWindow(summary.fiveHour),
257
+ formatUsageWindow(summary.weekly),
258
+ ...formatProviderRaw(summary),
259
+ ].filter((line): line is string => Boolean(line)).join("\n");
260
+ });
261
+ return ["# oauth-router usage raw", "", ...rows].join("\n");
262
+ }
263
+
121
264
  function emitReport(pi: ExtensionAPI, text: string) {
122
265
  pi.sendMessage({
123
266
  customType: "oauth-router",
@@ -150,6 +293,51 @@ async function pickUpstream(runtime: RouterRuntime, ctx: ExtensionCommandContext
150
293
  return selected;
151
294
  }
152
295
 
296
+ function formatAccountChoice(account: StoredRouterAccount, row?: RouterStatusRow): string {
297
+ return `${account.id} — ${account.label} — enabled=${row?.enabled ?? account.enabled} — weight=${row?.weight ?? account.weight} — state=${row ? formatHealth(row) : "unknown"}`;
298
+ }
299
+
300
+ async function pickAccount(runtime: RouterRuntime, ctx: ExtensionCommandContext, requestedId?: string, title = "Choose an account"): Promise<StoredRouterAccount> {
301
+ const accounts = runtime.listAccounts();
302
+ if (accounts.length === 0) throw new Error("No accounts configured yet. Use /router-login add.");
303
+
304
+ if (requestedId) {
305
+ const matched = accounts.find((account) => account.id === requestedId);
306
+ if (!matched) throw new Error(`Unknown account: ${requestedId}`);
307
+ return matched;
308
+ }
309
+
310
+ if (!ctx.hasUI) throw new Error("Account id required. Run /router-accounts to see available accounts.");
311
+
312
+ const rows = new Map(runtime.getStatusRows().map((row) => [row.id, row]));
313
+ const choice = await ctx.ui.select(
314
+ title,
315
+ accounts.map((account) => formatAccountChoice(account, rows.get(account.id))),
316
+ );
317
+ if (!choice) throw new Error("Cancelled by user");
318
+ const id = choice.split(" — ")[0]?.trim();
319
+ const selected = accounts.find((account) => account.id === id);
320
+ if (!selected) throw new Error(`Unknown account: ${id}`);
321
+ return selected;
322
+ }
323
+
324
+ async function pickUsageTarget(runtime: RouterRuntime, ctx: ExtensionCommandContext, requestedId?: string): Promise<"all" | string> {
325
+ if (requestedId === "all") return "all";
326
+ if (requestedId) return (await pickAccount(runtime, ctx, requestedId)).id;
327
+ if (!ctx.hasUI) throw new Error("Usage: /router-refresh-usage <id|all>");
328
+
329
+ const accounts = runtime.listAccounts();
330
+ if (accounts.length === 0) throw new Error("No accounts configured yet. Use /router-login add.");
331
+ const rows = new Map(runtime.getStatusRows().map((row) => [row.id, row]));
332
+ const allLabel = "all — refresh all accounts";
333
+ const choice = await ctx.ui.select("Refresh usage metadata for", [allLabel, ...accounts.map((account) => formatAccountChoice(account, rows.get(account.id)))]);
334
+ if (!choice) throw new Error("Cancelled by user");
335
+ if (choice === allLabel) return "all";
336
+ const id = choice.split(" — ")[0]?.trim();
337
+ if (!id) throw new Error("No account selected");
338
+ return id;
339
+ }
340
+
153
341
  async function getLabel(ctx: ExtensionCommandContext, fallback: string, provided?: string) {
154
342
  if (provided && provided.trim()) return provided.trim();
155
343
  if (!ctx.hasUI) return fallback;
@@ -157,6 +345,15 @@ async function getLabel(ctx: ExtensionCommandContext, fallback: string, provided
157
345
  return response?.trim() || fallback;
158
346
  }
159
347
 
348
+ async function getRequiredLabel(ctx: ExtensionCommandContext, fallback: string, provided?: string) {
349
+ if (provided && provided.trim()) return provided.trim();
350
+ if (!ctx.hasUI) throw new Error("Account label required");
351
+ const response = await ctx.ui.input("Account label:", fallback);
352
+ const label = response?.trim();
353
+ if (!label) throw new Error("Account label cannot be empty");
354
+ return label;
355
+ }
356
+
160
357
  function normalizePolicy(input?: string): RoutingPolicyName | undefined {
161
358
  if (!input) return undefined;
162
359
  const value = input.trim().toLowerCase();
@@ -179,15 +376,6 @@ function setFooterStatus(ctx: ExtensionCommandContext, runtime: RouterRuntime) {
179
376
  ctx.ui.setStatus("oauth-router", `oauth-router ${healthy}/${rows.length || 0} healthy | ${runtime.getPolicy()}`);
180
377
  }
181
378
 
182
- function getAccountUsageLines(runtime: RouterRuntime): string[] {
183
- const rows = runtime.getStatusRows();
184
- if (rows.length === 0) {
185
- return ["No accounts configured yet. Use /router-login add."];
186
- }
187
-
188
- return rows.map((row) => `- ${row.id} | ${row.label} | enabled=${row.enabled} | weight=${row.weight}`);
189
- }
190
-
191
379
  function showCommandHint(pi: ExtensionAPI, ctx: ExtensionCommandContext, title: string, lines: string[]) {
192
380
  emitReport(pi, [`# ${title}`, "", ...lines].join("\n"));
193
381
  ctx.ui.notify(title, "info");
@@ -198,35 +386,90 @@ async function handleRouterLogin(pi: ExtensionAPI, runtime: RouterRuntime, args:
198
386
 
199
387
  switch (command) {
200
388
  case "add": {
389
+ const allowDuplicate = rest.includes("--allow-duplicate");
390
+ const labelParts = rest.filter((part) => part !== "--allow-duplicate");
201
391
  const upstream = await pickUpstream(runtime, ctx, first);
202
- const label = await getLabel(ctx, `${upstream.label} ${runtime.listAccounts().length + 1}`, rest.join(" "));
392
+ const label = await getLabel(ctx, `${upstream.label} ${runtime.listAccounts().length + 1}`, labelParts.join(" "));
203
393
  const account = await createAccountFromUpstream(upstream, label, ctx);
394
+ const duplicate = findDuplicateAccount(account, runtime.listAccounts());
395
+ if (duplicate && !allowDuplicate) {
396
+ const replace = await shouldReplaceDuplicateAccount(ctx, duplicate);
397
+ if (!replace) throw new Error("Cancelled duplicate account add");
398
+ runtime.updateAccount({
399
+ ...duplicate,
400
+ access: account.access,
401
+ refresh: account.refresh,
402
+ expires: account.expires,
403
+ meta: account.meta,
404
+ updatedAt: Date.now(),
405
+ });
406
+ runtime.clearAccountHealth(duplicate.id);
407
+ await runtime.refreshUsageSnapshot(duplicate.id);
408
+ setFooterStatus(ctx, runtime);
409
+ emitReport(pi, `Updated existing account ${duplicate.id} (${duplicate.label}) with fresh credentials instead of adding a duplicate.`);
410
+ return;
411
+ }
204
412
  runtime.addAccount(account);
413
+ await runtime.refreshUsageSnapshot(account.id);
205
414
  setFooterStatus(ctx, runtime);
206
415
  emitReport(pi, `Added account ${account.id} (${account.label}) for upstream ${upstream.id}.`);
207
416
  ctx.ui.notify(`Added ${account.id}`, "info");
208
417
  return;
209
418
  }
210
419
  case "list": {
211
- const report = formatStatusReport(runtime);
212
- emitReport(pi, report);
420
+ emitReport(pi, formatAccountsReport(runtime));
213
421
  setFooterStatus(ctx, runtime);
214
422
  return;
215
423
  }
424
+ case "delete":
216
425
  case "remove": {
217
- if (!first) throw new Error("Usage: /router-login remove <id>");
426
+ const account = await pickAccount(runtime, ctx, first, "Choose account to delete");
218
427
  if (ctx.hasUI) {
219
- const ok = await ctx.ui.confirm("Remove account?", `Delete router account ${first}?`);
428
+ const ok = await ctx.ui.confirm("Remove account?", `Delete router account ${account.id} (${account.label})? This removes stored tokens and usage state for that router account.`);
220
429
  if (!ok) throw new Error("Cancelled by user");
221
430
  }
222
- runtime.removeAccount(first);
431
+ runtime.removeAccount(account.id);
223
432
  setFooterStatus(ctx, runtime);
224
- emitReport(pi, `Removed account ${first}.`);
433
+ emitReport(pi, `Removed account ${account.id}.`);
434
+ return;
435
+ }
436
+ case "rename": {
437
+ const account = await pickAccount(runtime, ctx, first, "Choose account to rename");
438
+ const label = await getRequiredLabel(ctx, account.label, rest.join(" "));
439
+ runtime.renameAccount(account.id, label);
440
+ setFooterStatus(ctx, runtime);
441
+ emitReport(pi, `Renamed account ${account.id} to ${label}.`);
442
+ return;
443
+ }
444
+ case "relogin": {
445
+ const existing = await pickAccount(runtime, ctx, first, "Choose account to re-login");
446
+ const upstream = runtime.listUpstreams().find((entry) => entry.id === existing.upstreamId);
447
+ if (!upstream) throw new Error(`Unknown upstream for account ${existing.id}: ${existing.upstreamId}`);
448
+ if (ctx.hasUI) {
449
+ const ok = await ctx.ui.confirm("Re-login account?", `Replace OAuth/API credentials for ${existing.id} (${existing.label}) and clear auth failure state?`);
450
+ if (!ok) throw new Error("Cancelled by user");
451
+ }
452
+ const fresh = await createAccountFromUpstream(upstream, existing.label, ctx);
453
+ runtime.updateAccount({
454
+ ...existing,
455
+ provider: fresh.provider,
456
+ upstreamId: fresh.upstreamId,
457
+ access: fresh.access,
458
+ refresh: fresh.refresh,
459
+ expires: fresh.expires,
460
+ meta: fresh.meta,
461
+ updatedAt: Date.now(),
462
+ });
463
+ runtime.clearAccountHealth(existing.id);
464
+ await runtime.refreshUsageSnapshot(existing.id);
465
+ setFooterStatus(ctx, runtime);
466
+ emitReport(pi, `Re-logged account ${existing.id} (${existing.label}) and cleared auth state.`);
225
467
  return;
226
468
  }
227
469
  case "refresh": {
228
- if (!first) throw new Error("Usage: /router-login refresh <id>");
229
- const refreshed = await runtime.refreshAccount(first);
470
+ const account = await pickAccount(runtime, ctx, first, "Choose account to refresh");
471
+ const refreshed = await runtime.refreshAccount(account.id);
472
+ await runtime.refreshUsageSnapshot(refreshed.id);
230
473
  setFooterStatus(ctx, runtime);
231
474
  emitReport(pi, `Refreshed account ${refreshed.id} (${refreshed.label}).`);
232
475
  return;
@@ -238,12 +481,18 @@ async function handleRouterLogin(pi: ExtensionAPI, runtime: RouterRuntime, args:
238
481
  [
239
482
  "# oauth-router commands",
240
483
  "",
241
- "- /router-login add [upstreamId] [label]",
484
+ "- /router-login add [upstreamId] [label] [--allow-duplicate]",
242
485
  "- /router-login list",
243
486
  "- /router-login remove <id>",
487
+ "- /router-login delete <id>",
488
+ "- /router-login rename <id> <new label>",
489
+ "- /router-login relogin <id>",
244
490
  "- /router-login refresh <id>",
245
491
  "- /router-status",
246
492
  "- /router-accounts",
493
+ "- /router-usage [id]",
494
+ "- /router-usage-raw [id]",
495
+ "- /router-refresh-usage <id|all>",
247
496
  "- /router-enable <id>",
248
497
  "- /router-disable <id>",
249
498
  "- /router-policy <round-robin|weighted-round-robin>",
@@ -270,28 +519,92 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
270
519
  });
271
520
 
272
521
  pi.registerCommand("router-accounts", {
273
- description: "Show oauth-router accounts",
522
+ description: "Show compact oauth-router account list",
274
523
  handler: async (_args, ctx) => {
275
- emitReport(pi, formatStatusReport(runtime));
524
+ emitReport(pi, formatAccountsReport(runtime));
276
525
  setFooterStatus(ctx, runtime);
277
526
  },
278
527
  });
279
528
 
529
+ pi.registerCommand("router-usage", {
530
+ description: "Show oauth-router visual provider quota and local usage",
531
+ handler: async (args, ctx) => {
532
+ const [id] = parseArgs(args || "");
533
+ emitReport(pi, formatUsageReport(runtime, id));
534
+ setFooterStatus(ctx, runtime);
535
+ },
536
+ });
537
+
538
+ pi.registerCommand("router-usage-raw", {
539
+ description: "Show raw oauth-router usage/provider quota details",
540
+ handler: async (args, ctx) => {
541
+ const [id] = parseArgs(args || "");
542
+ emitReport(pi, formatUsageRawReport(runtime, id));
543
+ setFooterStatus(ctx, runtime);
544
+ },
545
+ });
546
+
547
+ pi.registerCommand("router-quota", {
548
+ description: "Alias for visual oauth-router usage/quota",
549
+ handler: async (args, ctx) => {
550
+ const [id] = parseArgs(args || "");
551
+ emitReport(pi, formatUsageReport(runtime, id));
552
+ setFooterStatus(ctx, runtime);
553
+ },
554
+ });
555
+
556
+ pi.registerCommand("router-refresh-usage", {
557
+ description: "Refresh oauth-router account metadata from token claims",
558
+ handler: async (args, ctx) => {
559
+ const [requestedId] = parseArgs(args || "");
560
+ const target = await pickUsageTarget(runtime, ctx, requestedId);
561
+ const ids = target === "all" ? runtime.listAccounts().map((account) => account.id) : [target];
562
+ for (const accountId of ids) await runtime.refreshUsageSnapshot(accountId);
563
+ emitReport(pi, formatUsageReport(runtime, target === "all" ? undefined : target));
564
+ setFooterStatus(ctx, runtime);
565
+ },
566
+ });
567
+
568
+ pi.registerCommand("router-rename", {
569
+ description: "Rename an oauth-router account",
570
+ handler: async (args, ctx) => {
571
+ const [id, ...labelParts] = parseArgs(args || "");
572
+ const account = await pickAccount(runtime, ctx, id, "Choose account to rename");
573
+ const label = await getRequiredLabel(ctx, account.label, labelParts.join(" "));
574
+ runtime.renameAccount(account.id, label);
575
+ setFooterStatus(ctx, runtime);
576
+ emitReport(pi, `Renamed account ${account.id} to ${label}.`);
577
+ },
578
+ });
579
+
580
+ pi.registerCommand("router-delete", {
581
+ description: "Delete an oauth-router account",
582
+ handler: async (args, ctx) => {
583
+ const [id] = parseArgs(args || "");
584
+ const account = await pickAccount(runtime, ctx, id, "Choose account to delete");
585
+ if (ctx.hasUI) {
586
+ const ok = await ctx.ui.confirm("Delete account?", `Delete router account ${account.id} (${account.label})? This removes stored tokens and usage state.`);
587
+ if (!ok) throw new Error("Cancelled by user");
588
+ }
589
+ runtime.removeAccount(account.id);
590
+ setFooterStatus(ctx, runtime);
591
+ emitReport(pi, `Deleted account ${account.id}.`);
592
+ },
593
+ });
594
+
595
+ pi.registerCommand("router-relogin", {
596
+ description: "Re-login and recover an oauth-router account",
597
+ handler: async (args, ctx) => handleRouterLogin(pi, runtime, `relogin ${args || ""}`, ctx),
598
+ });
599
+
280
600
  pi.registerCommand("router-enable", {
281
601
  description: "Enable an oauth-router account",
282
602
  handler: async (args, ctx) => {
283
603
  const [id] = parseArgs(args || "");
284
- if (!id) {
285
- showCommandHint(pi, ctx, "router-enable", ["Usage: /router-enable <id>", "", ...getAccountUsageLines(runtime)]);
286
- return;
287
- }
288
- if (!runtime.getAccount(id)) {
289
- showCommandHint(pi, ctx, "router-enable", [`Unknown account: ${id}`, "", ...getAccountUsageLines(runtime)]);
290
- return;
291
- }
292
- runtime.setEnabled(id, true);
604
+ const account = await pickAccount(runtime, ctx, id, "Choose account to enable");
605
+ runtime.setEnabled(account.id, true);
293
606
  setFooterStatus(ctx, runtime);
294
- emitReport(pi, `Enabled account ${id}.`);
607
+ emitReport(pi, `Enabled account ${account.id}.`);
295
608
  },
296
609
  });
297
610
 
@@ -299,17 +612,10 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
299
612
  description: "Disable an oauth-router account",
300
613
  handler: async (args, ctx) => {
301
614
  const [id] = parseArgs(args || "");
302
- if (!id) {
303
- showCommandHint(pi, ctx, "router-disable", ["Usage: /router-disable <id>", "", ...getAccountUsageLines(runtime)]);
304
- return;
305
- }
306
- if (!runtime.getAccount(id)) {
307
- showCommandHint(pi, ctx, "router-disable", [`Unknown account: ${id}`, "", ...getAccountUsageLines(runtime)]);
308
- return;
309
- }
310
- runtime.setEnabled(id, false);
615
+ const account = await pickAccount(runtime, ctx, id, "Choose account to disable");
616
+ runtime.setEnabled(account.id, false);
311
617
  setFooterStatus(ctx, runtime);
312
- emitReport(pi, `Disabled account ${id}.`);
618
+ emitReport(pi, `Disabled account ${account.id}.`);
313
619
  },
314
620
  });
315
621
 
@@ -346,35 +652,19 @@ export function registerRouterCommands(pi: ExtensionAPI, runtime: RouterRuntime)
346
652
  description: "Set oauth-router account weight for weighted round robin",
347
653
  handler: async (args, ctx) => {
348
654
  const [id, rawWeight] = parseArgs(args || "");
349
- const hasAccounts = runtime.getStatusRows().length > 0;
350
- if (!id && !rawWeight) {
351
- showCommandHint(pi, ctx, "router-weight", [
352
- "Usage: /router-weight <id> <n>",
353
- "",
354
- "Example: /router-weight acct_ab12cd34 3",
355
- "",
356
- ...getAccountUsageLines(runtime),
357
- ]);
358
- return;
359
- }
360
-
361
- const weight = Number(rawWeight);
362
- if (!id || !Number.isFinite(weight)) {
363
- showCommandHint(pi, ctx, "router-weight", [
364
- "Usage: /router-weight <id> <n>",
365
- hasAccounts ? "Provide both an account id and a numeric weight." : "Add an account first with /router-login add.",
366
- "",
367
- ...getAccountUsageLines(runtime),
368
- ]);
369
- return;
655
+ const account = await pickAccount(runtime, ctx, id, "Choose account to reweight");
656
+ let weight = Number(rawWeight);
657
+ if (!Number.isFinite(weight) && ctx.hasUI) {
658
+ const response = await ctx.ui.input("Account weight:", String(account.weight));
659
+ weight = Number(response);
370
660
  }
371
- if (!runtime.getAccount(id)) {
372
- showCommandHint(pi, ctx, "router-weight", [`Unknown account: ${id}`, "", ...getAccountUsageLines(runtime)]);
661
+ if (!Number.isFinite(weight)) {
662
+ showCommandHint(pi, ctx, "router-weight", ["Usage: /router-weight <id> <n>", "Example: /router-weight acct_ab12cd34 3"]);
373
663
  return;
374
664
  }
375
- runtime.setWeight(id, weight);
665
+ runtime.setWeight(account.id, weight);
376
666
  setFooterStatus(ctx, runtime);
377
- emitReport(pi, `Updated weight for ${id} to ${Math.max(1, Math.floor(weight))}.`);
667
+ emitReport(pi, `Updated weight for ${account.id} to ${Math.max(1, Math.floor(weight))}.`);
378
668
  },
379
669
  });
380
670
  }