@centerforagenticai/pi-multi-account 0.1.1

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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,2000 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ copyFileSync,
5
+ existsSync,
6
+ fsyncSync,
7
+ mkdirSync,
8
+ openSync,
9
+ readFileSync,
10
+ renameSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { basename, dirname, join } from "node:path";
15
+ import { randomUUID } from "node:crypto";
16
+ import type { Api, Model } from "@earendil-works/pi-ai";
17
+ import { acquireMachineLease } from "./machine-lease.js";
18
+ import {
19
+ DECLARATION_BASE_URL,
20
+ DECLARATION_PLACEHOLDER_KEY,
21
+ LOGICAL_PROVIDER_DISPLAY_NAME,
22
+ LOGICAL_PROVIDER_ID,
23
+ assertProjectedManagedModel,
24
+ assertProjectedManagedModels,
25
+ } from "./models-declaration.js";
26
+ import type { EffectiveAccountGroupResolution } from "./group-policy.js";
27
+ import type {
28
+ AllowedFamily,
29
+ ManagedFamily,
30
+ CrossFamilyChain,
31
+ MultiAccountConfig,
32
+ RoutingProjection,
33
+ } from "./config.js";
34
+ import type {
35
+ InstalledDeclarationStatus,
36
+ ModelDeclarationCatalogs,
37
+ ModelDeclarationRow,
38
+ } from "./models-declaration.js";
39
+ import {
40
+ ALLOWED_CROSS_FAMILY_PAIRS,
41
+ ALLOWED_FAMILIES,
42
+ isAllowedFamily,
43
+ isManagedFamily,
44
+ normalizeCrossFamilyChains,
45
+ routingProjection,
46
+ routingProjectionsEqual,
47
+ validateCrossChain,
48
+ } from "./config.js";
49
+ import {
50
+ buildModelDeclarationWithCodexDefaults,
51
+ type CodexModelDefaultChange,
52
+ planCodexModelDefaults,
53
+ resolveCodexLongContextDefaults,
54
+ } from "./codex-model-defaults.js";
55
+ import {
56
+ COMMIT_WARNING_GUIDANCE,
57
+ NON_ROUTING_DRIFT_GUIDANCE,
58
+ type RoutingConfigCommitResult,
59
+ } from "./routing-config-transaction.js";
60
+ import type { ContinuationController } from "./continuation.js";
61
+ import type { CostReport } from "./cost-report.js";
62
+ import { renderCostReport } from "./cost-report-render.js";
63
+ import { type DiagnosticLog, sanitizedJson } from "./diagnostics.js";
64
+ import {
65
+ accountHealth,
66
+ renderStatus,
67
+ type StatusViewInput,
68
+ } from "./status-view.js";
69
+ import type { CredentialType } from "./discovery.js";
70
+ import { providerTypeFor, type ProviderType } from "./vendor.js";
71
+ import type { UnsupportedModelPair } from "./model-support.js";
72
+ import type { UsageFetchStatus } from "./usage-fetch.js";
73
+ import { PERIOD_TYPES, type PeriodType } from "./period-boundaries.js";
74
+ import {
75
+ isCanonicalManagedProviderId,
76
+ type RuntimeState,
77
+ } from "./runtime-state.js";
78
+ import type { UsageLedger } from "./usage.js";
79
+ import { formatUsageSnapshot } from "./usage.js";
80
+ import type { ContinuationWatchdog } from "./watchdog.js";
81
+
82
+ export const MULTI_ACCOUNT_SUBCOMMANDS = Object.freeze([
83
+ "status",
84
+ "limits",
85
+ "models",
86
+ "model",
87
+ "cost",
88
+ "log",
89
+ "rediscover",
90
+ "add",
91
+ "remove",
92
+ "clear",
93
+ "next",
94
+ "switch",
95
+ "stop",
96
+ "reset",
97
+ "reload",
98
+ "configure",
99
+ "enable",
100
+ "disable",
101
+ "group",
102
+ ] as const);
103
+
104
+ export type MultiAccountSubcommand = (typeof MULTI_ACCOUNT_SUBCOMMANDS)[number];
105
+
106
+ /**
107
+ * Every slash subcommand's supported grammar, exactly as shown in an arity
108
+ * error. Exported read-only so a help-surface test can assert on the real
109
+ * production text directly instead of re-deriving it by triggering an error.
110
+ */
111
+ export const COMMAND_USAGE: Readonly<Record<MultiAccountSubcommand, string>> =
112
+ Object.freeze({
113
+ status: "status [account-id|--json]",
114
+ limits: "limits",
115
+ models: "models [account-id|install|update]",
116
+ model: "model [id]",
117
+ cost: "cost [day|week|month|quarter|half-year|year]",
118
+ log: "log [lines]",
119
+ rediscover: "rediscover",
120
+ add: "add <anthropic|openai-codex|google-antigravity|openai> [slot-number]",
121
+ remove: "remove <account-id>",
122
+ clear: "clear <account-id>",
123
+ next: "next",
124
+ switch: "switch <account-id>",
125
+ stop: "stop",
126
+ reset: "reset",
127
+ reload: "reload",
128
+ configure: "configure",
129
+ enable: "enable",
130
+ disable: "disable <anthropic|openai-codex|google-antigravity|openai>",
131
+ group: "group <use <id>|reset|status>",
132
+ });
133
+
134
+ const COMMAND_ARITY: Readonly<
135
+ Record<MultiAccountSubcommand, readonly [minimum: number, maximum: number]>
136
+ > = Object.freeze({
137
+ status: [0, 1],
138
+ limits: [0, 0],
139
+ models: [0, 1],
140
+ model: [0, 1],
141
+ cost: [0, 1],
142
+ log: [0, 1],
143
+ rediscover: [0, 0],
144
+ add: [1, 2],
145
+ remove: [1, 1],
146
+ clear: [1, 1],
147
+ next: [0, 0],
148
+ switch: [1, 1],
149
+ stop: [0, 0],
150
+ reset: [0, 0],
151
+ reload: [0, 0],
152
+ configure: [0, 0],
153
+ enable: [0, 0],
154
+ disable: [1, 1],
155
+ group: [1, 2],
156
+ });
157
+
158
+ function assertCommandArity(
159
+ command: MultiAccountSubcommand,
160
+ argumentCount: number,
161
+ ): void {
162
+ const [minimum, maximum] = COMMAND_ARITY[command];
163
+ if (argumentCount < minimum || argumentCount > maximum) {
164
+ throw new TypeError(`Usage: /multi-account ${COMMAND_USAGE[command]}`);
165
+ }
166
+ }
167
+
168
+ function assertGroupCommand(
169
+ action: string | undefined,
170
+ groupId: string | undefined,
171
+ ): asserts action is "use" | "reset" | "status" {
172
+ const valid =
173
+ (action === "use" && groupId !== undefined) ||
174
+ ((action === "reset" || action === "status") && groupId === undefined);
175
+ if (!valid) {
176
+ throw new TypeError(`Usage: /multi-account ${COMMAND_USAGE.group}`);
177
+ }
178
+ }
179
+
180
+ export interface OperatorAccount {
181
+ readonly providerId: string;
182
+ readonly family: ManagedFamily;
183
+ readonly model: Model<Api>;
184
+ readonly modelIds: readonly string[];
185
+ readonly displayName?: string;
186
+ /**
187
+ * Bounded credential-presence category for this slot, when discovery reported
188
+ * one. Never a credential value. Drives the derived, live `providerType`
189
+ * shown on the operator surface (an Anthropic `api_key` account reads as the
190
+ * owning-vendor-API tier, an Anthropic OAuth account as a subscription). When
191
+ * absent it defaults to `"unknown"`, which derives the subscription type.
192
+ */
193
+ readonly credentialType?: CredentialType;
194
+ /**
195
+ * Derived, non-reversible account identity. Already attached upstream at the
196
+ * credential boundary; carried here only so the status surface can report two
197
+ * slots resolving to one account. Never a credential value.
198
+ */
199
+ readonly accountFingerprint?: string;
200
+ }
201
+
202
+ /**
203
+ * The minimum Pi UI subset this controller needs, declared structurally so the
204
+ * command layer does not depend on the host package. Pi's `ExtensionUIContext`
205
+ * satisfies it.
206
+ */
207
+ export interface CommandDialogOptions {
208
+ readonly signal?: AbortSignal;
209
+ }
210
+
211
+ export interface CommandUI {
212
+ select(
213
+ title: string,
214
+ options: string[],
215
+ opts?: CommandDialogOptions,
216
+ ): Promise<string | undefined>;
217
+ input(
218
+ title: string,
219
+ placeholder?: string,
220
+ opts?: CommandDialogOptions,
221
+ ): Promise<string | undefined>;
222
+ confirm(
223
+ title: string,
224
+ message: string,
225
+ opts?: CommandDialogOptions,
226
+ ): Promise<boolean>;
227
+ }
228
+
229
+ /** Mirrors Pi's `ExtensionMode`. */
230
+ export type CommandMode = "tui" | "rpc" | "json" | "print";
231
+
232
+ export interface CommandSession {
233
+ readonly mode: CommandMode;
234
+ readonly hasUI: boolean;
235
+ readonly ui: CommandUI;
236
+ /** Runs the operator-only logical model selector against the live command context. */
237
+ readonly switchLogicalModel?: (argument: string) => Promise<string>;
238
+ /** Shutdown-linked signal; passed through to every dialog. */
239
+ readonly signal?: AbortSignal;
240
+ }
241
+
242
+ /**
243
+ * The configure command's only route to persisted configuration.
244
+ *
245
+ * The controller owns dialogs and validation; it never reads or writes the
246
+ * config file itself, and it never publishes to the running process.
247
+ */
248
+ export interface RoutingConfigSurface {
249
+ /** Fresh normalized disk read; authoritative for prompts and confirmation. */
250
+ readonly readPersistedRouting: () => RoutingProjection;
251
+ /** This process's live routing policy, for viewing and divergence only. */
252
+ readonly processEffectiveRouting: () => RoutingProjection;
253
+ readonly commit: (input: {
254
+ readonly promptSnapshot: RoutingProjection;
255
+ readonly candidate: RoutingProjection;
256
+ readonly signal?: AbortSignal;
257
+ }) => Promise<RoutingConfigCommitResult>;
258
+ }
259
+
260
+ export interface MeteredFallbackStatus {
261
+ readonly providerId: "openrouter";
262
+ readonly enabled: boolean;
263
+ readonly reason: string;
264
+ readonly active: boolean;
265
+ readonly sessionDisabled: boolean;
266
+ readonly conversationEgressConsented: boolean;
267
+ readonly delegatesAllowed: false;
268
+ readonly configuredModel?: string;
269
+ readonly dailyLimitUsd?: number;
270
+ readonly reservedTodayUsd?: number;
271
+ readonly remainingTodayUsd?: number;
272
+ }
273
+
274
+ export type DeclarationNotice = NonNullable<StatusViewInput["declarationNotice"]>;
275
+
276
+ export function declarationNoticeForStatus(
277
+ status: InstalledDeclarationStatus | undefined,
278
+ ): DeclarationNotice | undefined {
279
+ if (status !== "mismatched" && status !== "unreadable") return undefined;
280
+ return {
281
+ condition: "stale",
282
+ remedy: "/multi-account models update",
283
+ status,
284
+ };
285
+ }
286
+
287
+ export function declarationNoticeMessage(notice: DeclarationNotice): string {
288
+ return notice.status === "mismatched"
289
+ ? `Managed model declaration is stale; logical routing is using the live catalog. Run ${notice.remedy} to re-sync it.`
290
+ : `LOGICAL ROUTING OFF: The managed model declaration is unreadable. Run ${notice.remedy}.`;
291
+ }
292
+
293
+ export interface AccountGroupCommandMemberStatus {
294
+ readonly providerId: string;
295
+ readonly eligible: boolean;
296
+ readonly reason: string;
297
+ }
298
+
299
+ export interface AccountGroupCommandStatus {
300
+ readonly resolution: EffectiveAccountGroupResolution;
301
+ readonly members?: readonly AccountGroupCommandMemberStatus[];
302
+ }
303
+
304
+ export interface AccountGroupCommandSurface {
305
+ readonly use: (groupId: string) => AccountGroupCommandStatus;
306
+ readonly reset: () => AccountGroupCommandStatus;
307
+ readonly status: () => AccountGroupCommandStatus;
308
+ }
309
+
310
+ export interface CommandDependencies {
311
+ readonly state: RuntimeState;
312
+ readonly usage: UsageLedger;
313
+ readonly diagnostics: DiagnosticLog;
314
+ readonly continuation: Pick<ContinuationController, "cancelAll">;
315
+ readonly watchdog: Pick<ContinuationWatchdog, "cancelAll">;
316
+ /** Session-owned cancellation of watchdog, continuation, and logical attribution. */
317
+ readonly cancelPendingActivity: () => void;
318
+ readonly accounts: () => readonly OperatorAccount[];
319
+ /** Captured once at session start; status must never recompute declaration state. */
320
+ readonly logicalRoutingState?: () =>
321
+ | { readonly status: InstalledDeclarationStatus }
322
+ | undefined;
323
+ /** Shared process-local policy state used by automatic routing and commands. */
324
+ readonly disabledProviders?: Set<string>;
325
+ /** Full routing eligibility, including credentials and retained usage. */
326
+ readonly isAccountEligible?: (providerId: string, nowMs: number) => boolean;
327
+ readonly currentProviderId: () => string | undefined;
328
+ /** Physical account that served the latest logical-provider turn, when selected. */
329
+ readonly activeAccountProviderId?: () => string | undefined;
330
+ /** Model id actually in use, for display on the active account. */
331
+ readonly activeModelId?: () => string | undefined;
332
+ /** Operator-facing label for a managed account, when one resolves. */
333
+ readonly accountLabel?: (providerId: string) => string | undefined;
334
+ /** Bounded credential expiry for a managed account, when known. */
335
+ readonly credentialExpiry?: (providerId: string) => number | undefined;
336
+ /** Session-local provider/model divergence observations for operator status. */
337
+ readonly unsupportedModels?: () => readonly UnsupportedModelPair[];
338
+ /** Detached authoritative usage fetch state for operator status. */
339
+ readonly usageFetchStatus?: (
340
+ providerId: string,
341
+ ) => UsageFetchStatus | undefined;
342
+ /** Read-only project/account/model cost intelligence for one calendar series. */
343
+ readonly costReport?: (periodType: PeriodType) => Promise<CostReport>;
344
+ /** Explicit metered last-resort policy; never includes credential material. */
345
+ readonly meteredFallbackStatus?: () => MeteredFallbackStatus;
346
+ readonly setModel: (model: Model<Api>) => Promise<boolean>;
347
+ readonly rediscover: () => Promise<void>;
348
+ readonly addSlot: (
349
+ family: ManagedFamily,
350
+ slotNumber?: number,
351
+ ) => Promise<string>;
352
+ readonly publicRemove?: (providerId: string) => Promise<boolean>;
353
+ readonly reloadGlobalConfig: () => Promise<MultiAccountConfig>;
354
+ readonly onConfigReload?: (
355
+ config: MultiAccountConfig,
356
+ ) => void | Promise<void>;
357
+ /** Absent in builds or harnesses with no persisted configuration surface. */
358
+ readonly routingConfig?: RoutingConfigSurface;
359
+ readonly now?: () => number;
360
+ /**
361
+ * Live inputs for the managed declaration transaction.
362
+ *
363
+ * Absent in harnesses with no models file. When absent, `models install`
364
+ * and `models update` refuse rather than guessing a target path, because a
365
+ * wrong guess would write a provider declaration into someone else's file.
366
+ */
367
+ readonly modelsDeclaration?: {
368
+ readonly targetPath: string;
369
+ readonly readCatalogs: () => ModelsCatalogs | Promise<ModelsCatalogs>;
370
+ };
371
+ /** Operator-only session group mutation and status surface. */
372
+ readonly accountGroups?: AccountGroupCommandSurface;
373
+ }
374
+
375
+ export const CONFIGURE_VIEW_OPTION = "View cross-family routing policy";
376
+
377
+ export function crossFamilyDirectionOption(
378
+ from: AllowedFamily,
379
+ to: AllowedFamily,
380
+ ): string {
381
+ return `${from} \u2192 ${to}`;
382
+ }
383
+
384
+ const CONFIGURE_TUI_ONLY =
385
+ "/multi-account configure needs an interactive Pi TUI session with dialogs. " +
386
+ "Edit crossFamilyChains and preferredModels in the machine-global config, then run /multi-account reload.";
387
+
388
+ const CONFIGURE_IN_PROGRESS =
389
+ "Configuration is already in progress. Finish or cancel the open configure dialog before starting another.";
390
+
391
+ const CONFIGURE_CANCELLED =
392
+ "Configure cancelled; no configuration or routing state changed.";
393
+
394
+ const ROUTING_DIVERGENCE_NOTE =
395
+ "This process is following a different routing policy than the persisted configuration; run /multi-account reload or restart Pi before it follows persisted policy.";
396
+
397
+ const MAX_MODEL_INPUT_LENGTH = 1_024;
398
+ const MAX_MODEL_ENTRIES = 32;
399
+ // Control characters can only reach here from a paste; a model id never has one.
400
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
401
+
402
+ function rediscoverGuidance(family: string): string {
403
+ return (
404
+ `Cross-family routing needs a represented ${family} account in this process. ` +
405
+ "Run /multi-account rediscover after adding/authenticating that family, then rerun configure."
406
+ );
407
+ }
408
+
409
+ function coverageRejection(providerIds: readonly string[]): string {
410
+ return (
411
+ `${providerIds.join(", ")} would have no preferred model in that list, ` +
412
+ "so an authorized account would fall through to an unchosen catalog head. Nothing was changed."
413
+ );
414
+ }
415
+
416
+ type ModelListResult =
417
+ | { readonly status: "ok"; readonly models: readonly string[] }
418
+ | { readonly status: "cancelled" }
419
+ | { readonly status: "rejected"; readonly reason: string };
420
+
421
+ /**
422
+ * Parses one bounded comma-separated best-first model list.
423
+ *
424
+ * `undefined` is Esc, which is cancellation rather than rejection. Everything
425
+ * else is validated against the live destination catalog BEFORE any mutation,
426
+ * because a typo'd id would otherwise be persisted as policy and then silently
427
+ * fall through to a catalog head.
428
+ */
429
+ function parseModelList(
430
+ raw: string | undefined,
431
+ available: readonly string[],
432
+ ): ModelListResult {
433
+ if (raw === undefined) return { status: "cancelled" };
434
+ if (raw.length > MAX_MODEL_INPUT_LENGTH) {
435
+ return {
436
+ status: "rejected",
437
+ reason: "the model list must be at most 1,024 characters.",
438
+ };
439
+ }
440
+ const entries = raw.split(",").map((entry) => entry.trim());
441
+ if (entries.length > MAX_MODEL_ENTRIES) {
442
+ return {
443
+ status: "rejected",
444
+ reason: "enter at most 32 comma-separated model IDs.",
445
+ };
446
+ }
447
+ const models: string[] = [];
448
+ for (const entry of entries) {
449
+ if (entry.length === 0) {
450
+ return {
451
+ status: "rejected",
452
+ reason: "enter at least one destination model ID.",
453
+ };
454
+ }
455
+ if (/\s/.test(entry)) {
456
+ return {
457
+ status: "rejected",
458
+ reason: "model IDs must not contain whitespace.",
459
+ };
460
+ }
461
+ if (CONTROL_CHARACTER.test(entry)) {
462
+ return {
463
+ status: "rejected",
464
+ reason: "model IDs must not contain a control character.",
465
+ };
466
+ }
467
+ if (models.includes(entry)) {
468
+ return {
469
+ status: "rejected",
470
+ reason: `remove the duplicate model ID ${entry}.`,
471
+ };
472
+ }
473
+ if (!available.includes(entry)) {
474
+ return {
475
+ status: "rejected",
476
+ reason: `${entry} is not offered by any live managed account in that family.`,
477
+ };
478
+ }
479
+ models.push(entry);
480
+ }
481
+ if (models.length === 0) {
482
+ return {
483
+ status: "rejected",
484
+ reason: "enter at least one destination model ID.",
485
+ };
486
+ }
487
+ return { status: "ok", models };
488
+ }
489
+
490
+ function liveModelIds(
491
+ accounts: readonly OperatorAccount[],
492
+ family: AllowedFamily,
493
+ ): readonly string[] {
494
+ const seen = new Set<string>();
495
+ const ids: string[] = [];
496
+ for (const account of accounts) {
497
+ if (account.family !== family) continue;
498
+ for (const modelId of account.modelIds) {
499
+ if (seen.has(modelId)) continue;
500
+ seen.add(modelId);
501
+ ids.push(modelId);
502
+ }
503
+ }
504
+ return ids;
505
+ }
506
+
507
+ /**
508
+ * Per-ACCOUNT coverage, not union membership.
509
+ *
510
+ * A list that names one live model of the family still leaves any account that
511
+ * cannot serve it falling through to whatever its catalog happens to list
512
+ * first, which is exactly the defect `preferredModels` exists to prevent.
513
+ */
514
+ function accountsMissingPreference(
515
+ accounts: readonly OperatorAccount[],
516
+ family: AllowedFamily,
517
+ models: readonly string[],
518
+ ): readonly string[] {
519
+ return accounts
520
+ .filter(
521
+ (account) =>
522
+ account.family === family &&
523
+ !account.modelIds.some((modelId) => models.includes(modelId)),
524
+ )
525
+ .map((account) => account.providerId);
526
+ }
527
+
528
+ function describeTierModelMap(projection: RoutingProjection): string {
529
+ const summary = Object.entries(projection.tierModelMap)
530
+ .map(([destination, mappings]) => {
531
+ const count = Object.keys(mappings).length;
532
+ return `${destination}: ${count} ${count === 1 ? "entry" : "entries"}`;
533
+ })
534
+ .join("; ");
535
+ return summary.length === 0 ? "none" : summary;
536
+ }
537
+
538
+ function describeRoutingPolicy(projection: RoutingProjection): string {
539
+ const edges =
540
+ projection.crossFamilyChains.length === 0
541
+ ? "no directional edges"
542
+ : projection.crossFamilyChains
543
+ .map((edge) => crossFamilyDirectionOption(edge.from, edge.to))
544
+ .join("; ");
545
+ const preferred = Object.entries(projection.preferredModels)
546
+ .map(([family, models]) => `${family}: ${models.join(", ")}`)
547
+ .join("; ");
548
+ return (
549
+ `${projection.crossFamilyChainEnabled ? "enabled" : "disabled"}; ` +
550
+ `${edges}; preferred models: ${preferred.length === 0 ? "none" : preferred}; ` +
551
+ `tier model map: ${describeTierModelMap(projection)}`
552
+ );
553
+ }
554
+
555
+ function renderRoutingView(
556
+ persisted: RoutingProjection,
557
+ effective: RoutingProjection,
558
+ ): string {
559
+ const lines = [
560
+ `Persisted cross-family routing: ${describeRoutingPolicy(persisted)}`,
561
+ `Process-effective cross-family routing: ${describeRoutingPolicy(effective)}`,
562
+ ];
563
+ if (!routingProjectionsEqual(persisted, effective)) {
564
+ lines.push(ROUTING_DIVERGENCE_NOTE);
565
+ }
566
+ return lines.join("\n");
567
+ }
568
+
569
+ function renderCommitResult(
570
+ result: RoutingConfigCommitResult,
571
+ candidate: RoutingProjection,
572
+ destinationFamily: AllowedFamily,
573
+ ): string {
574
+ switch (result.status) {
575
+ case "applied":
576
+ case "applied-warning": {
577
+ const edges = candidate.crossFamilyChains
578
+ .map((edge) => crossFamilyDirectionOption(edge.from, edge.to))
579
+ .join("; ");
580
+ const models = (candidate.preferredModels[destinationFamily] ?? []).join(
581
+ ", ",
582
+ );
583
+ const lines = [
584
+ `Cross-family routing is enabled with ${edges}; preferred ${destinationFamily} models: ${models}.`,
585
+ ];
586
+ if (result.status === "applied-warning") {
587
+ lines.push(COMMIT_WARNING_GUIDANCE);
588
+ }
589
+ if (result.nonRoutingDrift) lines.push(NON_ROUTING_DRIFT_GUIDANCE);
590
+ return lines.join("\n");
591
+ }
592
+ case "unchanged":
593
+ return "Cross-family routing is already configured exactly that way, on disk and in this process; nothing was written.";
594
+ case "busy":
595
+ return "Another process is committing multi-account configuration. Nothing was changed; rerun /multi-account configure in a moment.";
596
+ case "changed":
597
+ return "The persisted configuration changed while this dialog was open, so nothing was written. Rerun /multi-account configure from the current policy.";
598
+ case "invalid":
599
+ return "The machine-global configuration is malformed, so nothing was written. Repair the file, then rerun /multi-account configure.";
600
+ }
601
+ }
602
+
603
+ const MANUAL_REMOVE_INSTRUCTIONS =
604
+ "Credentials remain in AuthStorage. Run Pi's /logout command and select this provider; this command does not edit auth.json directly.";
605
+
606
+ function parseLineCount(value: string | undefined): number {
607
+ if (value === undefined) return 20;
608
+ if (!/^\d+$/.test(value))
609
+ throw new TypeError("log lines must be an integer from 1 through 100.");
610
+ const count = Number(value);
611
+ if (!Number.isSafeInteger(count) || count < 1 || count > 100) {
612
+ throw new RangeError("log lines must be an integer from 1 through 100.");
613
+ }
614
+ return count;
615
+ }
616
+
617
+ function parseSlotNumber(value: string | undefined): number | undefined {
618
+ if (value === undefined) return undefined;
619
+ if (!/^\d+$/.test(value))
620
+ throw new TypeError("slot number must be a positive safe integer.");
621
+ const slot = Number(value);
622
+ if (!Number.isSafeInteger(slot) || slot < 2) {
623
+ throw new RangeError("slot number must be a safe integer of at least 2.");
624
+ }
625
+ return slot;
626
+ }
627
+
628
+ export class MultiAccountCommandController {
629
+ readonly #dependencies: CommandDependencies;
630
+ readonly #disabledProviders: Set<string>;
631
+ /** One configure flow per controller: dialogs are modal to the operator. */
632
+ #configureInProgress = false;
633
+
634
+ constructor(dependencies: CommandDependencies) {
635
+ this.#dependencies = dependencies;
636
+ this.#disabledProviders =
637
+ dependencies.disabledProviders ?? new Set<string>();
638
+ }
639
+
640
+ async execute(
641
+ rawArguments: string,
642
+ session?: CommandSession,
643
+ ): Promise<string> {
644
+ try {
645
+ if (rawArguments.length > 1_024)
646
+ throw new RangeError("command arguments are too long.");
647
+ const parts = rawArguments.trim()
648
+ ? rawArguments.trim().split(/\s+/)
649
+ : ["status"];
650
+ if (parts.length > 3) throw new TypeError("too many command arguments.");
651
+ const [rawCommand, first, second] = parts;
652
+ if (
653
+ !rawCommand ||
654
+ !MULTI_ACCOUNT_SUBCOMMANDS.includes(
655
+ rawCommand as MultiAccountSubcommand,
656
+ )
657
+ ) {
658
+ return this.#output(
659
+ `Unknown subcommand. Use: ${MULTI_ACCOUNT_SUBCOMMANDS.join(", ")}.`,
660
+ );
661
+ }
662
+ const command = rawCommand as MultiAccountSubcommand;
663
+ assertCommandArity(command, parts.length - 1);
664
+ if (command === "group") assertGroupCommand(first, second);
665
+ let result: string;
666
+ switch (command) {
667
+ case "status":
668
+ result = this.#status(first);
669
+ break;
670
+ case "limits":
671
+ result = this.#limits();
672
+ break;
673
+ case "models":
674
+ // `models` shares its first word with two different commands: the
675
+ // per-account lister, and the declaration transaction. Only the two
676
+ // exact action words route to the transaction; every other argument
677
+ // keeps the pre-existing listing behaviour unchanged.
678
+ result =
679
+ first === "install" || first === "update"
680
+ ? await this.#modelsTransaction(first, session)
681
+ : this.#models(first);
682
+ break;
683
+ case "model":
684
+ if (session?.switchLogicalModel === undefined) {
685
+ throw new Error("Logical model selection requires a live command session.");
686
+ }
687
+ result = await session.switchLogicalModel(first ?? "");
688
+ break;
689
+ case "cost":
690
+ result = await this.#cost(first);
691
+ break;
692
+ case "log":
693
+ result = this.#dependencies.diagnostics.formatRecent(
694
+ parseLineCount(first),
695
+ );
696
+ break;
697
+ case "rediscover":
698
+ result = await this.#rediscover();
699
+ break;
700
+ case "add":
701
+ result = await this.#add(first, second);
702
+ break;
703
+ case "remove":
704
+ result = await this.#remove(first);
705
+ break;
706
+ case "clear":
707
+ result = this.#clear(first);
708
+ break;
709
+ case "next":
710
+ result = this.#next();
711
+ break;
712
+ case "switch":
713
+ result = await this.#switch(first);
714
+ break;
715
+ case "stop":
716
+ result = this.#stop();
717
+ break;
718
+ case "reset":
719
+ result = this.#reset();
720
+ break;
721
+ case "reload":
722
+ result = await this.#reload();
723
+ break;
724
+ case "configure":
725
+ result = await this.#configure(session);
726
+ break;
727
+ case "enable":
728
+ result = this.#enable();
729
+ break;
730
+ case "disable":
731
+ result = this.#disable(first);
732
+ break;
733
+ case "group":
734
+ result = this.#group(first, second);
735
+ break;
736
+ default:
737
+ throw new TypeError(`Unsupported subcommand: ${String(command)}`);
738
+ }
739
+ return this.#output(result);
740
+ } catch (error) {
741
+ this.#dependencies.diagnostics.recordError(
742
+ "command.multi-account",
743
+ error,
744
+ );
745
+ const detail = error instanceof Error ? error.message : error;
746
+ return this.#output(
747
+ `Command failed: ${String(detail)} Review /multi-account status and retry.`,
748
+ );
749
+ }
750
+ }
751
+
752
+ #accounts(): readonly OperatorAccount[] {
753
+ return this.#dependencies
754
+ .accounts()
755
+ .filter(
756
+ (account) =>
757
+ isCanonicalManagedProviderId(account.providerId, account.family) &&
758
+ account.model.provider === account.providerId,
759
+ );
760
+ }
761
+
762
+ #account(providerId: string | undefined): OperatorAccount {
763
+ if (!providerId) throw new TypeError("an account ID is required.");
764
+ const account = this.#accounts().find(
765
+ (candidate) => candidate.providerId === providerId,
766
+ );
767
+ if (!account)
768
+ throw new TypeError(
769
+ `Unknown managed account ${providerId}. Run rediscover.`,
770
+ );
771
+ return account;
772
+ }
773
+
774
+ /**
775
+ * Renders account status. Human-readable by default; `status --json` keeps the
776
+ * original machine-readable shape for scripted consumers.
777
+ */
778
+ #status(argument: string | undefined): string {
779
+ const now = this.#now();
780
+ const unsupportedModels = this.#dependencies.unsupportedModels?.() ?? [];
781
+ const asJson = argument === "--json";
782
+ const declarationNotice = declarationNoticeForStatus(
783
+ this.#dependencies.logicalRoutingState?.()?.status,
784
+ );
785
+ const declarationNoticeJson =
786
+ declarationNotice === undefined
787
+ ? undefined
788
+ : {
789
+ condition: declarationNotice.condition,
790
+ remedy: declarationNotice.remedy,
791
+ status: declarationNotice.status,
792
+ };
793
+ const providerId = asJson ? undefined : argument;
794
+ const allAccounts = this.#accounts();
795
+ const accounts = providerId ? [this.#account(providerId)] : allAccounts;
796
+ if (accounts.length === 0) {
797
+ const message =
798
+ "No managed accounts are available. Run /multi-account rediscover.";
799
+ if (asJson) {
800
+ return sanitizedJson({
801
+ message,
802
+ ...(declarationNoticeJson === undefined
803
+ ? {}
804
+ : { declarationNotice: declarationNoticeJson }),
805
+ });
806
+ }
807
+ return renderStatus({
808
+ nowMs: now,
809
+ accounts: [],
810
+ ...(declarationNotice === undefined ? {} : { declarationNotice }),
811
+ });
812
+ }
813
+ const currentProviderId = this.#dependencies.currentProviderId();
814
+ const activeAccountProviderId =
815
+ this.#dependencies.activeAccountProviderId?.() ?? currentProviderId;
816
+ // Label and expiry are resolved HERE, not per-branch, so the JSON and
817
+ // human-readable views cannot drift: a scripted consumer sees the same
818
+ // credential freshness the operator does.
819
+ const projected = accounts.map((account) => {
820
+ const disabled = this.#disabledProviders.has(account.providerId);
821
+ const coolingUntilMs = this.#dependencies.state.getCooldown(
822
+ account.providerId,
823
+ now,
824
+ )?.untilMs;
825
+ const unavailable =
826
+ this.#dependencies.state.getInvalidation(account.providerId) !==
827
+ undefined;
828
+ const usageUntrusted =
829
+ this.#dependencies.state.isUsageSnapshotUntrusted(
830
+ account.providerId,
831
+ now,
832
+ );
833
+ const usage = this.#dependencies.usage.get(account.providerId);
834
+ const view = {
835
+ providerId: account.providerId,
836
+ family: account.family,
837
+ // Derived live from the discovered credential type; additive field,
838
+ // no existing field changes. An absent credentialType defaults to
839
+ // the subscription type.
840
+ providerType: providerTypeFor(
841
+ account.family,
842
+ account.credentialType ?? "unknown",
843
+ ),
844
+ active: account.providerId === activeAccountProviderId,
845
+ disabled,
846
+ unavailable,
847
+ ...(coolingUntilMs === undefined ? {} : { coolingUntilMs }),
848
+ ...(usageUntrusted ? { usageUntrusted: true } : {}),
849
+ ...(usage === undefined ? {} : { usage }),
850
+ };
851
+ const health = accountHealth(view, now);
852
+ return {
853
+ ...view,
854
+ api: account.model.api,
855
+ healthy: health === "ready" || health === "low-headroom",
856
+ ...(account.accountFingerprint === undefined
857
+ ? {}
858
+ : { accountFingerprint: account.accountFingerprint }),
859
+ usageFetch: this.#dependencies.usageFetchStatus?.(account.providerId),
860
+ allModelsUnsupported:
861
+ account.modelIds.length > 0 &&
862
+ account.modelIds.every((modelId) =>
863
+ unsupportedModels.some(
864
+ (pair) =>
865
+ pair.providerId === account.providerId &&
866
+ pair.modelId === modelId,
867
+ ),
868
+ ),
869
+ ...this.#optionalField(
870
+ "label",
871
+ this.#dependencies.accountLabel?.(account.providerId),
872
+ ),
873
+ ...this.#optionalField(
874
+ "expiresAtMs",
875
+ this.#dependencies.credentialExpiry?.(account.providerId),
876
+ ),
877
+ };
878
+ });
879
+ const meteredFallback = this.#dependencies.meteredFallbackStatus?.();
880
+ if (asJson) {
881
+ return sanitizedJson({
882
+ currentProviderId,
883
+ healthyAccountCount: projected.filter((account) => account.healthy)
884
+ .length,
885
+ accounts: projected,
886
+ unsupportedModels: this.#dependencies.unsupportedModels?.(),
887
+ ...(declarationNoticeJson === undefined
888
+ ? {}
889
+ : { declarationNotice: declarationNoticeJson }),
890
+ ...(meteredFallback === undefined ? {} : { meteredFallback }),
891
+ });
892
+ }
893
+ const rendered = renderStatus({
894
+ nowMs: now,
895
+ scoped: providerId !== undefined,
896
+ accounts: projected.map((account) => ({
897
+ providerId: account.providerId,
898
+ family: account.family,
899
+ providerType: account.providerType,
900
+ active: account.active,
901
+ disabled: account.disabled,
902
+ unavailable: account.unavailable,
903
+ ...(account.usageUntrusted ? { usageUntrusted: true } : {}),
904
+ ...(account.coolingUntilMs === undefined
905
+ ? {}
906
+ : { coolingUntilMs: account.coolingUntilMs }),
907
+ ...(account.usage === undefined ? {} : { usage: account.usage }),
908
+ ...(account.usageFetch === undefined
909
+ ? {}
910
+ : { usageFetch: account.usageFetch }),
911
+ ...(account.allModelsUnsupported ? { allModelsUnsupported: true } : {}),
912
+ ...("label" in account ? { label: account.label } : {}),
913
+ ...("expiresAtMs" in account
914
+ ? { expiresAtMs: account.expiresAtMs }
915
+ : {}),
916
+ ...("accountFingerprint" in account
917
+ ? { accountFingerprint: account.accountFingerprint }
918
+ : {}),
919
+ ...this.#activeModelIdField(account.active),
920
+ })),
921
+ ...(unsupportedModels.length === 0 ? {} : { unsupportedModels }),
922
+ ...(declarationNotice === undefined ? {} : { declarationNotice }),
923
+ });
924
+ if (meteredFallback === undefined) return rendered;
925
+ const budget =
926
+ meteredFallback.reservedTodayUsd === undefined ||
927
+ meteredFallback.dailyLimitUsd === undefined
928
+ ? "daily reservation state unavailable"
929
+ : `$${meteredFallback.reservedTodayUsd.toFixed(2)} reserved of $${meteredFallback.dailyLimitUsd.toFixed(2)} today`;
930
+ if (!meteredFallback.enabled) {
931
+ const configured =
932
+ meteredFallback.configuredModel === undefined
933
+ ? ""
934
+ : `; configured for ${meteredFallback.configuredModel}; ${budget}`;
935
+ return `${rendered}\n\nOpenRouter last resort: disabled (${meteredFallback.reason})${configured}.`;
936
+ }
937
+ return (
938
+ `${rendered}\n\nOpenRouter last resort: enabled for ${meteredFallback.configuredModel ?? "an unavailable model"}; ` +
939
+ `${budget}; active ${meteredFallback.active ? "yes" : "no"}; delegate use blocked.`
940
+ );
941
+ }
942
+
943
+ #limits(): string {
944
+ const snapshots = this.#dependencies.usage.snapshots();
945
+ if (snapshots.length === 0)
946
+ return "No usage observations are available yet.";
947
+ return snapshots
948
+ .map((snapshot) => formatUsageSnapshot(snapshot, this.#now()))
949
+ .join("\n");
950
+ }
951
+
952
+ async #cost(periodValue: string | undefined): Promise<string> {
953
+ const periodType = periodValue ?? "month";
954
+ if (!PERIOD_TYPES.includes(periodType as PeriodType)) {
955
+ throw new TypeError(
956
+ "cost period must be day, week, month, quarter, half-year, or year.",
957
+ );
958
+ }
959
+ if (this.#dependencies.costReport === undefined) {
960
+ return "Cost intelligence is unavailable in this Pi build.";
961
+ }
962
+ return renderCostReport(
963
+ await this.#dependencies.costReport(periodType as PeriodType),
964
+ );
965
+ }
966
+
967
+ /**
968
+ * Run the managed declaration transaction for `install` or `update`.
969
+ *
970
+ * This fails closed in two ways rather than writing something the operator
971
+ * did not see. Without declaration inputs there is no target path to write.
972
+ * Without an interactive session there is no way to show the diff, and
973
+ * `executeModelsCommand` requires a confirmation answer before it commits;
974
+ * defaulting that answer to yes would write an unreviewed provider entry.
975
+ */
976
+ async #modelsTransaction(
977
+ action: "install" | "update",
978
+ session: CommandSession | undefined,
979
+ ): Promise<string> {
980
+ const declaration = this.#dependencies.modelsDeclaration;
981
+ if (declaration === undefined) {
982
+ throw new Error(
983
+ "Managed model declarations are unavailable in this session.",
984
+ );
985
+ }
986
+ if (session === undefined || !session.hasUI) {
987
+ throw new Error(
988
+ `/multi-account models ${action} needs an interactive session to confirm the change.`,
989
+ );
990
+ }
991
+ const { ui, signal } = session;
992
+ const result = await executeModelsCommand(`models ${action}`, {
993
+ targetPath: declaration.targetPath,
994
+ readCatalogs: declaration.readCatalogs,
995
+ // `confirm` is an adapter, not a handoff: the transaction supplies one
996
+ // rendered diff, while the dialog surface takes a title and a body.
997
+ confirm: (diff: string) =>
998
+ ui.confirm(
999
+ `Apply managed model declaration (${action})`,
1000
+ diff,
1001
+ signal ? { signal } : undefined,
1002
+ ),
1003
+ });
1004
+ if (result.outcome === "cancelled") {
1005
+ return `No change was written. ${result.action} cancelled.`;
1006
+ }
1007
+ const lines = [
1008
+ `Managed declaration ${result.action === "install" ? "installed" : "updated"} with ${result.modelIds.length} models.`,
1009
+ ...result.diagnostics,
1010
+ ];
1011
+ return lines.join("\n");
1012
+ }
1013
+
1014
+ #models(providerId: string | undefined): string {
1015
+ const accounts = providerId
1016
+ ? [this.#account(providerId)]
1017
+ : this.#accounts();
1018
+ if (accounts.length === 0)
1019
+ return "No models are available. Add or rediscover an account.";
1020
+ const unsupportedModels = this.#dependencies.unsupportedModels?.() ?? [];
1021
+ return sanitizedJson(
1022
+ accounts.map((account) => {
1023
+ const unsupported = account.modelIds.filter((modelId) =>
1024
+ unsupportedModels.some(
1025
+ (pair) =>
1026
+ pair.providerId === account.providerId &&
1027
+ pair.modelId === modelId,
1028
+ ),
1029
+ );
1030
+ return {
1031
+ providerId: account.providerId,
1032
+ displayName: account.displayName ?? account.providerId,
1033
+ api: account.model.api,
1034
+ models: account.modelIds,
1035
+ ...(unsupported.length === 0
1036
+ ? {}
1037
+ : { unsupportedModels: unsupported }),
1038
+ ...(unsupported.length === account.modelIds.length &&
1039
+ account.modelIds.length > 0
1040
+ ? { allModelsUnsupported: true }
1041
+ : {}),
1042
+ };
1043
+ }),
1044
+ );
1045
+ }
1046
+
1047
+ async #rediscover(): Promise<string> {
1048
+ await this.#dependencies.rediscover();
1049
+ return "Account metadata rediscovered and provider slots refreshed.";
1050
+ }
1051
+
1052
+ async #add(
1053
+ familyValue: string | undefined,
1054
+ slotValue: string | undefined,
1055
+ ): Promise<string> {
1056
+ if (!familyValue || !isManagedFamily(familyValue)) {
1057
+ throw new TypeError(
1058
+ "add requires family anthropic, openai-codex, google-antigravity, or openai.",
1059
+ );
1060
+ }
1061
+ const providerId = await this.#dependencies.addSlot(
1062
+ familyValue,
1063
+ parseSlotNumber(slotValue),
1064
+ );
1065
+ // The owning-vendor-api openai family authenticates from an API key, not an
1066
+ // OAuth login, so its guidance must not tell the operator to run a login.
1067
+ // Captured as a plain string so this branch does not depend on how the
1068
+ // family-acceptance guard above narrows `familyValue`.
1069
+ const requestedFamily: string = familyValue;
1070
+ return requestedFamily === "openai"
1071
+ ? `Registered API-key slot ${providerId}. Set OPENAI_API_KEY so it can authenticate.`
1072
+ : `Registered OAuth login slot ${providerId}. Use Pi's public login command to authenticate it.`;
1073
+ }
1074
+
1075
+ async #remove(providerId: string | undefined): Promise<string> {
1076
+ const account = this.#account(providerId);
1077
+ if (!this.#dependencies.publicRemove) return MANUAL_REMOVE_INSTRUCTIONS;
1078
+ const removed = await this.#dependencies.publicRemove(account.providerId);
1079
+ return removed
1080
+ ? `Removed credentials for ${account.providerId} through Pi's public API.`
1081
+ : MANUAL_REMOVE_INSTRUCTIONS;
1082
+ }
1083
+
1084
+ #clear(providerId: string | undefined): string {
1085
+ const account = this.#account(providerId);
1086
+ this.#disabledProviders.add(account.providerId);
1087
+ this.#dependencies.state.resetAccount(account.providerId);
1088
+ this.#dependencies.usage.clear(account.providerId);
1089
+ return `Cleared process-local state and disabled ${account.providerId}; credentials were preserved.`;
1090
+ }
1091
+
1092
+ #next(): string {
1093
+ const now = this.#now();
1094
+ const current = this.#dependencies.currentProviderId();
1095
+ const next = this.#accounts().find((account) => {
1096
+ if (
1097
+ account.providerId === current ||
1098
+ this.#disabledProviders.has(account.providerId)
1099
+ ) {
1100
+ return false;
1101
+ }
1102
+ if (this.#dependencies.isAccountEligible !== undefined) {
1103
+ return this.#dependencies.isAccountEligible(account.providerId, now);
1104
+ }
1105
+ return (
1106
+ this.#dependencies.state.getCooldown(account.providerId, now) ===
1107
+ undefined &&
1108
+ this.#dependencies.state.getInvalidation(account.providerId) ===
1109
+ undefined
1110
+ );
1111
+ });
1112
+ return next
1113
+ ? `Next healthy account: ${next.providerId}.`
1114
+ : "No healthy alternative is available; add or rediscover an account.";
1115
+ }
1116
+
1117
+ async #switch(providerId: string | undefined): Promise<string> {
1118
+ const account = this.#account(providerId);
1119
+ if (this.#disabledProviders.has(account.providerId)) {
1120
+ return `${account.providerId} is disabled in this process. Run enable or reset first.`;
1121
+ }
1122
+ const cooldown = this.#dependencies.state.getCooldown(
1123
+ account.providerId,
1124
+ this.#now(),
1125
+ );
1126
+ if (cooldown) {
1127
+ return `${account.providerId} is cooling until ${cooldown.untilMs}. Run /multi-account next or wait for recovery.`;
1128
+ }
1129
+ if (this.#dependencies.state.getInvalidation(account.providerId)) {
1130
+ return `${account.providerId} is unavailable after an authentication failure. Run rediscover or reset.`;
1131
+ }
1132
+ const switched =
1133
+ (await this.#dependencies.setModel(account.model)) === true;
1134
+ return switched
1135
+ ? `Switched explicitly to ${account.providerId}.`
1136
+ : `Pi could not select ${account.providerId}; authenticate it or choose another healthy account.`;
1137
+ }
1138
+
1139
+ #stop(): string {
1140
+ this.#dependencies.cancelPendingActivity();
1141
+ return "Pending automatic continuation and queued input were cancelled.";
1142
+ }
1143
+
1144
+ #reset(): string {
1145
+ this.#dependencies.cancelPendingActivity();
1146
+ this.#dependencies.state.clearAll();
1147
+ this.#dependencies.usage.clear();
1148
+ this.#disabledProviders.clear();
1149
+ return "Reset process-local cooldown, continuation, watchdog, usage, and disabled-account state.";
1150
+ }
1151
+
1152
+ async #reload(): Promise<string> {
1153
+ const config = await this.#dependencies.reloadGlobalConfig();
1154
+ await this.#dependencies.onConfigReload?.(config);
1155
+ return "Reloaded the machine-global multi-account configuration.";
1156
+ }
1157
+
1158
+ /**
1159
+ * Interactive cross-family routing configuration.
1160
+ *
1161
+ * TUI-only by explicit `mode` check, not by `hasUI`: Pi reports `hasUI` true
1162
+ * in RPC as well, and an RPC caller opening a modal selector would hang.
1163
+ */
1164
+ async #configure(session: CommandSession | undefined): Promise<string> {
1165
+ const routingConfig = this.#dependencies.routingConfig;
1166
+ if (routingConfig === undefined) {
1167
+ return "Cross-family routing configuration is unavailable in this build.";
1168
+ }
1169
+ if (
1170
+ session === undefined ||
1171
+ session.mode !== "tui" ||
1172
+ session.hasUI !== true
1173
+ ) {
1174
+ return CONFIGURE_TUI_ONLY;
1175
+ }
1176
+ if (this.#configureInProgress) return CONFIGURE_IN_PROGRESS;
1177
+ this.#configureInProgress = true;
1178
+ try {
1179
+ return await this.#runConfigure(session, routingConfig);
1180
+ } finally {
1181
+ // Cleared on success, Esc, rejection, every commit outcome, abort, and
1182
+ // exceptions, so one bad flow cannot block the command permanently.
1183
+ this.#configureInProgress = false;
1184
+ }
1185
+ }
1186
+
1187
+ async #runConfigure(
1188
+ session: CommandSession,
1189
+ routingConfig: RoutingConfigSurface,
1190
+ ): Promise<string> {
1191
+ const { ui } = session;
1192
+ const signal = session.signal;
1193
+ const dialogOptions: CommandDialogOptions =
1194
+ signal === undefined ? {} : { signal };
1195
+ const aborted = (): boolean => signal?.aborted === true;
1196
+
1197
+ const preAccounts = this.#accounts();
1198
+ const represented = new Set(preAccounts.map((account) => account.family));
1199
+ // Authoritative for replacement detection, confirmation, and the commit-time
1200
+ // drift comparison. The process-effective read is for display only.
1201
+ const persisted = routingProjection(routingConfig.readPersistedRouting());
1202
+ const effective = routingProjection(
1203
+ routingConfig.processEffectiveRouting(),
1204
+ );
1205
+
1206
+ const directions = ALLOWED_CROSS_FAMILY_PAIRS.filter(
1207
+ ([from, to]) => represented.has(from) && represented.has(to),
1208
+ );
1209
+ if (directions.length === 0) {
1210
+ const missing = ALLOWED_FAMILIES.filter(
1211
+ (family) => !represented.has(family),
1212
+ );
1213
+ return rediscoverGuidance(missing.join(" and "));
1214
+ }
1215
+
1216
+ const choice = await ui.select(
1217
+ "Cross-family routing",
1218
+ [
1219
+ CONFIGURE_VIEW_OPTION,
1220
+ ...directions.map(([from, to]) =>
1221
+ crossFamilyDirectionOption(from, to),
1222
+ ),
1223
+ ],
1224
+ dialogOptions,
1225
+ );
1226
+ if (aborted() || choice === undefined) return CONFIGURE_CANCELLED;
1227
+ if (choice === CONFIGURE_VIEW_OPTION) {
1228
+ return renderRoutingView(persisted, effective);
1229
+ }
1230
+ const direction = directions.find(
1231
+ ([from, to]) => crossFamilyDirectionOption(from, to) === choice,
1232
+ );
1233
+ if (direction === undefined) return CONFIGURE_CANCELLED;
1234
+ const [sourceFamily, destinationFamily] = direction;
1235
+
1236
+ const available = liveModelIds(preAccounts, destinationFamily);
1237
+ const selected = parseModelList(
1238
+ await ui.input(
1239
+ `Preferred ${destinationFamily} models, best first (comma-separated)`,
1240
+ `Available: ${available.join(", ")}`,
1241
+ dialogOptions,
1242
+ ),
1243
+ available,
1244
+ );
1245
+ if (aborted() || selected.status === "cancelled") return CONFIGURE_CANCELLED;
1246
+ if (selected.status === "rejected") {
1247
+ return `Configure rejected that entry: ${selected.reason} Nothing was changed.`;
1248
+ }
1249
+
1250
+ // Re-read accounts AFTER the dialog: an account can disappear while the
1251
+ // operator is typing, and every representation and coverage decision below
1252
+ // must use the later snapshot.
1253
+ const postAccounts = this.#accounts();
1254
+ const postRepresented = new Set(
1255
+ postAccounts.map((account) => account.family),
1256
+ );
1257
+ if (!postRepresented.has(destinationFamily)) {
1258
+ return rediscoverGuidance(destinationFamily);
1259
+ }
1260
+ const uncovered = accountsMissingPreference(
1261
+ postAccounts,
1262
+ destinationFamily,
1263
+ selected.models,
1264
+ );
1265
+ if (uncovered.length > 0) return coverageRejection(uncovered);
1266
+
1267
+ const selectedEdge: CrossFamilyChain = {
1268
+ from: sourceFamily,
1269
+ to: destinationFamily,
1270
+ };
1271
+ const preserved = normalizeCrossFamilyChains(persisted.crossFamilyChains);
1272
+ // Selecting an existing edge is a replacement, never an added duplicate.
1273
+ const edges = preserved.some(
1274
+ (edge) =>
1275
+ edge.from === selectedEdge.from && edge.to === selectedEdge.to,
1276
+ )
1277
+ ? preserved
1278
+ : [...preserved, selectedEdge];
1279
+ const preferredModels: Record<string, readonly string[]> = {
1280
+ ...persisted.preferredModels,
1281
+ [destinationFamily]: selected.models,
1282
+ };
1283
+
1284
+ // Enabling the global flag activates every staged edge at once, so a second
1285
+ // destination family whose preference lacks coverage is repaired in the SAME
1286
+ // invocation rather than being activated uncovered.
1287
+ const collected = new Set<AllowedFamily>([destinationFamily]);
1288
+ for (const edge of edges) {
1289
+ for (const family of [edge.from, edge.to]) {
1290
+ if (!postRepresented.has(family)) return rediscoverGuidance(family);
1291
+ }
1292
+ if (collected.has(edge.to)) continue;
1293
+ collected.add(edge.to);
1294
+ if (
1295
+ accountsMissingPreference(
1296
+ postAccounts,
1297
+ edge.to,
1298
+ preferredModels[edge.to] ?? [],
1299
+ ).length === 0
1300
+ ) {
1301
+ continue;
1302
+ }
1303
+ const repairAvailable = liveModelIds(postAccounts, edge.to);
1304
+ const repaired = parseModelList(
1305
+ await ui.input(
1306
+ `Preferred ${edge.to} models, best first (comma-separated)`,
1307
+ `Available: ${repairAvailable.join(", ")}`,
1308
+ dialogOptions,
1309
+ ),
1310
+ repairAvailable,
1311
+ );
1312
+ if (aborted() || repaired.status === "cancelled") {
1313
+ return CONFIGURE_CANCELLED;
1314
+ }
1315
+ if (repaired.status === "rejected") {
1316
+ return `Configure rejected that entry: ${repaired.reason} Nothing was changed.`;
1317
+ }
1318
+ const stillUncovered = accountsMissingPreference(
1319
+ postAccounts,
1320
+ edge.to,
1321
+ repaired.models,
1322
+ );
1323
+ if (stillUncovered.length > 0) return coverageRejection(stillUncovered);
1324
+ preferredModels[edge.to] = repaired.models;
1325
+ }
1326
+
1327
+ // Defense in depth over the COMPLETE enabled candidate. The per-step guards
1328
+ // above are the intended owners; this pass exists so a hand-edited invalid
1329
+ // edge cannot be activated because one of them was bypassed.
1330
+ for (const edge of edges) {
1331
+ try {
1332
+ validateCrossChain(edge);
1333
+ } catch (error) {
1334
+ const detail = error instanceof Error ? error.message : String(error);
1335
+ return `Configure refused the resulting policy: ${detail} Repair the machine-global config, then rerun configure.`;
1336
+ }
1337
+ if (!postRepresented.has(edge.from)) return rediscoverGuidance(edge.from);
1338
+ if (!postRepresented.has(edge.to)) return rediscoverGuidance(edge.to);
1339
+ const missing = accountsMissingPreference(
1340
+ postAccounts,
1341
+ edge.to,
1342
+ preferredModels[edge.to] ?? [],
1343
+ );
1344
+ if (missing.length > 0) return coverageRejection(missing);
1345
+ }
1346
+
1347
+ const candidate: RoutingProjection = {
1348
+ crossFamilyChainEnabled: true,
1349
+ crossFamilyChains: edges,
1350
+ preferredModels,
1351
+ tierModelMap: persisted.tierModelMap,
1352
+ };
1353
+
1354
+ const lines = [
1355
+ `Persisted starting policy: ${describeRoutingPolicy(persisted)}`,
1356
+ `Process-effective starting policy: ${describeRoutingPolicy(effective)}`,
1357
+ ];
1358
+ if (!routingProjectionsEqual(persisted, effective)) {
1359
+ lines.push(ROUTING_DIVERGENCE_NOTE);
1360
+ }
1361
+ lines.push(`Persisted candidate: ${describeRoutingPolicy(candidate)}`);
1362
+ if (!persisted.crossFamilyChainEnabled) {
1363
+ lines.push("Enabling cross-family routing activates every staged edge:");
1364
+ for (const edge of edges) {
1365
+ lines.push(` - ${crossFamilyDirectionOption(edge.from, edge.to)}`);
1366
+ }
1367
+ }
1368
+ lines.push(
1369
+ `preferredModels is family-scoped: this ${destinationFamily} list also affects same-family fallback for that destination family.`,
1370
+ );
1371
+ const approved = await ui.confirm(
1372
+ "Apply cross-family routing configuration",
1373
+ lines.join("\n"),
1374
+ dialogOptions,
1375
+ );
1376
+ if (aborted() || !approved) return CONFIGURE_CANCELLED;
1377
+
1378
+ return renderCommitResult(
1379
+ await routingConfig.commit({
1380
+ promptSnapshot: persisted,
1381
+ candidate,
1382
+ ...(signal === undefined ? {} : { signal }),
1383
+ }),
1384
+ candidate,
1385
+ destinationFamily,
1386
+ );
1387
+ }
1388
+
1389
+ #group(action: string | undefined, groupId: string | undefined): string {
1390
+ assertGroupCommand(action, groupId);
1391
+ const surface = this.#dependencies.accountGroups;
1392
+ if (surface === undefined) {
1393
+ throw new Error("Session account groups are unavailable in this build.");
1394
+ }
1395
+ if (action === "use") {
1396
+ if (groupId === undefined) {
1397
+ throw new TypeError(`Usage: /multi-account ${COMMAND_USAGE.group}`);
1398
+ }
1399
+ const status = surface.use(groupId);
1400
+ return `Session account group set to ${groupId}.\n${this.#renderAccountGroupStatus(status)}`;
1401
+ }
1402
+ if (action === "reset") {
1403
+ return `Session account group override reset.\n${this.#renderAccountGroupStatus(surface.reset())}`;
1404
+ }
1405
+ return this.#renderAccountGroupStatus(surface.status());
1406
+ }
1407
+
1408
+ #renderAccountGroupStatus(status: AccountGroupCommandStatus): string {
1409
+ const { resolution } = status;
1410
+ const source = resolution.source.replaceAll("-", " ");
1411
+ const groupId =
1412
+ resolution.source === "unrestricted" ? "unrestricted" : resolution.groupId;
1413
+ const lines = [`Effective account group: ${groupId} (${source}).`];
1414
+ for (const member of status.members ?? []) {
1415
+ lines.push(
1416
+ `- ${member.providerId}: ${member.eligible ? "eligible" : "blocked"} (${member.reason})`,
1417
+ );
1418
+ }
1419
+ return lines.join("\n");
1420
+ }
1421
+
1422
+ #enable(): string {
1423
+ this.#reset();
1424
+ return "Re-enabled all managed accounts and reset process-local routing, continuation, watchdog, and usage state.";
1425
+ }
1426
+
1427
+ #disable(familyValue: string | undefined): string {
1428
+ if (!familyValue || !isManagedFamily(familyValue)) {
1429
+ throw new TypeError(
1430
+ "disable requires family anthropic, openai-codex, google-antigravity, or openai.",
1431
+ );
1432
+ }
1433
+ for (const account of this.#accounts()) {
1434
+ if (account.family === familyValue)
1435
+ this.#disabledProviders.add(account.providerId);
1436
+ }
1437
+ return `Disabled ${familyValue} accounts in this process only.`;
1438
+ }
1439
+
1440
+ /**
1441
+ * Yields `{ activeModelId }` only when the account is active AND the host
1442
+ * actually reported a model id, so no `undefined` value is ever constructed
1443
+ * under exactOptionalPropertyTypes.
1444
+ */
1445
+ /**
1446
+ * Yields `{ [key]: value }` only when the value is present, so no `undefined`
1447
+ * member is ever constructed under exactOptionalPropertyTypes.
1448
+ */
1449
+ #optionalField<K extends string, V>(
1450
+ key: K,
1451
+ value: V | undefined,
1452
+ ): Record<K, V> | Record<string, never> {
1453
+ return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
1454
+ }
1455
+
1456
+ #activeModelIdField(isActive: boolean): { activeModelId?: string } {
1457
+ if (!isActive) return {};
1458
+ const modelId = this.#dependencies.activeModelId?.();
1459
+ return modelId === undefined ? {} : { activeModelId: modelId };
1460
+ }
1461
+
1462
+ /**
1463
+ * Machine-readable account status for the agent-invocable tool.
1464
+ *
1465
+ * REQ-STATUS-TOOL. This delegates to the SAME `#status("--json")` path the
1466
+ * operator's `/multi-account status --json` renders, rather than assembling a
1467
+ * second view. Duplicating it would let the operator surface and the agent
1468
+ * surface drift, and the label rule below is exactly the kind of detail that
1469
+ * drifts first.
1470
+ *
1471
+ * Human-readable labels are decorated HERE, at render time, from live config
1472
+ * via `dependencies.accountLabel`. They are never read from the usage store:
1473
+ * the store holds observations keyed by canonical provider id, and a label
1474
+ * persisted alongside them would go stale the moment config changed, then be
1475
+ * reported as current.
1476
+ */
1477
+ statusJson(): string {
1478
+ return this.#status("--json");
1479
+ }
1480
+
1481
+ isDisabled(providerId: string): boolean {
1482
+ return this.#disabledProviders.has(providerId);
1483
+ }
1484
+
1485
+ shutdown(): void {
1486
+ this.#dependencies.watchdog.cancelAll();
1487
+ this.#dependencies.continuation.cancelAll();
1488
+ this.#dependencies.usage.clear();
1489
+ this.#disabledProviders.clear();
1490
+ }
1491
+
1492
+ #now(): number {
1493
+ return (this.#dependencies.now ?? Date.now)();
1494
+ }
1495
+
1496
+ #output(value: unknown): string {
1497
+ return this.#dependencies.diagnostics.sanitizeOutput(value);
1498
+ }
1499
+ }
1500
+
1501
+ export { MANUAL_REMOVE_INSTRUCTIONS };
1502
+
1503
+ /*
1504
+ * ---------------------------------------------------------------------------
1505
+ * `/multi-account models install` and `/multi-account models update`
1506
+ * ---------------------------------------------------------------------------
1507
+ *
1508
+ * Writes the logical provider's declaration into the host's `models.json`.
1509
+ *
1510
+ * The file belongs to the operator, not to this extension. It may hold provider
1511
+ * entries this extension knows nothing about. The transaction owns the logical
1512
+ * provider declaration and `contextWindow` on offline-approved or independently
1513
+ * verified Codex overrides; it copies every other field through untouched.
1514
+ */
1515
+
1516
+ /** Live physical catalogs, keyed by managed family. */
1517
+ export type ModelsCatalogs = ModelDeclarationCatalogs;
1518
+
1519
+ /** Reported just before the atomic replacement, while the target is still old. */
1520
+ export type ModelsAtomicRenameDetails = {
1521
+ readonly temporaryPath: string;
1522
+ /** The prior bytes, kept aside. Absent only when there was no target. */
1523
+ readonly rollbackPath?: string | undefined;
1524
+ };
1525
+
1526
+ export type ModelsLeaseHandle = { readonly release: () => void | Promise<void> };
1527
+
1528
+ export type ModelsCommandOptions = {
1529
+ readonly targetPath: string;
1530
+ readonly readCatalogs: () => ModelsCatalogs | Promise<ModelsCatalogs>;
1531
+ /** Synthetic evidence seam for tests; production reads exact official pages. */
1532
+ readonly fetchCodexModelDocumentation?: typeof fetch;
1533
+ readonly validateCandidate?: (candidate: unknown) => void | Promise<void>;
1534
+ readonly confirm: (diff: string) => boolean | Promise<boolean>;
1535
+ readonly acquireLease?: (
1536
+ targetPath: string,
1537
+ ) => ModelsLeaseHandle | undefined | Promise<ModelsLeaseHandle | undefined>;
1538
+ readonly beforeAtomicRename?: (
1539
+ details: ModelsAtomicRenameDetails,
1540
+ ) => void | Promise<void>;
1541
+ readonly afterAtomicRename?: (
1542
+ details: ModelsAtomicRenameDetails,
1543
+ ) => void | Promise<void>;
1544
+ };
1545
+
1546
+ export type ModelsFileShape = {
1547
+ providers?: Record<string, Record<string, unknown>>;
1548
+ };
1549
+
1550
+ /** What the operator sees when a transaction finishes or declines to act. */
1551
+ export type ModelsCommandResult = {
1552
+ readonly action: "install" | "update";
1553
+ readonly outcome: "committed" | "cancelled";
1554
+ readonly modelIds: readonly string[];
1555
+ readonly diagnostics: readonly string[];
1556
+ };
1557
+
1558
+ const MODELS_COMMAND_USAGE =
1559
+ "Usage: /multi-account models install|update";
1560
+ /** Previous provider identity, retained only for the models.json migration. */
1561
+ const LEGACY_LOGICAL_PROVIDER_ID = "pi-multi-account";
1562
+
1563
+ /**
1564
+ * Read the target, treating an absent file as an empty one.
1565
+ *
1566
+ * Unparseable bytes fail closed rather than being replaced. Overwriting a file
1567
+ * this extension cannot read would destroy provider entries belonging to
1568
+ * someone else, which is the one outcome this transaction exists to prevent.
1569
+ */
1570
+ function readModelsTarget(targetPath: string): {
1571
+ parsed: ModelsFileShape;
1572
+ bytes: Buffer | undefined;
1573
+ } {
1574
+ if (!existsSync(targetPath)) return { parsed: {}, bytes: undefined };
1575
+ const bytes = readFileSync(targetPath);
1576
+ let parsed: unknown;
1577
+ try {
1578
+ parsed = JSON.parse(bytes.toString("utf8"));
1579
+ } catch {
1580
+ throw new Error(
1581
+ `The models file at ${targetPath} is not valid JSON, so it will not be replaced.`,
1582
+ );
1583
+ }
1584
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1585
+ throw new Error(
1586
+ `The models file at ${targetPath} is not a JSON object, so it will not be replaced.`,
1587
+ );
1588
+ }
1589
+ return { parsed: parsed as ModelsFileShape, bytes };
1590
+ }
1591
+
1592
+ /**
1593
+ * The default candidate check: structural, hermetic and always run.
1594
+ *
1595
+ * It confirms the bytes about to be written parse back to the declaration this
1596
+ * transaction intended — the managed entry present and correctly identified,
1597
+ * every row carrying an id, and no forbidden field. Driving a real host to
1598
+ * confirm the declaration actually loads is a live-integration concern and is
1599
+ * out of this node's scope; `validateCandidate` is the seam for it.
1600
+ */
1601
+ function validateCandidateStructure(candidate: unknown): void {
1602
+ const providers =
1603
+ typeof candidate === "object" && candidate !== null
1604
+ ? (candidate as ModelsFileShape).providers
1605
+ : undefined;
1606
+ const declaration = providers?.[LOGICAL_PROVIDER_ID];
1607
+ if (declaration === undefined) {
1608
+ throw new Error("the candidate declaration is missing the managed provider");
1609
+ }
1610
+ if (declaration.api !== LOGICAL_PROVIDER_ID) {
1611
+ throw new Error("the candidate declaration has the wrong api id");
1612
+ }
1613
+ // The host needs both of these to compose the provider at all, and deletes
1614
+ // it when composition fails. A candidate missing either is a file that
1615
+ // would load as nothing.
1616
+ if (declaration.name !== LOGICAL_PROVIDER_DISPLAY_NAME) {
1617
+ throw new Error("the candidate declaration has no usable provider name");
1618
+ }
1619
+ if (typeof declaration.apiKey !== "string" || declaration.apiKey === "") {
1620
+ throw new Error(
1621
+ "the candidate declaration has no authentication method, so the host would discard it",
1622
+ );
1623
+ }
1624
+ if (declaration.baseUrl !== DECLARATION_BASE_URL) {
1625
+ throw new Error("the candidate declaration has the wrong baseUrl");
1626
+ }
1627
+ if ("oauth" in declaration) {
1628
+ throw new Error("the candidate declaration carries a forbidden oauth field");
1629
+ }
1630
+ const models = declaration.models;
1631
+ if (!Array.isArray(models)) {
1632
+ throw new Error("the candidate declaration has no model rows");
1633
+ }
1634
+ assertProjectedManagedModels(models);
1635
+ }
1636
+
1637
+ const MODELS_DIFF_ID_LIST_LIMIT = 20;
1638
+ const MODEL_ID_SINGLE_LINE_CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/gu;
1639
+
1640
+ function managedRowId(row: unknown): string | undefined {
1641
+ try {
1642
+ const id = (row as { readonly id?: unknown } | null | undefined)?.id;
1643
+ return typeof id === "string" ? id : undefined;
1644
+ } catch {
1645
+ return undefined;
1646
+ }
1647
+ }
1648
+
1649
+ /** Canonical content used only to decide whether a row changed. */
1650
+ function renderManagedRow(row: unknown): string | undefined {
1651
+ try {
1652
+ return JSON.stringify(assertProjectedManagedModel(row));
1653
+ } catch {
1654
+ return undefined;
1655
+ }
1656
+ }
1657
+
1658
+ function renderModelId(id: string): string {
1659
+ return id.replace(MODEL_ID_SINGLE_LINE_CONTROLS, (character) => {
1660
+ switch (character) {
1661
+ case "\b":
1662
+ return "\\b";
1663
+ case "\t":
1664
+ return "\\t";
1665
+ case "\n":
1666
+ return "\\n";
1667
+ case "\f":
1668
+ return "\\f";
1669
+ case "\r":
1670
+ return "\\r";
1671
+ default:
1672
+ return `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`;
1673
+ }
1674
+ });
1675
+ }
1676
+
1677
+ function renderModelIds(ids: readonly string[]): string {
1678
+ const sorted = [...ids].sort();
1679
+ const visible = sorted.slice(0, MODELS_DIFF_ID_LIST_LIMIT).map(renderModelId);
1680
+ const overflow = sorted.length - visible.length;
1681
+ return `${visible.join(", ")}${overflow > 0 ? ` (+${overflow} more)` : ""}`;
1682
+ }
1683
+
1684
+ function renderCodexModelDefaultChanges(
1685
+ changes: readonly CodexModelDefaultChange[],
1686
+ ): string[] {
1687
+ if (changes.length === 0) {
1688
+ return [" openai-codex modelOverrides: no contextWindow changes"];
1689
+ }
1690
+ const lines = [" openai-codex modelOverrides:"];
1691
+ for (const change of changes) {
1692
+ if (change.contextWindow === undefined) {
1693
+ lines.push(
1694
+ ` - ${change.modelId}.contextWindow (was ${change.previousContextWindow})`,
1695
+ );
1696
+ continue;
1697
+ }
1698
+ if (!change.hadOverride) {
1699
+ lines.push(
1700
+ ` + ${change.modelId}.contextWindow = ${change.contextWindow}`,
1701
+ );
1702
+ continue;
1703
+ }
1704
+ lines.push(
1705
+ ` ~ ${change.modelId}.contextWindow: ${change.previousContextWindow ?? "<unset>"} -> ${change.contextWindow}`,
1706
+ );
1707
+ }
1708
+ return lines;
1709
+ }
1710
+
1711
+ function renderModelsDiff(
1712
+ targetPath: string,
1713
+ action: "install" | "update",
1714
+ previous: readonly unknown[] | undefined,
1715
+ next: readonly ModelDeclarationRow[],
1716
+ diagnostics: readonly string[],
1717
+ codexChanges: readonly CodexModelDefaultChange[],
1718
+ ): string {
1719
+ type PreviousRowKey = string | symbol;
1720
+ interface PreviousRowEntry {
1721
+ readonly displayId: string;
1722
+ readonly row: unknown;
1723
+ }
1724
+
1725
+ const previousById = new Map<PreviousRowKey, PreviousRowEntry>();
1726
+ for (const row of previous ?? []) {
1727
+ const id = managedRowId(row);
1728
+ previousById.set(id ?? Symbol("invalid previous model id"), {
1729
+ displayId: id ?? "<unknown>",
1730
+ row,
1731
+ });
1732
+ }
1733
+ const nextById = new Map<string, ModelDeclarationRow>();
1734
+ for (const row of next) nextById.set(row.id, row);
1735
+
1736
+ const added = [...nextById.keys()].filter((id) => !previousById.has(id));
1737
+ const removed = [...previousById]
1738
+ .filter(([id]) => typeof id !== "string" || !nextById.has(id))
1739
+ .map(([, entry]) => entry.displayId);
1740
+ const changed: string[] = [];
1741
+ const unchanged: string[] = [];
1742
+ for (const [id, after] of nextById) {
1743
+ if (!previousById.has(id)) continue;
1744
+ const renderedBefore = renderManagedRow(previousById.get(id)?.row);
1745
+ const renderedAfter = renderManagedRow(after);
1746
+ if (
1747
+ renderedBefore !== undefined &&
1748
+ renderedAfter !== undefined &&
1749
+ renderedBefore === renderedAfter
1750
+ ) {
1751
+ unchanged.push(id);
1752
+ } else {
1753
+ changed.push(id);
1754
+ }
1755
+ }
1756
+
1757
+ const lines = [
1758
+ `${action} ${LOGICAL_PROVIDER_ID} in ${targetPath}`,
1759
+ ` ${next.length} models total: ${added.length} added, ${removed.length} removed, ${changed.length} changed, ${unchanged.length} unchanged`,
1760
+ ];
1761
+ if (added.length > 0) lines.push(` + added: ${renderModelIds(added)}`);
1762
+ if (removed.length > 0) lines.push(` - removed: ${renderModelIds(removed)}`);
1763
+ if (changed.length > 0) lines.push(` ~ changed: ${renderModelIds(changed)}`);
1764
+ lines.push(...renderCodexModelDefaultChanges(codexChanges));
1765
+ for (const note of diagnostics) lines.push(` note: ${note}`);
1766
+ return lines.join("\n");
1767
+ }
1768
+
1769
+ /**
1770
+ * Install or update the logical provider's declaration.
1771
+ *
1772
+ * The order of operations is the contract. Syntax and declaration state are
1773
+ * settled before the live catalogs are read, so a mistyped command never
1774
+ * reaches the network. The candidate is validated and confirmed before the
1775
+ * lease is taken, so a rejected write never blocks another writer. The target
1776
+ * is re-read after the lease is held, so a change made by someone else between
1777
+ * the first read and the write aborts instead of being overwritten.
1778
+ */
1779
+ export async function executeModelsCommand(
1780
+ rawArguments: string,
1781
+ options: ModelsCommandOptions,
1782
+ ): Promise<ModelsCommandResult> {
1783
+ const tokens = rawArguments.trim().split(/\s+/u).filter((t) => t.length > 0);
1784
+ const [head, action, ...rest] = tokens;
1785
+ if (head !== "models" || rest.length > 0) throw new TypeError(MODELS_COMMAND_USAGE);
1786
+ if (action !== "install" && action !== "update") {
1787
+ throw new TypeError(MODELS_COMMAND_USAGE);
1788
+ }
1789
+
1790
+ const { targetPath } = options;
1791
+ const initial = readModelsTarget(targetPath);
1792
+ const existingDeclaration = initial.parsed.providers?.[LOGICAL_PROVIDER_ID];
1793
+ const legacyDeclaration =
1794
+ initial.parsed.providers?.[LEGACY_LOGICAL_PROVIDER_ID];
1795
+ const hasManagedLegacyDeclaration =
1796
+ legacyDeclaration?.api === LEGACY_LOGICAL_PROVIDER_ID &&
1797
+ legacyDeclaration.baseUrl === DECLARATION_BASE_URL;
1798
+
1799
+ // Declaration state is checked before anything is read from the providers.
1800
+ if (action === "install" && existingDeclaration !== undefined) {
1801
+ throw new Error(
1802
+ `A managed declaration already exists in ${targetPath}. Run /multi-account models update to refresh it.`,
1803
+ );
1804
+ }
1805
+ if (
1806
+ action === "update" &&
1807
+ existingDeclaration === undefined &&
1808
+ !hasManagedLegacyDeclaration
1809
+ ) {
1810
+ throw new Error(
1811
+ `No managed declaration is present in ${targetPath}. Run /multi-account models install first.`,
1812
+ );
1813
+ }
1814
+
1815
+ const previousDeclaration =
1816
+ existingDeclaration ??
1817
+ (action === "update" && hasManagedLegacyDeclaration
1818
+ ? legacyDeclaration
1819
+ : undefined);
1820
+ const previousRows = Array.isArray(previousDeclaration?.models)
1821
+ ? previousDeclaration.models
1822
+ : undefined;
1823
+
1824
+ // Validate operator-owned Codex overrides before consulting live providers.
1825
+ // A malformed override survives byte-for-byte because no catalog or official
1826
+ // documentation is read and no candidate is built.
1827
+ planCodexModelDefaults(initial.parsed.providers);
1828
+
1829
+ // Fail closed: an unreadable catalog produces no write or documentation
1830
+ // request. Validate a projection before deriving any network candidate IDs;
1831
+ // the final projection below uses the independently resolved map.
1832
+ const catalogs = await options.readCatalogs();
1833
+ buildModelDeclarationWithCodexDefaults(catalogs, new Map<string, number>());
1834
+ const resolvedCodexDefaults = await resolveCodexLongContextDefaults(
1835
+ catalogs["openai-codex"],
1836
+ {
1837
+ ...(options.fetchCodexModelDocumentation === undefined
1838
+ ? {}
1839
+ : { fetchDocumentation: options.fetchCodexModelDocumentation }),
1840
+ },
1841
+ );
1842
+ const codexDefaults = planCodexModelDefaults(
1843
+ initial.parsed.providers,
1844
+ resolvedCodexDefaults.contextWindowByModelId,
1845
+ resolvedCodexDefaults.liveCompleteOfflineModelIds,
1846
+ );
1847
+ const built = buildModelDeclarationWithCodexDefaults(
1848
+ catalogs,
1849
+ codexDefaults.contextWindowByModelId,
1850
+ );
1851
+ const diagnostics = [...resolvedCodexDefaults.diagnostics, ...built.diagnostics];
1852
+ const modelIds = built.models.map((row) => String(row.id));
1853
+
1854
+ const candidateProviders = { ...codexDefaults.providers };
1855
+ // Remove only the declaration shape this extension used before the ID rename.
1856
+ if (hasManagedLegacyDeclaration) {
1857
+ delete candidateProviders[LEGACY_LOGICAL_PROVIDER_ID];
1858
+ }
1859
+ const candidate: ModelsFileShape = {
1860
+ ...initial.parsed,
1861
+ providers: {
1862
+ ...candidateProviders,
1863
+ [LOGICAL_PROVIDER_ID]: {
1864
+ name: LOGICAL_PROVIDER_DISPLAY_NAME,
1865
+ api: LOGICAL_PROVIDER_ID,
1866
+ baseUrl: DECLARATION_BASE_URL,
1867
+ // Pi's provider composer requires an authentication method, and it
1868
+ // DELETES a provider whose entry fails to compose. Without this the
1869
+ // installed declaration would be discarded in silence and the
1870
+ // operator would simply never see the models they installed.
1871
+ //
1872
+ // The value is a fixed placeholder, not a credential. This provider
1873
+ // is never dialed: every request is dispatched through a physical
1874
+ // account that authenticates itself, and the baseUrl is a reserved
1875
+ // address that resolves nowhere.
1876
+ apiKey: DECLARATION_PLACEHOLDER_KEY,
1877
+ models: built.models,
1878
+ },
1879
+ },
1880
+ };
1881
+
1882
+ await (options.validateCandidate ?? validateCandidateStructure)(candidate);
1883
+
1884
+ const diff = renderModelsDiff(
1885
+ targetPath,
1886
+ action,
1887
+ previousRows,
1888
+ built.models,
1889
+ diagnostics,
1890
+ codexDefaults.changes,
1891
+ );
1892
+ if (!(await options.confirm(diff))) {
1893
+ return {
1894
+ action,
1895
+ outcome: "cancelled",
1896
+ modelIds,
1897
+ diagnostics,
1898
+ };
1899
+ }
1900
+
1901
+ // The lock sits beside the file it protects. A machine-global lock would
1902
+ // serialize writers of unrelated models files against each other, and would
1903
+ // write real machine state on behalf of a caller working in a sandbox.
1904
+ const acquire =
1905
+ options.acquireLease ??
1906
+ ((path: string) => acquireMachineLease({ lockPath: `${path}.lock` }));
1907
+ const lease = await acquire(targetPath);
1908
+ if (lease === undefined) {
1909
+ throw new Error(
1910
+ `Another process is writing ${targetPath}. Try again in a moment.`,
1911
+ );
1912
+ }
1913
+
1914
+ try {
1915
+ const current = readModelsTarget(targetPath);
1916
+ const drifted =
1917
+ initial.bytes === undefined
1918
+ ? current.bytes !== undefined
1919
+ : current.bytes === undefined || !current.bytes.equals(initial.bytes);
1920
+ if (drifted) {
1921
+ throw new Error(
1922
+ `${targetPath} changed while this command was preparing its write, so nothing was written.`,
1923
+ );
1924
+ }
1925
+ await commitModelsCandidate(targetPath, candidate, options);
1926
+ return {
1927
+ action,
1928
+ outcome: "committed",
1929
+ modelIds,
1930
+ diagnostics,
1931
+ };
1932
+ } finally {
1933
+ await lease.release();
1934
+ }
1935
+ }
1936
+
1937
+ /**
1938
+ * Replace the target atomically, mirroring `writeConfig`.
1939
+ *
1940
+ * The candidate is written to an owner-only temporary file in the target's own
1941
+ * directory, so the rename is a same-filesystem atomic swap and no reader ever
1942
+ * observes a half-written file. When a target already exists its bytes are
1943
+ * copied aside first: if anything fails after that point the prior file can be
1944
+ * put back exactly as it was. Both scratch files are removed on every path,
1945
+ * because a leftover temporary is itself a partial write someone will find.
1946
+ */
1947
+ async function commitModelsCandidate(
1948
+ targetPath: string,
1949
+ candidate: ModelsFileShape,
1950
+ options: ModelsCommandOptions,
1951
+ ): Promise<void> {
1952
+ const directory = dirname(targetPath);
1953
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
1954
+ const stem = `${basename(targetPath)}.${process.pid}.${randomUUID()}`;
1955
+ const temporaryPath = join(directory, `.${stem}.tmp`);
1956
+ const rollbackPath = existsSync(targetPath)
1957
+ ? join(directory, `.${stem}.rollback`)
1958
+ : undefined;
1959
+
1960
+ let descriptor: number | undefined;
1961
+ let replaced = false;
1962
+ try {
1963
+ descriptor = openSync(temporaryPath, "wx", 0o600);
1964
+ writeFileSync(descriptor, `${JSON.stringify(candidate, null, "\t")}\n`, {
1965
+ encoding: "utf-8",
1966
+ });
1967
+ fsyncSync(descriptor);
1968
+ closeSync(descriptor);
1969
+ descriptor = undefined;
1970
+ chmodSync(temporaryPath, 0o600);
1971
+
1972
+ if (rollbackPath !== undefined) {
1973
+ copyFileSync(targetPath, rollbackPath);
1974
+ chmodSync(rollbackPath, 0o600);
1975
+ }
1976
+
1977
+ await options.beforeAtomicRename?.({ temporaryPath, rollbackPath });
1978
+
1979
+ renameSync(temporaryPath, targetPath);
1980
+ replaced = true;
1981
+ await options.afterAtomicRename?.({ temporaryPath, rollbackPath });
1982
+ chmodSync(targetPath, 0o600);
1983
+ } catch (error) {
1984
+ if (descriptor !== undefined) closeSync(descriptor);
1985
+ // The rename either happened or it did not. When it did not, the target
1986
+ // was never touched, so putting the rollback copy back would be a write
1987
+ // where none occurred. Restore only after a failure past the swap.
1988
+ if (replaced && rollbackPath !== undefined && existsSync(rollbackPath)) {
1989
+ copyFileSync(rollbackPath, targetPath);
1990
+ } else if (replaced && rollbackPath === undefined && existsSync(targetPath)) {
1991
+ unlinkSync(targetPath);
1992
+ }
1993
+ throw error;
1994
+ } finally {
1995
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
1996
+ if (rollbackPath !== undefined && existsSync(rollbackPath)) {
1997
+ unlinkSync(rollbackPath);
1998
+ }
1999
+ }
2000
+ }