takomi 2.1.25 → 2.1.27
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/.pi/agents/architect.md +0 -1
- package/.pi/agents/coder.md +0 -1
- package/.pi/agents/designer.md +0 -1
- package/.pi/agents/orchestrator.md +0 -1
- package/.pi/agents/reviewer.md +0 -1
- package/.pi/extensions/oauth-router/README.md +19 -6
- package/.pi/extensions/oauth-router/commands.ts +366 -76
- package/.pi/extensions/oauth-router/config.ts +74 -11
- package/.pi/extensions/oauth-router/oauth-flow.ts +278 -3
- package/.pi/extensions/oauth-router/oauth-store.ts +30 -10
- package/.pi/extensions/oauth-router/provider.ts +69 -3
- package/.pi/extensions/oauth-router/scripts/vibe-verify.py +0 -4
- package/.pi/extensions/oauth-router/state.ts +361 -174
- package/.pi/extensions/oauth-router/types.ts +70 -0
- package/.pi/extensions/takomi-context-manager/index.ts +6 -3
- package/.pi/extensions/takomi-context-manager/model-policy-gate.ts +28 -13
- package/.pi/extensions/takomi-runtime/commands.ts +24 -7
- package/.pi/extensions/takomi-runtime/index.ts +99 -9
- package/.pi/extensions/takomi-runtime/model-routing-defaults.ts +296 -0
- package/.pi/extensions/takomi-runtime/routing-policy.ts +67 -17
- package/.pi/extensions/takomi-subagents/index.ts +5 -4
- package/.pi/extensions/takomi-subagents/native-render.ts +6 -3
- package/.pi/extensions/takomi-subagents/pi-subagents-engine.ts +26 -2
- package/.pi/extensions/takomi-subagents/pi-subagents-internal.ts +13 -2
- package/.pi/extensions/takomi-subagents/tool-runner.ts +182 -90
- package/.pi/settings.json +18 -20
- package/package.json +6 -2
package/.pi/agents/architect.md
CHANGED
package/.pi/agents/coder.md
CHANGED
package/.pi/agents/designer.md
CHANGED
package/.pi/agents/reviewer.md
CHANGED
|
@@ -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
|
|
15
|
-
- `/router-login
|
|
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-
|
|
18
|
-
- `/router-
|
|
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
|
|
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 =
|
|
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}`,
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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(
|
|
431
|
+
runtime.removeAccount(account.id);
|
|
223
432
|
setFooterStatus(ctx, runtime);
|
|
224
|
-
emitReport(pi, `Removed account ${
|
|
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
|
-
|
|
229
|
-
const refreshed = await runtime.refreshAccount(
|
|
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
|
|
522
|
+
description: "Show compact oauth-router account list",
|
|
274
523
|
handler: async (_args, ctx) => {
|
|
275
|
-
emitReport(pi,
|
|
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
|
-
|
|
285
|
-
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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 (!
|
|
372
|
-
showCommandHint(pi, ctx, "router-weight", [
|
|
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
|
}
|