@centerforagenticai/pi-multi-account 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,942 @@
1
+ /**
2
+ * Wires discovered account slots into Pi's provider registry.
3
+ *
4
+ * Anthropic aliases use the exact captured upstream behavior; Codex aliases use
5
+ * a bounded identity adapter over Pi's maintained stream. Live catalogs are
6
+ * deep-cloned through the canonical catalog-rebinding helper. Real and spare
7
+ * slots share one numeric registration plan so a lower spare gap is registered
8
+ * before a higher real slot.
9
+ */
10
+
11
+ import {
12
+ DECLARATION_BASE_URL,
13
+ LOGICAL_PROVIDER_DISPLAY_NAME,
14
+ LOGICAL_PROVIDER_ID,
15
+ assertProjectedManagedModels,
16
+ type ModelDeclarationRow,
17
+ } from "./models-declaration.js";
18
+ import {
19
+ NOOP_LOGICAL_ATTRIBUTION_ATTEMPT,
20
+ createLogicalProvider,
21
+ type LogicalAttributionLifecycle,
22
+ type LogicalProviderDeps,
23
+ } from "./logical-provider.js";
24
+ import type {
25
+ ExtensionAPI,
26
+ ExtensionContext,
27
+ ProviderConfig,
28
+ ProviderModelConfig,
29
+ } from "@earendil-works/pi-coding-agent";
30
+ import {
31
+ createAssistantMessageEventStream,
32
+ type AssistantMessage,
33
+ type AssistantMessageEvent,
34
+ type AssistantMessageEventStream,
35
+ } from "@earendil-works/pi-ai";
36
+ import {
37
+ getApiProvider,
38
+ registerApiProvider,
39
+ } from "@earendil-works/pi-ai/compat";
40
+ import { composeDisplayName } from "./account-labels.js";
41
+ import {
42
+ createAnthropicAliasProviderConfig,
43
+ type CapturedAnthropicProvider,
44
+ } from "./upstream-anthropic.js";
45
+ import {
46
+ CODEX_ALIAS_API,
47
+ buildCodexAliasFromBaseConfig,
48
+ getCodexModelsFromRegistry,
49
+ type CapturedCodexProvider,
50
+ } from "./codex-adapter.js";
51
+ import {
52
+ OPENAI_ALIAS_API,
53
+ createOpenAiAliasProviderConfig,
54
+ openaiPlatformModels,
55
+ } from "./openai-adapter.js";
56
+ import {
57
+ classifyProviderId,
58
+ isProviderSlotWithinAccountLimit,
59
+ type CredentialType,
60
+ type ProviderSlot,
61
+ } from "./discovery.js";
62
+ import { MANAGED_FAMILIES, type ManagedFamily } from "./config.js";
63
+ import { cloneProviderModelCatalog } from "./catalog-rebinding.js";
64
+ import type { ModelSupportRegistry } from "./model-support.js";
65
+ import {
66
+ mergeRefreshedCredentials,
67
+ projectAntigravityOAuthCredential,
68
+ } from "./credential-lifecycle.js";
69
+ import { GOOGLE_ANTIGRAVITY_API } from "./upstream-antigravity.js";
70
+
71
+ export interface ProviderRegistrationInput {
72
+ pi: Pick<ExtensionAPI, "registerProvider">;
73
+ extensionContext: Pick<ExtensionContext, "modelRegistry">;
74
+ anthropicCaptured: CapturedAnthropicProvider;
75
+ codexCaptured: CapturedCodexProvider;
76
+ /**
77
+ * Preloaded through src/upstream-antigravity.ts by the composition root.
78
+ * Optional so the existing composition remains inert until it owns that async load.
79
+ */
80
+ antigravityConfig?: ProviderConfig;
81
+ slots: readonly ProviderSlot[];
82
+ spareSlots: ReadonlyMap<string, string>;
83
+ config: {
84
+ accountLimit: number;
85
+ accountLabels?: Readonly<Record<string, string>>;
86
+ };
87
+ /**
88
+ * Resolves a managed account's operator-facing label. Injected so this module
89
+ * stays free of credential access; the caller owns credential lookup.
90
+ */
91
+ resolveLabel?: (providerId: string) => string;
92
+ /** Session-local model divergence used to prune known-unsupported aliases. */
93
+ modelSupport?: ModelSupportRegistry;
94
+ }
95
+
96
+ export interface RegistrationRecord {
97
+ readonly providerId: string;
98
+ readonly family: string;
99
+ readonly slotIndex: number;
100
+ readonly role: "base" | "alias" | "spare";
101
+ readonly api: string;
102
+ }
103
+
104
+ export interface ProviderRegistrationResult {
105
+ readonly registered: readonly RegistrationRecord[];
106
+ }
107
+
108
+ interface RegistrationPlanEntry {
109
+ readonly slot: ProviderSlot;
110
+ readonly role: "base" | "alias" | "spare";
111
+ }
112
+
113
+ function displayLabelFor(providerId: string): string {
114
+ const match = /-account-(\d+)$/.exec(providerId);
115
+ return match ? `Account ${match[1]}` : "Account 1";
116
+ }
117
+
118
+ /**
119
+ * Stamps an account's operator-facing identity onto a provider config.
120
+ *
121
+ * Pi renders `ProviderConfig.name` in its provider and login lists, falling back
122
+ * to the OAuth method name. Because every managed Anthropic alias inherits the
123
+ * same upstream OAuth config, they all rendered as an identical
124
+ * "Claude Pro/Max", leaving an operator unable to tell which account to sign in
125
+ * to or refresh. Setting both fields makes each account addressable.
126
+ *
127
+ * Returns the config unchanged when no label resolver is supplied, so callers
128
+ * that do not care about identity keep today's exact behavior.
129
+ */
130
+ function withAccountIdentity(
131
+ config: ProviderConfig,
132
+ providerId: string,
133
+ resolveLabel: ((providerId: string) => string) | undefined,
134
+ ): ProviderConfig {
135
+ if (!resolveLabel) return config;
136
+ const label = resolveLabel(providerId);
137
+ const baseName = config.name ?? config.oauth?.name ?? providerId;
138
+ const displayName = composeDisplayName(baseName, label, providerId);
139
+ const oauth = config.oauth
140
+ ? {
141
+ ...config.oauth,
142
+ name: composeDisplayName(config.oauth.name, label, providerId),
143
+ }
144
+ : undefined;
145
+ return {
146
+ ...config,
147
+ name: displayName,
148
+ ...(oauth === undefined ? {} : { oauth }),
149
+ };
150
+ }
151
+
152
+ function cloneRegistrationModels(
153
+ models: readonly ProviderModelConfig[],
154
+ providerId: string,
155
+ ): ProviderModelConfig[] {
156
+ try {
157
+ return structuredClone(models) as ProviderModelConfig[];
158
+ } catch (error) {
159
+ const detail = error instanceof Error ? error.message : String(error);
160
+ throw new Error(
161
+ `Antigravity models for ${providerId} could not be losslessly cloned: ${detail}`,
162
+ );
163
+ }
164
+ }
165
+
166
+ type RegistrationModelFilter = (
167
+ providerId: string,
168
+ models: readonly ProviderModelConfig[],
169
+ ) => readonly ProviderModelConfig[];
170
+
171
+ /**
172
+ * Build one isolated Antigravity provider config from the reviewed boundary.
173
+ *
174
+ * Exported so every caller that mints a `google-antigravity` slot -- discovery
175
+ * (`registerDiscoveredProviders` below) and the composition root's interactive
176
+ * `/multi-account add google-antigravity` dispatch -- shares this single
177
+ * OAuth-credential-projection wrapper instead of each growing its own copy.
178
+ * The credential projection, merge, and API-shape guard inside this function
179
+ * are the reviewed boundary; do not duplicate them at a call site.
180
+ */
181
+ export function createAntigravitySlotConfig(
182
+ source: ProviderConfig,
183
+ providerId: string,
184
+ resolveLabel: ((providerId: string) => string) | undefined,
185
+ filterModels: RegistrationModelFilter,
186
+ ): ProviderConfig {
187
+ if (
188
+ source.api !== GOOGLE_ANTIGRAVITY_API ||
189
+ !source.oauth ||
190
+ typeof source.streamSimple !== "function" ||
191
+ !Array.isArray(source.models)
192
+ ) {
193
+ throw new Error(
194
+ "The preloaded Antigravity boundary is missing its distinct API, OAuth callbacks, stream, or models.",
195
+ );
196
+ }
197
+ const sourceOauth = source.oauth;
198
+ const models = cloneRegistrationModels(
199
+ filterModels(providerId, source.models),
200
+ providerId,
201
+ );
202
+ const refreshModels = source.refreshModels;
203
+ const config: ProviderConfig = {
204
+ ...source,
205
+ api: GOOGLE_ANTIGRAVITY_API,
206
+ models,
207
+ oauth: {
208
+ ...sourceOauth,
209
+ login: async (callbacks) =>
210
+ projectAntigravityOAuthCredential(await sourceOauth.login(callbacks)),
211
+ refreshToken: async (credentials, signal) =>
212
+ mergeRefreshedCredentials(
213
+ credentials,
214
+ projectAntigravityOAuthCredential(
215
+ await sourceOauth.refreshToken(
216
+ projectAntigravityOAuthCredential(credentials),
217
+ signal,
218
+ ),
219
+ ),
220
+ ),
221
+ getApiKey: (credentials) =>
222
+ sourceOauth.getApiKey(projectAntigravityOAuthCredential(credentials)),
223
+ },
224
+ ...(refreshModels === undefined
225
+ ? {}
226
+ : {
227
+ refreshModels: async (context) =>
228
+ cloneRegistrationModels(
229
+ filterModels(
230
+ providerId,
231
+ await refreshModels(context),
232
+ ),
233
+ providerId,
234
+ ),
235
+ }),
236
+ };
237
+ return withAccountIdentity(config, providerId, resolveLabel);
238
+ }
239
+
240
+ function createRegistrationPlan(
241
+ slots: readonly ProviderSlot[],
242
+ spareSlots: ReadonlyMap<string, string>,
243
+ accountLimit: number,
244
+ ): RegistrationPlanEntry[] {
245
+ const byProviderId = new Map<string, RegistrationPlanEntry>();
246
+ for (const slot of slots) {
247
+ // Guard the discovered arm before it enters the plan: a hostile discovered
248
+ // slot above the current limit reaches neither label/catalog work,
249
+ // registerProvider(), nor the returned ledger.
250
+ if (!isProviderSlotWithinAccountLimit(slot, accountLimit)) continue;
251
+ if (!byProviderId.has(slot.providerId)) {
252
+ byProviderId.set(slot.providerId, {
253
+ slot,
254
+ role: slot.slotIndex === 1 ? "base" : "alias",
255
+ });
256
+ }
257
+ }
258
+
259
+ for (const [family, providerId] of spareSlots) {
260
+ if (!MANAGED_FAMILIES.includes(family as ManagedFamily)) continue;
261
+ if (byProviderId.has(providerId)) continue;
262
+ const slot = classifyProviderId({
263
+ providerId,
264
+ credentialType: "unknown" satisfies CredentialType,
265
+ });
266
+ if (!slot || slot.family !== family) continue;
267
+ // Guard the classified spare arm separately with its own current-limit
268
+ // check before it enters the plan.
269
+ if (!isProviderSlotWithinAccountLimit(slot, accountLimit)) continue;
270
+ byProviderId.set(providerId, { slot, role: "spare" });
271
+ }
272
+
273
+ return [...byProviderId.values()].sort((left, right) => {
274
+ const familyDelta =
275
+ MANAGED_FAMILIES.indexOf(left.slot.family) -
276
+ MANAGED_FAMILIES.indexOf(right.slot.family);
277
+ return familyDelta !== 0
278
+ ? familyDelta
279
+ : left.slot.slotIndex - right.slot.slotIndex ||
280
+ left.slot.providerId.localeCompare(right.slot.providerId);
281
+ });
282
+ }
283
+
284
+ /** Registers canonical real and spare slots in family/numeric order. */
285
+ export function registerDiscoveredProviders(
286
+ input: ProviderRegistrationInput,
287
+ ): ProviderRegistrationResult {
288
+ const {
289
+ pi,
290
+ extensionContext,
291
+ anthropicCaptured,
292
+ codexCaptured,
293
+ antigravityConfig,
294
+ slots,
295
+ spareSlots,
296
+ config,
297
+ resolveLabel,
298
+ modelSupport,
299
+ } = input;
300
+
301
+ const registered: RegistrationRecord[] = [];
302
+ let codexLiveModels: readonly ProviderModelConfig[] | undefined;
303
+ let anthropicLiveModels: readonly ProviderModelConfig[] | undefined;
304
+ const getCodexModels = (): readonly ProviderModelConfig[] =>
305
+ (codexLiveModels ??= getCodexModelsFromRegistry(
306
+ extensionContext.modelRegistry,
307
+ ));
308
+ const getAnthropicModels = (): readonly ProviderModelConfig[] =>
309
+ (anthropicLiveModels ??= cloneProviderModelCatalog(
310
+ extensionContext.modelRegistry,
311
+ "anthropic",
312
+ ));
313
+ let openaiLiveModels: readonly ProviderModelConfig[] | undefined;
314
+ const getOpenaiModels = (): readonly ProviderModelConfig[] =>
315
+ (openaiLiveModels ??= openaiPlatformModels(
316
+ extensionContext.modelRegistry,
317
+ ));
318
+ const modelsFor = (
319
+ providerId: string,
320
+ models: readonly ProviderModelConfig[],
321
+ ): readonly ProviderModelConfig[] => {
322
+ if (modelSupport === undefined) return models;
323
+ const supported = models.filter(
324
+ (model) => !modelSupport.isUnsupported(providerId, model.id),
325
+ );
326
+ // The upstream Anthropic contract rejects an empty catalog. When every
327
+ // observed model is unsupported, retain the catalog for registration but
328
+ // let dispatch-time selection exclude the recorded pair.
329
+ return supported.length > 0 ? supported : models;
330
+ };
331
+
332
+ for (const { slot, role } of createRegistrationPlan(
333
+ slots,
334
+ spareSlots,
335
+ config.accountLimit,
336
+ )) {
337
+ if (slot.family === "google-antigravity") {
338
+ // The composition root owns the asynchronous upstream boundary.
339
+ // Until it supplies that boundary, publish nothing instead of using Codex.
340
+ if (antigravityConfig === undefined) continue;
341
+ const aliasConfig = createAntigravitySlotConfig(
342
+ antigravityConfig,
343
+ slot.providerId,
344
+ resolveLabel,
345
+ modelsFor,
346
+ );
347
+ pi.registerProvider(slot.providerId, aliasConfig);
348
+ registered.push({
349
+ providerId: slot.providerId,
350
+ family: slot.family,
351
+ slotIndex: slot.slotIndex,
352
+ role,
353
+ api: GOOGLE_ANTIGRAVITY_API,
354
+ });
355
+ continue;
356
+ }
357
+
358
+ if (role === "spare") {
359
+ if (slot.family === "anthropic") {
360
+ // Upstream already registered the protected base configuration unchanged.
361
+ // Re-register only numbered spare aliases; replacing `anthropic` with an
362
+ // OAuth-only config would discard its captured models and stream handler.
363
+ if (slot.slotIndex !== 1) {
364
+ pi.registerProvider(
365
+ slot.providerId,
366
+ withAccountIdentity(
367
+ { oauth: anthropicCaptured.config.oauth },
368
+ slot.providerId,
369
+ resolveLabel,
370
+ ),
371
+ );
372
+ }
373
+ registered.push({
374
+ providerId: slot.providerId,
375
+ family: slot.family,
376
+ slotIndex: slot.slotIndex,
377
+ role,
378
+ api: "anthropic-messages",
379
+ });
380
+ } else if (slot.family === "openai") {
381
+ // The host already registered the base `openai` provider, so a base
382
+ // spare (an empty openai family, slot 1) is only recorded, never
383
+ // re-registered — mirroring the anthropic spare guard. A numbered
384
+ // spare is registered as its own api-key alias so an operator can
385
+ // target it.
386
+ if (slot.slotIndex !== 1) {
387
+ pi.registerProvider(
388
+ slot.providerId,
389
+ withAccountIdentity(
390
+ createOpenAiAliasProviderConfig(
391
+ getOpenaiModels(),
392
+ displayLabelFor(slot.providerId),
393
+ ),
394
+ slot.providerId,
395
+ resolveLabel,
396
+ ),
397
+ );
398
+ }
399
+ registered.push({
400
+ providerId: slot.providerId,
401
+ family: slot.family,
402
+ slotIndex: slot.slotIndex,
403
+ role,
404
+ api: OPENAI_ALIAS_API,
405
+ });
406
+ } else {
407
+ // The host already registered the base openai-codex provider, so a
408
+ // base spare (an empty codex family, slot 1) is only recorded, never
409
+ // re-registered — mirroring the anthropic and openai spare guards. A
410
+ // numbered spare is registered as its own alias.
411
+ if (slot.slotIndex !== 1) {
412
+ pi.registerProvider(
413
+ slot.providerId,
414
+ withAccountIdentity(
415
+ { oauth: codexCaptured.oauth },
416
+ slot.providerId,
417
+ resolveLabel,
418
+ ),
419
+ );
420
+ }
421
+ registered.push({
422
+ providerId: slot.providerId,
423
+ family: slot.family,
424
+ slotIndex: slot.slotIndex,
425
+ role,
426
+ api: "openai-codex-responses",
427
+ });
428
+ }
429
+ continue;
430
+ }
431
+
432
+ if (slot.family === "anthropic") {
433
+ if (role === "base") {
434
+ registered.push({
435
+ providerId: slot.providerId,
436
+ family: slot.family,
437
+ slotIndex: slot.slotIndex,
438
+ role,
439
+ api: "anthropic-messages",
440
+ });
441
+ } else {
442
+ const aliasConfig = withAccountIdentity(
443
+ createAnthropicAliasProviderConfig(
444
+ anthropicCaptured,
445
+ modelsFor(slot.providerId, getAnthropicModels()),
446
+ ),
447
+ slot.providerId,
448
+ resolveLabel,
449
+ );
450
+ pi.registerProvider(slot.providerId, aliasConfig);
451
+ registered.push({
452
+ providerId: slot.providerId,
453
+ family: slot.family,
454
+ slotIndex: slot.slotIndex,
455
+ role,
456
+ api: aliasConfig.api ?? "hypha-anthropic-oauth",
457
+ });
458
+ }
459
+ continue;
460
+ }
461
+
462
+ if (slot.family === "openai") {
463
+ if (role === "base") {
464
+ // The host already registered the base `openai` provider (api-key
465
+ // auth over the platform catalog; the pinned Pi ships it). Re-
466
+ // registering it would replace that host provider and start another
467
+ // availability refresh, so — exactly like the anthropic and
468
+ // openai-codex base slots — we only RECORD it and leave the host
469
+ // provider untouched. Only numbered aliases, which the host does not
470
+ // register, get an api-key alias config.
471
+ registered.push({
472
+ providerId: slot.providerId,
473
+ family: slot.family,
474
+ slotIndex: slot.slotIndex,
475
+ role,
476
+ api: OPENAI_ALIAS_API,
477
+ });
478
+ } else {
479
+ const aliasConfig = withAccountIdentity(
480
+ createOpenAiAliasProviderConfig(
481
+ getOpenaiModels(),
482
+ displayLabelFor(slot.providerId),
483
+ ),
484
+ slot.providerId,
485
+ resolveLabel,
486
+ );
487
+ pi.registerProvider(slot.providerId, aliasConfig);
488
+ registered.push({
489
+ providerId: slot.providerId,
490
+ family: slot.family,
491
+ slotIndex: slot.slotIndex,
492
+ role,
493
+ api: OPENAI_ALIAS_API,
494
+ });
495
+ }
496
+ continue;
497
+ }
498
+
499
+ if (role === "base") {
500
+ // The host already registered the native openai-codex base provider with
501
+ // its real OAuth lifecycle (login/refresh/toAuth from stored creds) and
502
+ // model catalog. Re-registering it would clobber that native auth with
503
+ // our alias-shaped config, so — exactly like the anthropic base — we only
504
+ // RECORD the base slot and leave the host provider untouched (coexistence).
505
+ registered.push({
506
+ providerId: slot.providerId,
507
+ family: slot.family,
508
+ slotIndex: slot.slotIndex,
509
+ role,
510
+ api: "openai-codex-responses",
511
+ });
512
+ } else {
513
+ const aliasConfig = withAccountIdentity(
514
+ buildCodexAliasFromBaseConfig(
515
+ codexCaptured,
516
+ modelsFor(slot.providerId, getCodexModels()),
517
+ displayLabelFor(slot.providerId),
518
+ ),
519
+ slot.providerId,
520
+ resolveLabel,
521
+ );
522
+ pi.registerProvider(slot.providerId, aliasConfig);
523
+ registered.push({
524
+ providerId: slot.providerId,
525
+ family: slot.family,
526
+ slotIndex: slot.slotIndex,
527
+ role,
528
+ api: CODEX_ALIAS_API,
529
+ });
530
+ }
531
+ }
532
+
533
+ return { registered };
534
+ }
535
+
536
+ /**
537
+ * Put the logical `unified` provider in front of the host.
538
+ *
539
+ * Registration is separate from construction so a caller can register without
540
+ * driving a request, and drive a request without a host.
541
+ *
542
+ * This adds one provider and touches nothing else. In particular it never
543
+ * re-registers or mutates the base `anthropic` or `openai-codex` providers:
544
+ * those are recorded elsewhere in this module precisely so their native
545
+ * configuration and auth survive, and re-registering base Codex would clobber
546
+ * that native auth with an alias-shaped config. The physical aliases stay
547
+ * registered exactly as they were; the logical provider sits alongside them.
548
+ *
549
+ * Row visibility is not decided here. Which models appear in the picker is the
550
+ * host's business, expressed through its own `enabledModels` setting.
551
+ */
552
+ function isAssistantMessageEvent(value: unknown): value is AssistantMessageEvent {
553
+ return (
554
+ typeof value === "object" &&
555
+ value !== null &&
556
+ "type" in value &&
557
+ typeof value.type === "string"
558
+ );
559
+ }
560
+
561
+ function isAssistantMessage(value: unknown): value is AssistantMessage {
562
+ return (
563
+ typeof value === "object" &&
564
+ value !== null &&
565
+ "role" in value &&
566
+ value.role === "assistant"
567
+ );
568
+ }
569
+
570
+ function hasStreamResult(
571
+ value: AsyncIterable<unknown>,
572
+ ): value is AsyncIterable<unknown> & { result(): Promise<unknown> } {
573
+ return "result" in value && typeof value.result === "function";
574
+ }
575
+
576
+ function deferredLogicalProviderStream(
577
+ upstream: Promise<AsyncIterable<unknown>>,
578
+ ): AssistantMessageEventStream {
579
+ const stream = createAssistantMessageEventStream();
580
+ const events: AssistantMessageEvent[] = [];
581
+ const waiters = new Set<() => void>();
582
+ let completed = false;
583
+ let failure: unknown;
584
+ let terminal: AssistantMessage | undefined;
585
+ const wake = (): void => {
586
+ for (const waiter of waiters) waiter();
587
+ waiters.clear();
588
+ };
589
+ const pump = (async (): Promise<void> => {
590
+ try {
591
+ const source = await upstream;
592
+ for await (const event of source) {
593
+ if (!isAssistantMessageEvent(event)) {
594
+ throw new TypeError("The logical provider emitted an invalid stream event.");
595
+ }
596
+ events.push(event);
597
+ if (event.type === "done") terminal = event.message;
598
+ else if (event.type === "error") terminal = event.error;
599
+ wake();
600
+ }
601
+ if (terminal === undefined && hasStreamResult(source)) {
602
+ const result = await source.result();
603
+ if (!isAssistantMessage(result)) {
604
+ throw new TypeError("The logical provider stream returned an invalid result.");
605
+ }
606
+ terminal = result;
607
+ }
608
+ } catch (cause) {
609
+ failure = cause;
610
+ } finally {
611
+ completed = true;
612
+ wake();
613
+ }
614
+ })();
615
+ stream[Symbol.asyncIterator] = async function* (): AsyncIterator<AssistantMessageEvent> {
616
+ let index = 0;
617
+ while (true) {
618
+ while (index < events.length) {
619
+ yield events[index++]!;
620
+ }
621
+ if (completed) {
622
+ if (failure !== undefined) throw failure;
623
+ return;
624
+ }
625
+ await new Promise<void>((resolve) => waiters.add(resolve));
626
+ }
627
+ };
628
+ stream.result = async (): Promise<AssistantMessage> => {
629
+ await pump;
630
+ if (failure !== undefined) throw failure;
631
+ if (terminal === undefined) {
632
+ throw new Error("The logical provider stream ended without a terminal message.");
633
+ }
634
+ return terminal;
635
+ };
636
+ return stream;
637
+ }
638
+
639
+ /**
640
+ * Identifies this package's logical `unified` entry in pi-ai's process-global
641
+ * API registry. It remains distinct from `ALIAS_API_SOURCE_ID` so Pi's
642
+ * generation reset can track the two extension-owned registrations separately.
643
+ */
644
+ export const LOGICAL_API_SOURCE_ID = "hyphagroup-pi-multi-account-unified";
645
+
646
+ /**
647
+ * The registration seam, injected only so tests can prove fail-closed ordering.
648
+ * Production uses a factory-generation registrar created by
649
+ * {@link createLogicalApiRegistrarForFactoryGeneration}.
650
+ */
651
+ export type LogicalApiRegistrar = (
652
+ logicalStream: NonNullable<ProviderConfig["streamSimple"]>,
653
+ ) => () => void;
654
+
655
+ type LogicalStream = NonNullable<ProviderConfig["streamSimple"]>;
656
+
657
+ interface LogicalApiStreamOwner {
658
+ readonly owner: symbol;
659
+ readonly stream: LogicalStream;
660
+ }
661
+
662
+ interface LogicalApiRegistryGeneration {
663
+ readonly provider: NonNullable<ReturnType<typeof getApiProvider>>;
664
+ readonly streams: LogicalApiStreamOwner[];
665
+ }
666
+
667
+ /**
668
+ * The generation currently installed in pi-ai's process-global registry.
669
+ *
670
+ * Several in-process Pi sessions load this module through one host module
671
+ * instance. Their factory registrars join this generation while its exact
672
+ * wrapped provider still occupies the registry. `resetApiProviders()` breaks
673
+ * that identity on reload, so the next factory creates a fresh generation and
674
+ * cannot fall back to a stale pre-reload session stream.
675
+ */
676
+ let currentLogicalApiGeneration: LogicalApiRegistryGeneration | undefined;
677
+
678
+ function createLogicalApiRegistryGeneration(): LogicalApiRegistryGeneration {
679
+ const streams: LogicalApiStreamOwner[] = [];
680
+ const currentStream: LogicalStream = (model, context, options) => {
681
+ const active = streams.at(-1);
682
+ if (active === undefined) {
683
+ throw new Error("No active unified logical provider session is available.");
684
+ }
685
+ return active.stream(model, context, options);
686
+ };
687
+
688
+ // Deliberately uncast. `SimpleStreamOptions` extends `StreamOptions` with
689
+ // optional fields, so the one forwarding handler satisfies both slots today.
690
+ // If a pi-ai upgrade changes either signature, this must fail typecheck rather
691
+ // than be silently bridged by a cast.
692
+ registerApiProvider(
693
+ {
694
+ api: LOGICAL_PROVIDER_ID,
695
+ stream: currentStream,
696
+ streamSimple: currentStream,
697
+ },
698
+ LOGICAL_API_SOURCE_ID,
699
+ );
700
+ const provider = getApiProvider(LOGICAL_PROVIDER_ID);
701
+ if (provider === undefined) {
702
+ throw new Error("The unified logical API registrar did not publish its entry.");
703
+ }
704
+ const generation = { provider, streams };
705
+ currentLogicalApiGeneration = generation;
706
+ return generation;
707
+ }
708
+
709
+ function acquireLogicalApiRegistryGeneration(): LogicalApiRegistryGeneration {
710
+ const registered = getApiProvider(LOGICAL_PROVIDER_ID);
711
+ if (
712
+ currentLogicalApiGeneration !== undefined &&
713
+ registered === currentLogicalApiGeneration.provider
714
+ ) {
715
+ return currentLogicalApiGeneration;
716
+ }
717
+ return createLogicalApiRegistryGeneration();
718
+ }
719
+
720
+ /**
721
+ * Creates the logical compat registrar owned by one extension factory generation.
722
+ *
723
+ * Registration is lazy so an absent or unreadable declaration publishes
724
+ * nothing. The first matched session installs one process-global forwarding
725
+ * entry; sibling factories join that entry rather than replacing it with a
726
+ * session closure. Each active session contributes its exact host
727
+ * `streamSimple`. The newest live session is current, and releasing it restores
728
+ * the preceding live stream without unregistering the shared API id. Pi's
729
+ * `reload()` calls `resetApiProviders()` before rerunning factories, so the next
730
+ * generation restores the entry structurally, matching the Anthropic alias API
731
+ * lifetime.
732
+ */
733
+ export function createLogicalApiRegistrarForFactoryGeneration(): LogicalApiRegistrar {
734
+ let factoryGeneration: LogicalApiRegistryGeneration | undefined;
735
+ return (logicalStream) => {
736
+ const registered = getApiProvider(LOGICAL_PROVIDER_ID);
737
+ const generation =
738
+ factoryGeneration !== undefined && registered === factoryGeneration.provider
739
+ ? factoryGeneration
740
+ : acquireLogicalApiRegistryGeneration();
741
+ factoryGeneration = generation;
742
+
743
+ const owner = Symbol("logical-session-stream");
744
+ generation.streams.push({ owner, stream: logicalStream });
745
+ let released = false;
746
+ return () => {
747
+ if (released) return;
748
+ released = true;
749
+ const index = generation.streams.findIndex((candidate) => candidate.owner === owner);
750
+ if (index !== -1) generation.streams.splice(index, 1);
751
+ };
752
+ };
753
+ }
754
+
755
+ const registerLogicalApiWithCompat =
756
+ createLogicalApiRegistrarForFactoryGeneration();
757
+
758
+ function registerProjectedLogicalProvider(
759
+ pi: Pick<ExtensionAPI, "registerProvider">,
760
+ models: ModelDeclarationRow[],
761
+ deps: LogicalProviderDeps,
762
+ registerLogicalApi: LogicalApiRegistrar,
763
+ ): () => void {
764
+ const provider = createLogicalProvider(deps);
765
+ const streamSimple: NonNullable<ProviderConfig["streamSimple"]> = (
766
+ model,
767
+ context,
768
+ options,
769
+ ) =>
770
+ deferredLogicalProviderStream(
771
+ Promise.resolve(provider.streamSimple(model, context, options)),
772
+ );
773
+ const providerConfig: ProviderConfig = {
774
+ name: LOGICAL_PROVIDER_DISPLAY_NAME,
775
+ api: LOGICAL_PROVIDER_ID,
776
+ baseUrl: DECLARATION_BASE_URL,
777
+ models,
778
+ streamSimple,
779
+ };
780
+ // Activate the compat stream BEFORE publishing the host provider, and bind it
781
+ // to the exact same logical stream the host provider uses. A throwing registrar
782
+ // therefore rejects registration and leaves no logical provider publication
783
+ // behind; provider-map and raw-compat dispatch cannot diverge in account
784
+ // selection, failover, attribution, or request options. The registrar is
785
+ // deliberately NOT wrapped here: swallowing its failure would publish a host
786
+ // provider whose `unified` API cannot resolve.
787
+ const releaseLogicalStream = registerLogicalApi(streamSimple);
788
+ try {
789
+ pi.registerProvider(LOGICAL_PROVIDER_ID, providerConfig);
790
+ } catch (error) {
791
+ // Remove only this failed session's stream. The process-global API entry is
792
+ // generation-owned and remains available to any preceding live session.
793
+ releaseLogicalStream();
794
+ throw error;
795
+ }
796
+ return releaseLogicalStream;
797
+ }
798
+
799
+ function safeAttributionCall(call: () => void): void {
800
+ try {
801
+ call();
802
+ } catch {
803
+ // Attribution release and settlement cannot fail a host lifecycle event.
804
+ }
805
+ }
806
+
807
+ /** Permanently release a raw reusable lifecycle at the session boundary. */
808
+ export function createSessionAttributionAdapter(
809
+ rawLifecycle: LogicalAttributionLifecycle,
810
+ ): LogicalAttributionLifecycle {
811
+ let released = false;
812
+ return {
813
+ beginAttempt(route) {
814
+ if (released) return NOOP_LOGICAL_ATTRIBUTION_ATTEMPT;
815
+ try {
816
+ return rawLifecycle.beginAttempt(route);
817
+ } catch {
818
+ return NOOP_LOGICAL_ATTRIBUTION_ATTEMPT;
819
+ }
820
+ },
821
+ settle() {
822
+ if (released) return;
823
+ safeAttributionCall(() => rawLifecycle.settle());
824
+ },
825
+ shutdown() {
826
+ if (released) return;
827
+ released = true;
828
+ safeAttributionCall(() => rawLifecycle.shutdown());
829
+ },
830
+ };
831
+ }
832
+
833
+ /** A session-scoped logical registration, disposed with the session. */
834
+ export interface LogicalProviderSessionRegistration {
835
+ /**
836
+ * Tear the registration down.
837
+ *
838
+ * Idempotent by contract: the host calls `session_shutdown` on its own
839
+ * schedule and a caller may dispose explicitly, so both paths race by
840
+ * design and the second one must be a no-op rather than a double teardown.
841
+ */
842
+ dispose(): void;
843
+ }
844
+
845
+ /**
846
+ * Register the logical provider for the lifetime of one session.
847
+ *
848
+ * The registration is bound to a session: it registers, subscribes to
849
+ * `session_shutdown`, and releases on whichever of shutdown or `dispose()`
850
+ * arrives first.
851
+ *
852
+ * The subscription is captured and released rather than left dangling, mirroring
853
+ * `MultiAccountLifecycle` in `./lifecycle.ts`, which is the established idiom
854
+ * for this in the extension. Teardown is deliberately not fail-soft in the
855
+ * `#isolated` sense: there is no diagnostic sink on this path that could report
856
+ * a swallowed fault, so a release fault must not be hidden here.
857
+ */
858
+ export function registerLogicalProviderForSession(
859
+ pi: Pick<ExtensionAPI, "registerProvider" | "on">,
860
+ projectedModels: unknown[],
861
+ deps: LogicalProviderDeps,
862
+ registerLogicalApi: LogicalApiRegistrar = registerLogicalApiWithCompat,
863
+ ): LogicalProviderSessionRegistration {
864
+ const models = assertProjectedManagedModels(projectedModels);
865
+ const rawLifecycle: LogicalAttributionLifecycle = deps.attribution ?? {
866
+ beginAttempt: () => NOOP_LOGICAL_ATTRIBUTION_ATTEMPT,
867
+ settle: () => {},
868
+ shutdown: () => {},
869
+ };
870
+ const sessionAttribution = createSessionAttributionAdapter(rawLifecycle);
871
+ const sessionDeps = Object.defineProperty(
872
+ Object.create(deps) as LogicalProviderDeps,
873
+ "attribution",
874
+ { value: sessionAttribution, enumerable: true },
875
+ );
876
+ // Reassigned to the active-stream release once registration succeeds; a
877
+ // registrar failure never installs a release.
878
+ let releaseLogicalStream: () => void = () => {};
879
+ try {
880
+ releaseLogicalStream = registerProjectedLogicalProvider(
881
+ pi,
882
+ models,
883
+ sessionDeps,
884
+ registerLogicalApi,
885
+ );
886
+ } catch (error) {
887
+ // registerProjectedLogicalProvider already removed any activated session
888
+ // stream before rethrowing, so only attribution needs releasing here.
889
+ sessionAttribution.shutdown();
890
+ throw error;
891
+ }
892
+
893
+ let subscriptionsReleased = false;
894
+ const subscriptions: unknown[] = [];
895
+ let unsubscribe: unknown = () => {
896
+ for (const subscription of subscriptions) {
897
+ if (typeof subscription === "function") {
898
+ safeAttributionCall(() => (subscription as () => void)());
899
+ }
900
+ }
901
+ };
902
+ function releaseSubscriptions(): void {
903
+ if (subscriptionsReleased) return;
904
+ subscriptionsReleased = true;
905
+ if (typeof unsubscribe === "function") unsubscribe();
906
+ unsubscribe = undefined;
907
+ }
908
+
909
+ // The one idempotent release for this session. It removes only this session's
910
+ // compat stream, shuts down attribution, and releases subscriptions. The
911
+ // generation-owned API entry remains registered until Pi resets the registry.
912
+ // session_shutdown and dispose() race by design; all three releases are guarded.
913
+ function releaseRegistration(): void {
914
+ releaseLogicalStream();
915
+ sessionAttribution.shutdown();
916
+ releaseSubscriptions();
917
+ }
918
+
919
+ try {
920
+ subscriptions.push(
921
+ pi.on("agent_settled", () => {
922
+ sessionAttribution.settle();
923
+ }),
924
+ );
925
+ subscriptions.push(
926
+ pi.on("session_shutdown", () => {
927
+ releaseRegistration();
928
+ }),
929
+ );
930
+ } catch (error) {
931
+ // Subscription setup failed after host publication: deactivate this session,
932
+ // shut attribution down, release partial subscriptions, and rethrow.
933
+ releaseRegistration();
934
+ throw error;
935
+ }
936
+
937
+ return {
938
+ dispose() {
939
+ releaseRegistration();
940
+ },
941
+ };
942
+ }