@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.
- package/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- 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
|
+
}
|