@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
package/src/config.ts ADDED
@@ -0,0 +1,1317 @@
1
+ /**
2
+ * Fresh machine-global config schema for the multi-account extension.
3
+ *
4
+ * Accepts the Anthropic, OpenAI Codex, and Google Antigravity subscription
5
+ * families. OpenRouter and every unsupported family are rejected from the
6
+ * effective route graph. Same-family failover is enabled by default;
7
+ * cross-family chaining is disabled by default and, when explicitly enabled,
8
+ * only the explicit pairs in {@link ALLOWED_CROSS_FAMILY_PAIRS} are permitted:
9
+ * both Anthropic↔Codex directions, and all four Antigravity directions
10
+ * (Anthropic↔Antigravity and Codex↔Antigravity). Authorizing one direction
11
+ * never implies its reverse.
12
+ * No project-local override loading.
13
+ *
14
+ * Config and state files are written atomically with POSIX mode 0600 and their
15
+ * owning directory is constrained to 0700 (REQ-PERM-1).
16
+ */
17
+
18
+ import {
19
+ chmodSync,
20
+ closeSync,
21
+ existsSync,
22
+ fsyncSync,
23
+ mkdirSync,
24
+ openSync,
25
+ readFileSync,
26
+ renameSync,
27
+ unlinkSync,
28
+ writeFileSync,
29
+ } from "node:fs";
30
+ import { randomUUID } from "node:crypto";
31
+ import { basename, dirname, isAbsolute, join } from "node:path";
32
+ import { PROJECT_KEY_PATTERN } from "./project-identity.js";
33
+ import {
34
+ AccountRateHistoryError,
35
+ normalizeAccountRateHistory,
36
+ parseAccountRateRecord,
37
+ type AccountRateRecord,
38
+ } from "./account-rate-history.js";
39
+ import {
40
+ PRESET_ID_PATTERN,
41
+ SubscriptionPlanCatalogError,
42
+ parseSubscriptionPlanCatalogOverride,
43
+ type SubscriptionPlanCatalogOverride,
44
+ } from "./subscription-plan-catalog.js";
45
+ import {
46
+ MAX_TIER_MODEL_ID_LENGTH,
47
+ type TierModelDestination,
48
+ type TierModelMap,
49
+ } from "./tier-model-resolver.js";
50
+
51
+ export const ALLOWED_FAMILIES = [
52
+ "anthropic",
53
+ "openai-codex",
54
+ "google-antigravity",
55
+ ] as const;
56
+ export type AllowedFamily = (typeof ALLOWED_FAMILIES)[number];
57
+
58
+ /**
59
+ * Owning-vendor-API families reached by a vendor's own pay-per-token platform
60
+ * API rather than a subscription. This is the distinct `openai` platform
61
+ * provider only; Anthropic's owning-vendor-API tier is the existing `anthropic`
62
+ * family with an `api_key` credential, not a separate token.
63
+ */
64
+ export const OWNING_VENDOR_API_FAMILIES = ["openai"] as const;
65
+
66
+ /**
67
+ * Every managed provider family: the subscription families plus the
68
+ * owning-vendor-API families. Discovery, registration, completion, status, and
69
+ * cost and physical-routing surfaces operate over this set. The unified logical
70
+ * provider also consumes the managed set, but projects `openai-codex` and
71
+ * `openai` through their shared OpenAI vendor while retaining their distinct
72
+ * subscription and owning-vendor-API tiers. The v1 route resolver, usage config,
73
+ * subscription cost, and directional cross-family chains stay on the narrower
74
+ * {@link ALLOWED_FAMILIES}.
75
+ */
76
+ export const MANAGED_FAMILIES = [
77
+ ...ALLOWED_FAMILIES,
78
+ ...OWNING_VENDOR_API_FAMILIES,
79
+ ] as const;
80
+ export type ManagedFamily = (typeof MANAGED_FAMILIES)[number];
81
+
82
+ /**
83
+ * The absolute maximum number of managed account slots per family, base
84
+ * included. Every numbered-slot enumerator, canonical-id constructor, and
85
+ * downstream credential/registration/state/item sink is bounded by this value
86
+ * before any synchronous work begins, so a hostile or oversized configuration
87
+ * cannot cause discovery, spare search, add-slot search, or completion to visit
88
+ * a slot above it. Configuration accepts only integer account limits from `1`
89
+ * through this maximum.
90
+ */
91
+ export const MAX_ACCOUNT_LIMIT = 32;
92
+
93
+ /**
94
+ * Accepts an account limit: an integer in `1..MAX_ACCOUNT_LIMIT`. This is the
95
+ * one predicate that both config validation and every slot iterator consult
96
+ * before enumerating, so a limit above the maximum never reaches a formatter.
97
+ */
98
+ export function isAccountLimit(value: unknown): value is number {
99
+ return (
100
+ typeof value === "number" &&
101
+ Number.isInteger(value) &&
102
+ value >= 1 &&
103
+ value <= MAX_ACCOUNT_LIMIT
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Accepts a numbered account slot index for the current validated limit: an
109
+ * integer in `1..accountLimit` when `accountLimit` itself satisfies
110
+ * {@link isAccountLimit}. Every direct iterator consumer revalidates each
111
+ * yielded index with this predicate before it formats a canonical id or causes
112
+ * a side effect, so a regressed iterator cannot smuggle a slot past a formatter.
113
+ */
114
+ export function isAccountSlotIndex(
115
+ value: unknown,
116
+ accountLimit: number,
117
+ ): value is number {
118
+ if (!isAccountLimit(accountLimit)) return false;
119
+ return (
120
+ typeof value === "number" &&
121
+ Number.isInteger(value) &&
122
+ value >= 1 &&
123
+ value <= accountLimit
124
+ );
125
+ }
126
+
127
+ /**
128
+ * The canonical managed provider id for one family and numbered slot, or `null`
129
+ * when the family, index, or current limit is invalid. Slot `1` is the base
130
+ * family id; slots `2..accountLimit` are `${family}-account-${slotIndex}`. This
131
+ * is the only production constructor used by the direct iterator consumers and
132
+ * downstream current-limit guards for base and numbered managed slot ids.
133
+ */
134
+ export function canonicalProviderIdForAccountSlot(
135
+ family: ManagedFamily,
136
+ slotIndex: number,
137
+ accountLimit: number,
138
+ ): string | null {
139
+ if (!isManagedFamily(family)) return null;
140
+ if (!isAccountSlotIndex(slotIndex, accountLimit)) return null;
141
+ return slotIndex === 1 ? family : `${family}-account-${slotIndex}`;
142
+ }
143
+
144
+ /**
145
+ * The sole production loop that increments a numbered account slot. It
146
+ * validates `accountLimit` and `firstSlot` before its first yield, accepts only
147
+ * `firstSlot: 1 | 2`, and yields each integer from `firstSlot` through
148
+ * `accountLimit` once in ascending order. No other production loop may increment
149
+ * a numbered account slot; every numbered-slot path consumes this iterator and
150
+ * revalidates each yield independently.
151
+ */
152
+ export function* accountSlotIndexes(
153
+ accountLimit: number,
154
+ firstSlot: 1 | 2,
155
+ ): IterableIterator<number> {
156
+ if (!isAccountLimit(accountLimit)) return;
157
+ if (firstSlot !== 1 && firstSlot !== 2) return;
158
+ for (let slot = firstSlot; slot <= accountLimit; slot++) {
159
+ yield slot;
160
+ }
161
+ }
162
+
163
+ export const KNOWN_REJECTED_FAMILIES = ["openrouter"] as const;
164
+
165
+ export interface CrossFamilyChain {
166
+ readonly from: AllowedFamily;
167
+ readonly to: AllowedFamily;
168
+ }
169
+
170
+ export interface MultiAccountConfig {
171
+ readonly accountLimit: number;
172
+ readonly sameFamilyFailover: boolean;
173
+ readonly crossFamilyChainEnabled: boolean;
174
+ readonly crossFamilyChains: readonly CrossFamilyChain[];
175
+ readonly watchdogIntervalMs: number;
176
+ readonly cooldownMaxMs: number;
177
+ /** Idle time allowed between qualifying progress events in one recovery invocation. */
178
+ readonly recoveryIdleTimeoutMs: number;
179
+ /** Total elapsed time allowed for one complete recovery invocation. */
180
+ readonly recoveryAbsoluteTimeoutMs: number;
181
+ /**
182
+ * Operator-chosen display labels keyed by canonical provider id, so managed
183
+ * accounts are distinguishable in Pi's login list and the status view.
184
+ * Anthropic credentials are opaque, so configuration is the only way to name
185
+ * them; Codex labels can be derived from the credential's identity claim.
186
+ */
187
+ readonly accountLabels: Readonly<Record<string, string>>;
188
+ /** Local-only display mapping for bounded project digests. Raw paths are invalid. */
189
+ readonly projectLabels: Readonly<Record<string, string>>;
190
+ /**
191
+ * Named allow-lists of explicit canonical OAuth provider ids. Group names are
192
+ * local policy identifiers; members never derive from labels, email addresses,
193
+ * or credential metadata.
194
+ */
195
+ readonly accountGroups?: Readonly<Record<string, readonly string[]>>;
196
+ /** Exact absolute cwd to account-group id defaults. No project-root folding. */
197
+ readonly accountGroupCwdDefaults?: Readonly<Record<string, string>>;
198
+ /** Optional machine-wide fallback when no exact-cwd or session override applies. */
199
+ readonly defaultAccountGroup?: string;
200
+ /**
201
+ * Monthly subscription price in USD, keyed by canonical managed provider
202
+ * id. Legacy, mutable, and retroactive: it has no effective start. Kept
203
+ * readable and untouched for compatibility; not migrated into
204
+ * {@link MultiAccountConfig.accountRateHistory}.
205
+ */
206
+ readonly monthlySubscriptionUsd: Readonly<Record<string, number>>;
207
+ /**
208
+ * Machine-global overrides for the shipped subscription-plan preset
209
+ * catalog ({@link ../config/subscription-plans.v1.json}), keyed by preset
210
+ * id. A key that matches a shipped id replaces that preset; any other key
211
+ * adds a validated custom preset. There is no project-local catalog layer.
212
+ */
213
+ readonly subscriptionPlanCatalogOverrides?: Readonly<
214
+ Record<string, SubscriptionPlanCatalogOverride>
215
+ >;
216
+ /**
217
+ * Effective-dated USD rate records per canonical accountId, ordered
218
+ * ascending by `effectiveFrom`. Selecting a catalog preset copies its
219
+ * terms into the record, so a later catalog edit never rewrites an
220
+ * existing record. No duplicate or overlapping `effectiveFrom` instants;
221
+ * never carries a default end, renewal, or effective instant.
222
+ */
223
+ readonly accountRateHistory?: Readonly<Record<string, readonly AccountRateRecord[]>>;
224
+ /**
225
+ * Per-family model preference, best first, keyed by family.
226
+ *
227
+ * Consulted when failover crosses FAMILIES, where the failed turn's model id
228
+ * is meaningless: `claude-opus-5` does not exist in the Codex catalog. Without
229
+ * a preference the destination falls back to the catalog head -- whatever
230
+ * happens to be first -- which is the defect fixed in 37c4317, where an opus
231
+ * turn silently resumed on fable.
232
+ *
233
+ * Ported from the Sarrius reference (index.ts:3784-3790), which reached the
234
+ * same conclusion: a per-family list rather than N-by-N model pairs. Adding a
235
+ * model to a list is enough; there is no mapping matrix to maintain.
236
+ *
237
+ * Same-family failover ignores this entirely and keeps the exact model.
238
+ */
239
+ readonly preferredModels: Readonly<Record<string, readonly string[]>>;
240
+ readonly tierModelMap: TierModelMap;
241
+ /**
242
+ * How close to expiry a credential may get before routing prefers a fresher
243
+ * same-family account, in milliseconds. Pre-emption avoids spending a turn to
244
+ * discover an expiry that was predictable. Zero disables pre-emption, leaving
245
+ * purely reactive routing.
246
+ */
247
+ readonly preemptiveExpiryWindowMs: number;
248
+ /** Whether undocumented provider usage fetches run for each managed family. */
249
+ readonly usageFetchEnabled?: Readonly<Record<AllowedFamily, boolean>>;
250
+ }
251
+
252
+ export const DEFAULT_USAGE_FETCH_ENABLED: Readonly<
253
+ Record<AllowedFamily, boolean>
254
+ > = Object.freeze({
255
+ anthropic: true,
256
+ "openai-codex": true,
257
+ "google-antigravity": true,
258
+ });
259
+
260
+ /** Warming starts before pre-flight's expiry avoidance window can trigger. */
261
+ export const CREDENTIAL_WARMING_WINDOW_MULTIPLIER = 2;
262
+
263
+ export function credentialWarmingThresholdMs(
264
+ config: Pick<MultiAccountConfig, "preemptiveExpiryWindowMs">,
265
+ ): number {
266
+ return config.preemptiveExpiryWindowMs * CREDENTIAL_WARMING_WINDOW_MULTIPLIER;
267
+ }
268
+
269
+ export const DEFAULT_CONFIG: MultiAccountConfig = {
270
+ accountLimit: 4,
271
+ sameFamilyFailover: true,
272
+ crossFamilyChainEnabled: false,
273
+ crossFamilyChains: [],
274
+ watchdogIntervalMs: 30_000,
275
+ cooldownMaxMs: 300_000,
276
+ recoveryIdleTimeoutMs: 5 * 60_000,
277
+ recoveryAbsoluteTimeoutMs: 30 * 60_000,
278
+ accountLabels: {},
279
+ projectLabels: {},
280
+ accountGroups: {},
281
+ accountGroupCwdDefaults: {},
282
+ monthlySubscriptionUsd: {},
283
+ subscriptionPlanCatalogOverrides: {},
284
+ accountRateHistory: {},
285
+ preferredModels: {},
286
+ tierModelMap: Object.freeze({}),
287
+ // Comfortably longer than a turn, short enough that accounts are not retired
288
+ // while they still have useful life.
289
+ preemptiveExpiryWindowMs: 120_000,
290
+ usageFetchEnabled: DEFAULT_USAGE_FETCH_ENABLED,
291
+ };
292
+
293
+ const CONFIG_KEYS = new Set<keyof MultiAccountConfig>([
294
+ "accountLimit",
295
+ "sameFamilyFailover",
296
+ "crossFamilyChainEnabled",
297
+ "crossFamilyChains",
298
+ "watchdogIntervalMs",
299
+ "cooldownMaxMs",
300
+ "recoveryIdleTimeoutMs",
301
+ "recoveryAbsoluteTimeoutMs",
302
+ "accountLabels",
303
+ "projectLabels",
304
+ "accountGroups",
305
+ "accountGroupCwdDefaults",
306
+ "defaultAccountGroup",
307
+ "monthlySubscriptionUsd",
308
+ "subscriptionPlanCatalogOverrides",
309
+ "accountRateHistory",
310
+ "preferredModels",
311
+ "tierModelMap",
312
+ "preemptiveExpiryWindowMs",
313
+ "usageFetchEnabled",
314
+ ]);
315
+
316
+ /**
317
+ * Validates the optional `accountLabels` map. Rejects non-object containers and
318
+ * non-string entries so a malformed config fails closed at load rather than
319
+ * surfacing a broken label at render time.
320
+ */
321
+ function parseUsageFetchEnabled(
322
+ value: unknown,
323
+ ): Readonly<Record<AllowedFamily, boolean>> {
324
+ if (value === undefined) return DEFAULT_USAGE_FETCH_ENABLED;
325
+ if (!isRecord(value)) {
326
+ throw new ConfigValidationError("usageFetchEnabled must be a JSON object.");
327
+ }
328
+ const unknownKeys = Object.keys(value).filter(
329
+ (key) => !isAllowedFamily(key),
330
+ );
331
+ if (unknownKeys.length > 0) {
332
+ throw new ConfigValidationError(
333
+ `usageFetchEnabled has unsupported family "${unknownKeys[0]}".`,
334
+ );
335
+ }
336
+ const anthropic = value["anthropic"];
337
+ if (typeof anthropic !== "boolean") {
338
+ throw new ConfigValidationError(
339
+ "usageFetchEnabled.anthropic must be a boolean.",
340
+ );
341
+ }
342
+ const openaiCodex = value["openai-codex"];
343
+ if (typeof openaiCodex !== "boolean") {
344
+ throw new ConfigValidationError(
345
+ "usageFetchEnabled.openai-codex must be a boolean.",
346
+ );
347
+ }
348
+ const googleAntigravity = value["google-antigravity"];
349
+ if (
350
+ googleAntigravity !== undefined &&
351
+ typeof googleAntigravity !== "boolean"
352
+ ) {
353
+ throw new ConfigValidationError(
354
+ "usageFetchEnabled.google-antigravity must be a boolean.",
355
+ );
356
+ }
357
+ return Object.freeze({
358
+ anthropic,
359
+ "openai-codex": openaiCodex,
360
+ "google-antigravity": googleAntigravity ?? true,
361
+ });
362
+ }
363
+
364
+ function parseAccountLabels(value: unknown): Readonly<Record<string, string>> {
365
+ if (value === undefined) return DEFAULT_CONFIG.accountLabels;
366
+ if (!isRecord(value)) {
367
+ throw new ConfigValidationError("accountLabels must be a JSON object.");
368
+ }
369
+ const labels: Record<string, string> = {};
370
+ for (const [providerId, label] of Object.entries(value)) {
371
+ const slotIndex = configuredManagedProviderSlotIndex(providerId);
372
+ if (slotIndex !== null && slotIndex > MAX_ACCOUNT_LIMIT) {
373
+ throw new ConfigValidationError(ACCOUNT_LABEL_LIMIT_ERROR);
374
+ }
375
+ if (typeof label !== "string") {
376
+ throw new ConfigValidationError(
377
+ `accountLabels.${providerId} must be a string.`,
378
+ );
379
+ }
380
+ labels[providerId] = label;
381
+ }
382
+ return Object.freeze(labels);
383
+ }
384
+
385
+ const MAX_PROJECT_LABEL_LENGTH = 128;
386
+
387
+ function parseProjectLabels(value: unknown): Readonly<Record<string, string>> {
388
+ if (value === undefined) return DEFAULT_CONFIG.projectLabels;
389
+ if (!isRecord(value)) {
390
+ throw new ConfigValidationError("projectLabels must be a JSON object.");
391
+ }
392
+ const labels: Record<string, string> = {};
393
+ for (const [projectKey, label] of Object.entries(value)) {
394
+ if (!PROJECT_KEY_PATTERN.test(projectKey)) {
395
+ throw new ConfigValidationError(
396
+ `projectLabels.${projectKey} must use a bounded project digest key.`,
397
+ );
398
+ }
399
+ if (
400
+ typeof label !== "string" ||
401
+ label.trim().length === 0 ||
402
+ label.length > MAX_PROJECT_LABEL_LENGTH
403
+ ) {
404
+ throw new ConfigValidationError(
405
+ `projectLabels.${projectKey} must be a non-empty string of at most ${MAX_PROJECT_LABEL_LENGTH} characters.`,
406
+ );
407
+ }
408
+ labels[projectKey] = label;
409
+ }
410
+ return Object.freeze(labels);
411
+ }
412
+
413
+ /**
414
+ * The numbered slot an operator-supplied config key names, or `null` when the
415
+ * key is not a canonical managed provider id in `families`. The base family id
416
+ * maps to slot `1`; `${family}-account-${decimal}` maps to its safe integer
417
+ * slot only when the decimal is at least `2`, carries no sign, fraction,
418
+ * exponent, whitespace, or leading zero, and round-trips through `String(slot)`;
419
+ * every unknown family and other string maps to `null`. It normalizes nothing
420
+ * and performs no I/O.
421
+ *
422
+ * The `families` parameter is the predicate split: `parseAccountLabels()` passes
423
+ * the managed set (an `openai` label key is valid), while
424
+ * `parseMonthlySubscriptionUsd()` passes the subscription set (an `openai` id
425
+ * must be rejected — an owning-vendor-API family is not a subscription-cost key).
426
+ */
427
+ function configuredProviderSlotIndex(
428
+ providerId: string,
429
+ families: readonly string[],
430
+ ): number | null {
431
+ for (const family of families) {
432
+ if (providerId === family) return 1;
433
+ const prefix = `${family}-account-`;
434
+ if (!providerId.startsWith(prefix)) continue;
435
+ const suffix = providerId.slice(prefix.length);
436
+ const slot = Number(suffix);
437
+ return Number.isSafeInteger(slot) && slot >= 2 && String(slot) === suffix
438
+ ? slot
439
+ : null;
440
+ }
441
+ return null;
442
+ }
443
+
444
+ /** Managed-scoped slot recognizer: accepts `openai` keys (labels). */
445
+ function configuredManagedProviderSlotIndex(providerId: string): number | null {
446
+ return configuredProviderSlotIndex(providerId, MANAGED_FAMILIES);
447
+ }
448
+
449
+ /** Subscription-scoped slot recognizer: rejects `openai` keys (monthly cost). */
450
+ function configuredSubscriptionProviderSlotIndex(
451
+ providerId: string,
452
+ ): number | null {
453
+ return configuredProviderSlotIndex(providerId, ALLOWED_FAMILIES);
454
+ }
455
+
456
+ /**
457
+ * True when `accountId` is the canonical id of a subscription-scoped managed
458
+ * account slot -- an {@link ALLOWED_FAMILIES} member, base or numbered --
459
+ * within the current `accountLimit`. Reuses the exact same slot recognizer
460
+ * `parseMonthlySubscriptionUsd()` and `parseAccountRateHistory()` apply, so
461
+ * "is a configured account" means one thing across every config-shape
462
+ * validator and the `account set-plan` CLI surface that must agree with it.
463
+ */
464
+ export function isCanonicalSubscriptionAccountId(
465
+ accountId: string,
466
+ accountLimit: number,
467
+ ): boolean {
468
+ if (!isAccountLimit(accountLimit)) return false;
469
+ const slotIndex = configuredSubscriptionProviderSlotIndex(accountId);
470
+ return slotIndex !== null && slotIndex <= accountLimit;
471
+ }
472
+
473
+ /** Fixed, bounded, key-free rejection for an over-limit canonical label key. */
474
+ export const ACCOUNT_LABEL_LIMIT_ERROR =
475
+ "accountLabels contains a canonical managed provider id above slot 32.";
476
+ /** Fixed, bounded, key-free rejection for an over-limit canonical subscription key. */
477
+ export const SUBSCRIPTION_LIMIT_ERROR =
478
+ "monthlySubscriptionUsd contains a canonical managed provider id above slot 32.";
479
+
480
+ function parseMonthlySubscriptionUsd(
481
+ value: unknown,
482
+ ): Readonly<Record<string, number>> {
483
+ if (value === undefined) return DEFAULT_CONFIG.monthlySubscriptionUsd;
484
+ if (!isRecord(value)) {
485
+ throw new ConfigValidationError(
486
+ "monthlySubscriptionUsd must be a JSON object.",
487
+ );
488
+ }
489
+ const costs: Record<string, number> = {};
490
+ for (const [providerId, monthlyCost] of Object.entries(value)) {
491
+ const slotIndex = configuredSubscriptionProviderSlotIndex(providerId);
492
+ if (slotIndex === null) {
493
+ throw new ConfigValidationError(
494
+ `monthlySubscriptionUsd.${providerId} is not a canonical managed provider id.`,
495
+ );
496
+ }
497
+ if (slotIndex > MAX_ACCOUNT_LIMIT) {
498
+ throw new ConfigValidationError(SUBSCRIPTION_LIMIT_ERROR);
499
+ }
500
+ if (
501
+ typeof monthlyCost !== "number" ||
502
+ !Number.isFinite(monthlyCost) ||
503
+ monthlyCost <= 0
504
+ ) {
505
+ throw new ConfigValidationError(
506
+ `monthlySubscriptionUsd.${providerId} must be a finite positive USD amount.`,
507
+ );
508
+ }
509
+ costs[providerId] = monthlyCost;
510
+ }
511
+ return Object.freeze(costs);
512
+ }
513
+
514
+ const MAX_SUBSCRIPTION_PLAN_OVERRIDE_ID_LENGTH = 64;
515
+
516
+ /**
517
+ * Validates the optional `subscriptionPlanCatalogOverrides` map: preset id ->
518
+ * override fields. Reuses the catalog module's own field-level validation so
519
+ * there is exactly one definition of a valid preset entry; wraps its errors
520
+ * as `ConfigValidationError` so every config-schema failure shares one error
521
+ * class. There is no project-local catalog layer -- this is the only place
522
+ * subscription-plan overrides are read.
523
+ */
524
+ function parseSubscriptionPlanCatalogOverrides(
525
+ value: unknown,
526
+ ): Readonly<Record<string, SubscriptionPlanCatalogOverride>> {
527
+ if (value === undefined) {
528
+ return DEFAULT_CONFIG.subscriptionPlanCatalogOverrides ?? {};
529
+ }
530
+ if (!isRecord(value)) {
531
+ throw new ConfigValidationError(
532
+ "subscriptionPlanCatalogOverrides must be a JSON object.",
533
+ );
534
+ }
535
+ const overrides: Record<string, SubscriptionPlanCatalogOverride> = {};
536
+ for (const [presetId, rawOverride] of Object.entries(value)) {
537
+ if (
538
+ presetId.length === 0 ||
539
+ presetId.length > MAX_SUBSCRIPTION_PLAN_OVERRIDE_ID_LENGTH ||
540
+ !PRESET_ID_PATTERN.test(presetId)
541
+ ) {
542
+ throw new ConfigValidationError(
543
+ `subscriptionPlanCatalogOverrides has an invalid preset id "${presetId}".`,
544
+ );
545
+ }
546
+ try {
547
+ overrides[presetId] = parseSubscriptionPlanCatalogOverride(
548
+ rawOverride,
549
+ `subscriptionPlanCatalogOverrides.${presetId}`,
550
+ );
551
+ } catch (error) {
552
+ if (error instanceof SubscriptionPlanCatalogError) {
553
+ throw new ConfigValidationError(error.message);
554
+ }
555
+ throw error;
556
+ }
557
+ }
558
+ return Object.freeze(overrides);
559
+ }
560
+
561
+ /**
562
+ * Validates the optional `accountRateHistory` map: canonical accountId ->
563
+ * an array of already-selected rate records. Each key must be the same
564
+ * canonical managed provider id shape used by `monthlySubscriptionUsd`, and
565
+ * each record's own `accountId` field must equal its map key. Reuses the
566
+ * account-rate-history module's own record and ordering validation, wrapping
567
+ * its errors as `ConfigValidationError`.
568
+ */
569
+ function parseAccountRateHistory(
570
+ value: unknown,
571
+ ): Readonly<Record<string, readonly AccountRateRecord[]>> {
572
+ if (value === undefined) return DEFAULT_CONFIG.accountRateHistory ?? {};
573
+ if (!isRecord(value)) {
574
+ throw new ConfigValidationError("accountRateHistory must be a JSON object.");
575
+ }
576
+ const history: Record<string, readonly AccountRateRecord[]> = {};
577
+ try {
578
+ for (const [accountId, rawRecords] of Object.entries(value)) {
579
+ const slotIndex = configuredSubscriptionProviderSlotIndex(accountId);
580
+ if (slotIndex === null) {
581
+ throw new ConfigValidationError(
582
+ `accountRateHistory.${accountId} is not a canonical managed provider id.`,
583
+ );
584
+ }
585
+ if (slotIndex > MAX_ACCOUNT_LIMIT) {
586
+ throw new ConfigValidationError(SUBSCRIPTION_LIMIT_ERROR);
587
+ }
588
+ if (!Array.isArray(rawRecords)) {
589
+ throw new ConfigValidationError(
590
+ `accountRateHistory.${accountId} must be an array of rate records.`,
591
+ );
592
+ }
593
+ const candidates = rawRecords.map((rawRecord, index) => {
594
+ const record = parseAccountRateRecord(
595
+ rawRecord,
596
+ `accountRateHistory.${accountId}[${index}]`,
597
+ );
598
+ if (record.accountId !== accountId) {
599
+ throw new ConfigValidationError(
600
+ `accountRateHistory.${accountId}[${index}].accountId must equal "${accountId}".`,
601
+ );
602
+ }
603
+ return { record };
604
+ });
605
+ history[accountId] = normalizeAccountRateHistory(candidates);
606
+ }
607
+ } catch (error) {
608
+ if (error instanceof AccountRateHistoryError) {
609
+ throw new ConfigValidationError(error.message);
610
+ }
611
+ throw error;
612
+ }
613
+ return Object.freeze(history);
614
+ }
615
+
616
+ /**
617
+ * Validates the optional `preferredModels` map: family -> ordered model ids.
618
+ *
619
+ * Strict, because a config error here breaks every agent on this machine at
620
+ * startup. An unknown family is rejected rather than ignored: silently dropping
621
+ * a typo'd key would leave the operator believing a preference is in force when
622
+ * a catalog head is being shipped instead, which is precisely the failure this
623
+ * config exists to prevent.
624
+ */
625
+ function parsePreferredModels(
626
+ value: unknown,
627
+ ): Readonly<Record<string, readonly string[]>> {
628
+ if (value === undefined) return DEFAULT_CONFIG.preferredModels;
629
+ if (!isRecord(value)) {
630
+ throw new ConfigValidationError("preferredModels must be a JSON object.");
631
+ }
632
+ const preferred: Record<string, readonly string[]> = {};
633
+ for (const [family, models] of Object.entries(value)) {
634
+ if (!isAllowedFamily(family)) {
635
+ throw new ConfigValidationError(
636
+ `preferredModels.${family} is not a managed family.`,
637
+ );
638
+ }
639
+ if (!Array.isArray(models)) {
640
+ throw new ConfigValidationError(
641
+ `preferredModels.${family} must be an array of model ids.`,
642
+ );
643
+ }
644
+ for (const modelId of models) {
645
+ if (typeof modelId !== "string" || modelId.length === 0) {
646
+ throw new ConfigValidationError(
647
+ `preferredModels.${family} entries must be non-empty strings.`,
648
+ );
649
+ }
650
+ }
651
+ preferred[family] = Object.freeze([...(models as readonly string[])]);
652
+ }
653
+ return Object.freeze(preferred);
654
+ }
655
+
656
+ const TIER_MODEL_DESTINATIONS: readonly TierModelDestination[] = [
657
+ "anthropic",
658
+ "openai",
659
+ "openrouter",
660
+ ];
661
+ const MAX_TIER_MODEL_ENTRIES = 256;
662
+ const TIER_MODEL_CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u;
663
+
664
+ function isTierModelDestination(
665
+ value: string,
666
+ ): value is TierModelDestination {
667
+ return TIER_MODEL_DESTINATIONS.includes(value as TierModelDestination);
668
+ }
669
+
670
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
671
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
672
+ return false;
673
+ }
674
+ try {
675
+ const prototype = Object.getPrototypeOf(value) as unknown;
676
+ return prototype === Object.prototype || prototype === null;
677
+ } catch {
678
+ return false;
679
+ }
680
+ }
681
+
682
+ function isValidTierModelId(value: unknown): value is string {
683
+ return (
684
+ typeof value === "string" &&
685
+ value.length > 0 &&
686
+ value.length <= MAX_TIER_MODEL_ID_LENGTH &&
687
+ value.trim().length > 0 &&
688
+ !TIER_MODEL_CONTROL_CHARACTER.test(value)
689
+ );
690
+ }
691
+
692
+ export function parseTierModelMap(value: unknown): TierModelMap {
693
+ if (value === undefined) return DEFAULT_CONFIG.tierModelMap;
694
+ if (!isRecord(value)) {
695
+ throw new ConfigValidationError("tierModelMap must be a JSON object.");
696
+ }
697
+
698
+ const destinations: Array<{
699
+ readonly destination: TierModelDestination;
700
+ readonly entries: readonly (readonly [string, unknown])[];
701
+ }> = [];
702
+ let totalEntries = 0;
703
+ for (const [destination, destinationValue] of Object.entries(value)) {
704
+ if (!isTierModelDestination(destination)) {
705
+ throw new ConfigValidationError(
706
+ `tierModelMap has unsupported destination "${destination}".`,
707
+ );
708
+ }
709
+ if (!isPlainObject(destinationValue)) {
710
+ throw new ConfigValidationError(
711
+ `tierModelMap.${destination} must be a plain object.`,
712
+ );
713
+ }
714
+ const entries = Object.entries(destinationValue);
715
+ totalEntries += entries.length;
716
+ if (totalEntries > MAX_TIER_MODEL_ENTRIES) {
717
+ throw new ConfigValidationError(
718
+ `tierModelMap must contain at most ${MAX_TIER_MODEL_ENTRIES} entries.`,
719
+ );
720
+ }
721
+ destinations.push({ destination, entries });
722
+ }
723
+
724
+ const projected: Partial<
725
+ Record<TierModelDestination, Readonly<Record<string, string>>>
726
+ > = {};
727
+ for (const { destination, entries } of destinations) {
728
+ // A null-prototype object so a source id of `__proto__` (a valid
729
+ // model-id string) creates a real own entry instead of invoking the
730
+ // legacy prototype setter and being silently dropped, and so the
731
+ // resolver's later `map[dest][requested]` read cannot return an inherited
732
+ // `Object.prototype` member (e.g. `constructor`, `toString`).
733
+ const destinationProjection: Record<string, string> = Object.create(
734
+ null,
735
+ ) as Record<string, string>;
736
+ for (const [sourceId, destinationId] of entries) {
737
+ if (sourceId === "*") {
738
+ throw new ConfigValidationError(
739
+ `tierModelMap.${destination} cannot use the reserved source key "*".`,
740
+ );
741
+ }
742
+ if (!isValidTierModelId(sourceId)) {
743
+ throw new ConfigValidationError(
744
+ `tierModelMap.${destination} source ids must be non-empty strings without control characters and at most ${MAX_TIER_MODEL_ID_LENGTH} characters.`,
745
+ );
746
+ }
747
+ if (!isValidTierModelId(destinationId)) {
748
+ throw new ConfigValidationError(
749
+ `tierModelMap.${destination} destination ids must be non-empty strings without control characters and at most ${MAX_TIER_MODEL_ID_LENGTH} characters.`,
750
+ );
751
+ }
752
+ destinationProjection[sourceId] = destinationId;
753
+ }
754
+ projected[destination] = Object.freeze(destinationProjection);
755
+ }
756
+ return Object.freeze(projected);
757
+ }
758
+
759
+ /**
760
+ * The currently permitted cross-family pairs: both Anthropic↔Codex
761
+ * directions, and all four Antigravity directions (into and out of each of
762
+ * its two managed partners, Anthropic and Codex). Every direction is its own
763
+ * explicit tuple; authorizing one direction never authorizes its reverse, so
764
+ * `anthropic` → `google-antigravity` requires its own entry independent of
765
+ * `google-antigravity` → `anthropic`, and likewise for the Codex↔Antigravity
766
+ * pair.
767
+ */
768
+ export const ALLOWED_CROSS_FAMILY_PAIRS: ReadonlyArray<
769
+ readonly [AllowedFamily, AllowedFamily]
770
+ > = [
771
+ ["anthropic", "openai-codex"],
772
+ ["openai-codex", "anthropic"],
773
+ ["google-antigravity", "anthropic"],
774
+ ["anthropic", "google-antigravity"],
775
+ ["google-antigravity", "openai-codex"],
776
+ ["openai-codex", "google-antigravity"],
777
+ ];
778
+
779
+ export class ConfigValidationError extends Error {
780
+ constructor(message: string) {
781
+ super(`[multi-account config] ${message}`);
782
+ this.name = "ConfigValidationError";
783
+ }
784
+ }
785
+
786
+ function isRecord(value: unknown): value is Record<string, unknown> {
787
+ return typeof value === "object" && value !== null && !Array.isArray(value);
788
+ }
789
+
790
+ export const ACCOUNT_GROUP_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
791
+
792
+ export function isAccountGroupId(value: unknown): value is string {
793
+ return typeof value === "string" && ACCOUNT_GROUP_ID_PATTERN.test(value);
794
+ }
795
+
796
+ function parseAccountGroups(
797
+ value: unknown,
798
+ accountLimit: number,
799
+ ): Readonly<Record<string, readonly string[]>> {
800
+ if (value === undefined) return DEFAULT_CONFIG.accountGroups ?? {};
801
+ if (!isRecord(value)) {
802
+ throw new ConfigValidationError("accountGroups must be a JSON object.");
803
+ }
804
+ const groups = Object.create(null) as Record<string, readonly string[]>;
805
+ for (const [groupId, members] of Object.entries(value)) {
806
+ if (!isAccountGroupId(groupId)) {
807
+ throw new ConfigValidationError(
808
+ "accountGroups group ids must use 1 through 64 letters, digits, dots, underscores, or hyphens.",
809
+ );
810
+ }
811
+ if (!Array.isArray(members)) {
812
+ throw new ConfigValidationError(`accountGroups.${groupId} must be an array.`);
813
+ }
814
+ const providerIds = members.map((member, index) => {
815
+ if (
816
+ typeof member !== "string" ||
817
+ !isCanonicalSubscriptionAccountId(member, accountLimit)
818
+ ) {
819
+ throw new ConfigValidationError(
820
+ `accountGroups.${groupId}[${index}] must be a canonical managed subscription provider id within accountLimit.`,
821
+ );
822
+ }
823
+ return member;
824
+ });
825
+ groups[groupId] = Object.freeze(providerIds);
826
+ }
827
+ return Object.freeze(groups);
828
+ }
829
+
830
+ function parseAccountGroupCwdDefaults(
831
+ value: unknown,
832
+ groups: Readonly<Record<string, readonly string[]>>,
833
+ ): Readonly<Record<string, string>> {
834
+ if (value === undefined) return DEFAULT_CONFIG.accountGroupCwdDefaults ?? {};
835
+ if (!isRecord(value)) {
836
+ throw new ConfigValidationError(
837
+ "accountGroupCwdDefaults must be a JSON object.",
838
+ );
839
+ }
840
+ const defaults = Object.create(null) as Record<string, string>;
841
+ for (const [cwd, groupId] of Object.entries(value)) {
842
+ if (!isAbsolute(cwd) || cwd.includes("\u0000")) {
843
+ throw new ConfigValidationError(
844
+ "accountGroupCwdDefaults keys must be absolute directory paths.",
845
+ );
846
+ }
847
+ if (typeof groupId !== "string" || !Object.hasOwn(groups, groupId)) {
848
+ throw new ConfigValidationError(
849
+ "accountGroupCwdDefaults values must name a configured accountGroups entry.",
850
+ );
851
+ }
852
+ defaults[cwd] = groupId;
853
+ }
854
+ return Object.freeze(defaults);
855
+ }
856
+
857
+ function parseDefaultAccountGroup(
858
+ value: unknown,
859
+ groups: Readonly<Record<string, readonly string[]>>,
860
+ ): string | undefined {
861
+ if (value === undefined) return undefined;
862
+ if (typeof value !== "string" || !Object.hasOwn(groups, value)) {
863
+ throw new ConfigValidationError(
864
+ "defaultAccountGroup must name a configured accountGroups entry.",
865
+ );
866
+ }
867
+ return value;
868
+ }
869
+
870
+ export function isAllowedFamily(family: string): family is AllowedFamily {
871
+ return (ALLOWED_FAMILIES as readonly string[]).includes(family);
872
+ }
873
+
874
+ /**
875
+ * Broad managed-family predicate: the subscription families plus the
876
+ * owning-vendor-API `openai` family. Use for canonical identity, discovery,
877
+ * registration, completion, and the account listing — anywhere an `openai`
878
+ * account must be recognized. Subscription-tier checks (proactive routing, the
879
+ * v1 resolver, usage config, subscription cost, cross-family chains, and the
880
+ * logical provider's subscription-tier usage attribution) keep using
881
+ * {@link isAllowedFamily}; the logical provider's routing pool itself also
882
+ * admits owning-vendor-API accounts.
883
+ */
884
+ export function isManagedFamily(family: string): family is ManagedFamily {
885
+ return (MANAGED_FAMILIES as readonly string[]).includes(family);
886
+ }
887
+
888
+ export function assertFamilyAllowed(
889
+ family: string,
890
+ ): asserts family is AllowedFamily {
891
+ if (!isAllowedFamily(family)) {
892
+ const hint = (KNOWN_REJECTED_FAMILIES as readonly string[]).includes(family)
893
+ ? ` (${family} is explicitly excluded from the route graph)`
894
+ : "";
895
+ throw new ConfigValidationError(
896
+ `Family "${family}" is not in the allowed set [${ALLOWED_FAMILIES.join(", ")}]${hint}. ` +
897
+ "Only managed subscription families may appear in the effective route graph.",
898
+ );
899
+ }
900
+ }
901
+
902
+ export function validateCrossChain(chain: CrossFamilyChain): void {
903
+ assertFamilyAllowed(chain.from);
904
+ assertFamilyAllowed(chain.to);
905
+ if (chain.from === chain.to) {
906
+ throw new ConfigValidationError(
907
+ `Cross-family chain from "${chain.from}" to itself is not permitted.`,
908
+ );
909
+ }
910
+ const allowed = ALLOWED_CROSS_FAMILY_PAIRS.some(
911
+ ([from, to]) => from === chain.from && to === chain.to,
912
+ );
913
+ if (!allowed) {
914
+ throw new ConfigValidationError(
915
+ `Cross-family chain from "${chain.from}" to "${chain.to}" is not permitted. ` +
916
+ "Each cross-family direction requires its own explicit pair in ALLOWED_CROSS_FAMILY_PAIRS.",
917
+ );
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Collapses duplicate directional edges, keeping the FIRST occurrence.
923
+ *
924
+ * The base parser has always accepted duplicates and routing already treats
925
+ * them as one membership fact (`crossFamilyChains.some(...)`). Rejecting them
926
+ * would make a machine-global file that worked yesterday fail extension
927
+ * initialization closed today, which is a worse outcome than a redundant entry.
928
+ * First-seen order is kept because the stored order is operator-authored, even
929
+ * though it does not express runtime precedence.
930
+ */
931
+ export function normalizeCrossFamilyChains(
932
+ chains: readonly CrossFamilyChain[],
933
+ ): readonly CrossFamilyChain[] {
934
+ const seen = new Set<string>();
935
+ const normalized: CrossFamilyChain[] = [];
936
+ for (const chain of chains) {
937
+ const key = `${chain.from}\u0000${chain.to}`;
938
+ if (seen.has(key)) continue;
939
+ seen.add(key);
940
+ normalized.push(chain);
941
+ }
942
+ return normalized;
943
+ }
944
+
945
+ function parseCrossFamilyChains(value: unknown): readonly CrossFamilyChain[] {
946
+ if (!Array.isArray(value)) {
947
+ throw new ConfigValidationError("crossFamilyChains must be an array.");
948
+ }
949
+
950
+ const parsed = value.map((candidate, index) => {
951
+ if (!isRecord(candidate)) {
952
+ throw new ConfigValidationError(
953
+ `crossFamilyChains[${index}] must be an object.`,
954
+ );
955
+ }
956
+ const unknownKeys = Object.keys(candidate).filter(
957
+ (key) => key !== "from" && key !== "to",
958
+ );
959
+ if (unknownKeys.length > 0) {
960
+ throw new ConfigValidationError(
961
+ `crossFamilyChains[${index}] has unsupported field "${unknownKeys[0]}".`,
962
+ );
963
+ }
964
+ if (
965
+ typeof candidate["from"] !== "string" ||
966
+ typeof candidate["to"] !== "string"
967
+ ) {
968
+ throw new ConfigValidationError(
969
+ `crossFamilyChains[${index}] must contain string from/to fields.`,
970
+ );
971
+ }
972
+ assertFamilyAllowed(candidate["from"]);
973
+ assertFamilyAllowed(candidate["to"]);
974
+ const chain: CrossFamilyChain = {
975
+ from: candidate["from"],
976
+ to: candidate["to"],
977
+ };
978
+ validateCrossChain(chain);
979
+ return chain;
980
+ });
981
+ return normalizeCrossFamilyChains(parsed);
982
+ }
983
+
984
+ /**
985
+ * The config fields the interactive configure flow may change, and the only
986
+ * fields it may publish into a running process.
987
+ *
988
+ * Everything else read from disk during a routing edit stays on disk: applying
989
+ * it to the live closure would change account, usage, or label behavior without
990
+ * the rediscovery that `/multi-account reload` performs.
991
+ */
992
+ export const ROUTING_CONFIG_FIELDS = Object.freeze([
993
+ "crossFamilyChainEnabled",
994
+ "crossFamilyChains",
995
+ "preferredModels",
996
+ "tierModelMap",
997
+ ] as const);
998
+
999
+ export type RoutingConfigField = (typeof ROUTING_CONFIG_FIELDS)[number];
1000
+
1001
+ export interface RoutingProjection {
1002
+ readonly crossFamilyChainEnabled: boolean;
1003
+ readonly crossFamilyChains: readonly CrossFamilyChain[];
1004
+ readonly preferredModels: Readonly<Record<string, readonly string[]>>;
1005
+ readonly tierModelMap: TierModelMap;
1006
+ }
1007
+
1008
+ export type NonRoutingProjection = Omit<MultiAccountConfig, RoutingConfigField>;
1009
+
1010
+ export function routingProjection(
1011
+ config: Pick<MultiAccountConfig, RoutingConfigField>,
1012
+ ): RoutingProjection {
1013
+ return {
1014
+ crossFamilyChainEnabled: config.crossFamilyChainEnabled,
1015
+ crossFamilyChains: normalizeCrossFamilyChains(config.crossFamilyChains),
1016
+ preferredModels: config.preferredModels,
1017
+ tierModelMap: config.tierModelMap,
1018
+ };
1019
+ }
1020
+
1021
+ export function nonRoutingProjection(
1022
+ config: MultiAccountConfig,
1023
+ ): NonRoutingProjection {
1024
+ const {
1025
+ crossFamilyChainEnabled: _enabled,
1026
+ crossFamilyChains: _chains,
1027
+ preferredModels: _preferred,
1028
+ tierModelMap: _tierModelMap,
1029
+ ...rest
1030
+ } = config;
1031
+ return rest;
1032
+ }
1033
+
1034
+ /**
1035
+ * Order-insensitive canonical form for comparison only.
1036
+ *
1037
+ * Object key order is a JSON accident: two configs whose `preferredModels` keys
1038
+ * were written in a different order describe the same policy. Array order is
1039
+ * NOT normalized, because best-first model order and stored edge order are
1040
+ * operator intent.
1041
+ */
1042
+ function canonical(value: unknown): unknown {
1043
+ if (Array.isArray(value)) return value.map(canonical);
1044
+ if (typeof value === "object" && value !== null) {
1045
+ return Object.entries(value as Record<string, unknown>)
1046
+ .filter(([, nested]) => nested !== undefined)
1047
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
1048
+ .map(([key, nested]) => [key, canonical(nested)]);
1049
+ }
1050
+ return value;
1051
+ }
1052
+
1053
+ function canonicalJson(value: unknown): string {
1054
+ return JSON.stringify(canonical(value));
1055
+ }
1056
+
1057
+ export function routingProjectionsEqual(
1058
+ left: RoutingProjection,
1059
+ right: RoutingProjection,
1060
+ ): boolean {
1061
+ return (
1062
+ canonicalJson(routingProjection(left)) ===
1063
+ canonicalJson(routingProjection(right))
1064
+ );
1065
+ }
1066
+
1067
+ export function nonRoutingProjectionsEqual(
1068
+ left: MultiAccountConfig,
1069
+ right: MultiAccountConfig,
1070
+ ): boolean {
1071
+ return (
1072
+ canonicalJson(nonRoutingProjection(left)) ===
1073
+ canonicalJson(nonRoutingProjection(right))
1074
+ );
1075
+ }
1076
+
1077
+ /**
1078
+ * Parses unknown machine-global JSON, materializes omitted fields from the
1079
+ * fresh defaults, and rejects malformed or unknown fields before they can enter
1080
+ * the effective route graph.
1081
+ */
1082
+ export function parseConfig(value: unknown): MultiAccountConfig {
1083
+ if (!isRecord(value)) {
1084
+ throw new ConfigValidationError("Config must be a JSON object.");
1085
+ }
1086
+
1087
+ const unknownKeys = Object.keys(value).filter(
1088
+ (key) => !CONFIG_KEYS.has(key as keyof MultiAccountConfig),
1089
+ );
1090
+ if (unknownKeys.length > 0) {
1091
+ throw new ConfigValidationError(
1092
+ `Unsupported config field "${unknownKeys[0]}".`,
1093
+ );
1094
+ }
1095
+
1096
+ const accountLimit = value["accountLimit"] ?? DEFAULT_CONFIG.accountLimit;
1097
+ const sameFamilyFailover =
1098
+ value["sameFamilyFailover"] ?? DEFAULT_CONFIG.sameFamilyFailover;
1099
+ const crossFamilyChainEnabled =
1100
+ value["crossFamilyChainEnabled"] ?? DEFAULT_CONFIG.crossFamilyChainEnabled;
1101
+ const crossFamilyChains = parseCrossFamilyChains(
1102
+ value["crossFamilyChains"] ?? DEFAULT_CONFIG.crossFamilyChains,
1103
+ );
1104
+ const watchdogIntervalMs =
1105
+ value["watchdogIntervalMs"] ?? DEFAULT_CONFIG.watchdogIntervalMs;
1106
+ const cooldownMaxMs = value["cooldownMaxMs"] ?? DEFAULT_CONFIG.cooldownMaxMs;
1107
+ const recoveryIdleTimeoutMs =
1108
+ value["recoveryIdleTimeoutMs"] ?? DEFAULT_CONFIG.recoveryIdleTimeoutMs;
1109
+ const recoveryAbsoluteTimeoutMs =
1110
+ value["recoveryAbsoluteTimeoutMs"] ?? DEFAULT_CONFIG.recoveryAbsoluteTimeoutMs;
1111
+ const accountLabels = parseAccountLabels(value["accountLabels"]);
1112
+ const projectLabels = parseProjectLabels(value["projectLabels"]);
1113
+ const monthlySubscriptionUsd = parseMonthlySubscriptionUsd(
1114
+ value["monthlySubscriptionUsd"],
1115
+ );
1116
+ const subscriptionPlanCatalogOverrides = parseSubscriptionPlanCatalogOverrides(
1117
+ value["subscriptionPlanCatalogOverrides"],
1118
+ );
1119
+ const accountRateHistory = parseAccountRateHistory(value["accountRateHistory"]);
1120
+ const preferredModels = parsePreferredModels(value["preferredModels"]);
1121
+ const tierModelMap = parseTierModelMap(value["tierModelMap"]);
1122
+ const preemptiveExpiryWindowMs =
1123
+ value["preemptiveExpiryWindowMs"] ??
1124
+ DEFAULT_CONFIG.preemptiveExpiryWindowMs;
1125
+ const usageFetchEnabled = parseUsageFetchEnabled(value["usageFetchEnabled"]);
1126
+
1127
+ if (!isAccountLimit(accountLimit)) {
1128
+ throw new ConfigValidationError(
1129
+ `accountLimit must be an integer from 1 through ${MAX_ACCOUNT_LIMIT}.`,
1130
+ );
1131
+ }
1132
+ const accountGroups = parseAccountGroups(value["accountGroups"], accountLimit);
1133
+ const accountGroupCwdDefaults = parseAccountGroupCwdDefaults(
1134
+ value["accountGroupCwdDefaults"],
1135
+ accountGroups,
1136
+ );
1137
+ const defaultAccountGroup = parseDefaultAccountGroup(
1138
+ value["defaultAccountGroup"],
1139
+ accountGroups,
1140
+ );
1141
+ if (typeof sameFamilyFailover !== "boolean") {
1142
+ throw new ConfigValidationError("sameFamilyFailover must be a boolean.");
1143
+ }
1144
+ if (typeof crossFamilyChainEnabled !== "boolean") {
1145
+ throw new ConfigValidationError(
1146
+ "crossFamilyChainEnabled must be a boolean.",
1147
+ );
1148
+ }
1149
+ if (
1150
+ typeof watchdogIntervalMs !== "number" ||
1151
+ !Number.isFinite(watchdogIntervalMs) ||
1152
+ watchdogIntervalMs < 1_000
1153
+ ) {
1154
+ throw new ConfigValidationError(
1155
+ "watchdogIntervalMs must be a finite number of at least 1000 ms.",
1156
+ );
1157
+ }
1158
+ if (
1159
+ typeof cooldownMaxMs !== "number" ||
1160
+ !Number.isFinite(cooldownMaxMs) ||
1161
+ cooldownMaxMs < 0
1162
+ ) {
1163
+ throw new ConfigValidationError(
1164
+ "cooldownMaxMs must be a finite non-negative number.",
1165
+ );
1166
+ }
1167
+ for (const [field, timeoutMs] of [
1168
+ ["recoveryIdleTimeoutMs", recoveryIdleTimeoutMs],
1169
+ ["recoveryAbsoluteTimeoutMs", recoveryAbsoluteTimeoutMs],
1170
+ ] as const) {
1171
+ if (
1172
+ typeof timeoutMs !== "number" ||
1173
+ !Number.isFinite(timeoutMs) ||
1174
+ timeoutMs < 1_000
1175
+ ) {
1176
+ throw new ConfigValidationError(
1177
+ `${field} must be a finite number of at least 1000 ms.`,
1178
+ );
1179
+ }
1180
+ }
1181
+ if (
1182
+ typeof preemptiveExpiryWindowMs !== "number" ||
1183
+ !Number.isFinite(preemptiveExpiryWindowMs) ||
1184
+ preemptiveExpiryWindowMs < 0
1185
+ ) {
1186
+ throw new ConfigValidationError(
1187
+ "preemptiveExpiryWindowMs must be a finite non-negative number.",
1188
+ );
1189
+ }
1190
+
1191
+ const parsedConfig: MultiAccountConfig = {
1192
+ accountLimit: accountLimit as number,
1193
+ sameFamilyFailover,
1194
+ crossFamilyChainEnabled,
1195
+ crossFamilyChains,
1196
+ watchdogIntervalMs,
1197
+ cooldownMaxMs,
1198
+ recoveryIdleTimeoutMs: recoveryIdleTimeoutMs as number,
1199
+ recoveryAbsoluteTimeoutMs: recoveryAbsoluteTimeoutMs as number,
1200
+ accountLabels,
1201
+ projectLabels,
1202
+ accountGroups,
1203
+ accountGroupCwdDefaults,
1204
+ monthlySubscriptionUsd,
1205
+ subscriptionPlanCatalogOverrides,
1206
+ accountRateHistory,
1207
+ preferredModels,
1208
+ tierModelMap,
1209
+ preemptiveExpiryWindowMs,
1210
+ usageFetchEnabled,
1211
+ };
1212
+ if (defaultAccountGroup === undefined) return parsedConfig;
1213
+ return { ...parsedConfig, defaultAccountGroup };
1214
+ }
1215
+
1216
+ export function validateConfig(
1217
+ config: unknown,
1218
+ ): asserts config is MultiAccountConfig {
1219
+ parseConfig(config);
1220
+ }
1221
+
1222
+ /**
1223
+ * Reads and validates the config from disk. Returns DEFAULT_CONFIG when the
1224
+ * file does not exist. Missing fields are materialized from DEFAULT_CONFIG;
1225
+ * malformed values and unsupported fields fail closed. Never loads a
1226
+ * project-local override.
1227
+ */
1228
+ export function readConfig(configPath: string): MultiAccountConfig {
1229
+ let raw: string;
1230
+ try {
1231
+ raw = readFileSync(configPath, "utf-8");
1232
+ } catch (error) {
1233
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
1234
+ return parseConfig({});
1235
+ }
1236
+ throw error;
1237
+ }
1238
+
1239
+ let parsed: unknown;
1240
+ try {
1241
+ parsed = JSON.parse(raw);
1242
+ } catch {
1243
+ throw new ConfigValidationError(
1244
+ `Config file at ${configPath} is not valid JSON.`,
1245
+ );
1246
+ }
1247
+
1248
+ return parseConfig(parsed);
1249
+ }
1250
+
1251
+ function fsyncDirectory(directory: string): void {
1252
+ const descriptor = openSync(directory, "r");
1253
+ try {
1254
+ fsyncSync(descriptor);
1255
+ } finally {
1256
+ closeSync(descriptor);
1257
+ }
1258
+ }
1259
+
1260
+ /**
1261
+ * `committed` means the new bytes are authoritative AND their mode and
1262
+ * durability were verified. `committed-warning` means the atomic rename already
1263
+ * happened -- so the new bytes are authoritative and any commit callback has
1264
+ * run -- but a later mode or durability step failed. The distinction exists
1265
+ * because a caller that publishes on commit cannot treat a post-rename failure
1266
+ * as "nothing happened".
1267
+ */
1268
+ export type ConfigWriteOutcome = "committed" | "committed-warning";
1269
+
1270
+ /**
1271
+ * Validates then atomically replaces config using a same-directory 0600
1272
+ * temporary file. Existing permissive files are not reused, and both fresh and
1273
+ * existing owning directories are constrained to 0700 (REQ-PERM-1).
1274
+ *
1275
+ * `onCommitted` runs as the FIRST statement after `renameSync`, so a caller can
1276
+ * publish the committed value before any step that may still fail. It must not
1277
+ * throw; a throw is treated as a post-rename failure because disk is already
1278
+ * replaced.
1279
+ */
1280
+ export function writeConfig(
1281
+ configPath: string,
1282
+ config: MultiAccountConfig,
1283
+ onCommitted?: () => void,
1284
+ ): ConfigWriteOutcome {
1285
+ const normalized = parseConfig(config);
1286
+ const directory = dirname(configPath);
1287
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
1288
+ chmodSync(directory, 0o700);
1289
+
1290
+ const temporaryPath = join(
1291
+ directory,
1292
+ `.${basename(configPath)}.${process.pid}.${randomUUID()}.tmp`,
1293
+ );
1294
+ let descriptor: number | undefined;
1295
+ let replaced = false;
1296
+ try {
1297
+ descriptor = openSync(temporaryPath, "wx", 0o600);
1298
+ writeFileSync(descriptor, `${JSON.stringify(normalized, null, 2)}\n`, {
1299
+ encoding: "utf-8",
1300
+ });
1301
+ fsyncSync(descriptor);
1302
+ closeSync(descriptor);
1303
+ descriptor = undefined;
1304
+ chmodSync(temporaryPath, 0o600);
1305
+ renameSync(temporaryPath, configPath);
1306
+ replaced = true;
1307
+ onCommitted?.();
1308
+ chmodSync(configPath, 0o600);
1309
+ fsyncDirectory(directory);
1310
+ return "committed";
1311
+ } catch (error) {
1312
+ if (descriptor !== undefined) closeSync(descriptor);
1313
+ if (replaced) return "committed-warning";
1314
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
1315
+ throw error;
1316
+ }
1317
+ }