@centerforagenticai/pi-multi-account 0.1.4 → 0.1.6

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 (35) hide show
  1. package/README.md +17 -3
  2. package/package.json +5 -5
  3. package/packages/pi-anthropic-oauth/package.json +2 -2
  4. package/packages/pi-anthropic-oauth/src/stream.ts +26 -8
  5. package/packages/pi-anthropic-oauth/src/transport-activity.ts +59 -0
  6. package/packages/pi-antigravity/package.json +2 -2
  7. package/packages/pi-antigravity/src/models/discovery.ts +2 -1
  8. package/packages/pi-antigravity/src/models/grouping.ts +12 -10
  9. package/packages/pi-antigravity/src/models/models.ts +11 -4
  10. package/src/account-group-failure.ts +149 -0
  11. package/src/account-group-members.ts +139 -0
  12. package/src/anthropic-adaptive-stream.ts +18 -9
  13. package/src/anthropic-alias-stream.ts +13 -68
  14. package/src/codex-adapter.ts +78 -50
  15. package/src/commands.ts +3 -3
  16. package/src/config.ts +197 -2
  17. package/src/diagnostic-store.ts +17 -15
  18. package/src/diagnostics.ts +97 -35
  19. package/src/host-final-stop-message.ts +20 -118
  20. package/src/index.ts +452 -87
  21. package/src/logical-dispatch.ts +12 -13
  22. package/src/logical-provider.ts +1282 -355
  23. package/src/model-fallback-policy.ts +384 -0
  24. package/src/models-declaration.ts +15 -8
  25. package/src/public-assistant-projection.ts +163 -0
  26. package/src/recovery-engine.ts +648 -110
  27. package/src/recovery-plan.ts +7 -1
  28. package/src/recovery-send-evidence.ts +29 -0
  29. package/src/refusal-advice.ts +139 -0
  30. package/src/routing.ts +4 -8
  31. package/src/runtime-state.ts +7 -0
  32. package/src/shared-usage.ts +21 -4
  33. package/src/upstream-anthropic.ts +12 -4
  34. package/src/upstream-antigravity.ts +2 -38
  35. package/src/usage-fetch.ts +42 -59
package/src/index.ts CHANGED
@@ -19,6 +19,11 @@ import {
19
19
  type OperatorAccount,
20
20
  } from "./commands.js";
21
21
  import { CompactionRouter } from "./compaction.js";
22
+ import {
23
+ accountGroupMemberAvailability,
24
+ isAccountGroupVirtualModel,
25
+ readAccountGroupRegistrySnapshot,
26
+ } from "./account-group-members.js";
22
27
  import {
23
28
  createCodexAliasProviderConfig,
24
29
  getCodexModelsFromRegistry,
@@ -35,6 +40,8 @@ import {
35
40
  import {
36
41
  ALLOWED_FAMILIES,
37
42
  DEFAULT_CONFIG,
43
+ ConfigValidationError,
44
+ isAccountLimit,
38
45
  accountSlotIndexes,
39
46
  canonicalProviderIdForAccountSlot,
40
47
  isAccountSlotIndex,
@@ -80,6 +87,7 @@ import {
80
87
  import { DiagnosticStore } from "./diagnostic-store.js";
81
88
  import { DeclarationNoticeMarker } from "./declaration-notice-marker.js";
82
89
  import { DiagnosticLog } from "./diagnostics.js";
90
+ import { createRefusalAdvisor } from "./refusal-advice.js";
83
91
  import {
84
92
  classifyProviderId,
85
93
  createPublicAuthStorageAdapter,
@@ -136,6 +144,7 @@ import {
136
144
  type InstalledDeclarationStatus,
137
145
  } from "./models-declaration.js";
138
146
  import { createLogicalDispatch } from "./logical-dispatch.js";
147
+ import type { AccountGroupFailureBlockReason } from "./account-group-failure.js";
139
148
  import type { ModelsCatalogs } from "./commands.js";
140
149
  import {
141
150
  NOOP_LOGICAL_ATTRIBUTION_ATTEMPT,
@@ -155,6 +164,7 @@ import {
155
164
  import { isCredentialUsable } from "./credential-lifecycle.js";
156
165
  import {
157
166
  routeAfterFailure,
167
+ logicalAccountEligible,
158
168
  selectAvailableManagedAccount,
159
169
  selectAvailableRecoveryAccount,
160
170
  selectAvailableRouteCandidates,
@@ -200,7 +210,7 @@ import {
200
210
  type SessionIdSource,
201
211
  } from "./session-account-groups.js";
202
212
  import { projectKeyForCwd } from "./project-identity.js";
203
- import { RuntimeState, isCanonicalManagedProviderId } from "./runtime-state.js";
213
+ import { RuntimeState, isCanonicalManagedProviderId, normalizeCredentialRevision } from "./runtime-state.js";
204
214
  import { providerTypeFor } from "./vendor.js";
205
215
  import { resolveTierModel } from "./tier-model-resolver.js";
206
216
  import {
@@ -408,6 +418,17 @@ function readInstalledDeclaration(targetPath: string): unknown {
408
418
  }
409
419
  }
410
420
 
421
+ /** Only schema field names, never validator prose or operator-supplied values. */
422
+ function configRepairMessage(error: unknown): string {
423
+ const field = error instanceof ConfigValidationError
424
+ ? [...Object.keys(DEFAULT_CONFIG), "defaultAccountGroup"].find((key) =>
425
+ error.message.startsWith(`[multi-account config] ${key} `) ||
426
+ error.message.startsWith(`[multi-account config] ${key}.`),
427
+ )
428
+ : undefined;
429
+ return `Multi-account configuration is invalid${field === undefined ? "" : ` (${field})`}. Correct the global config and run /multi-account reload or /reload.`;
430
+ }
431
+
411
432
  /** The session's id, or undefined when the host context cannot supply one. */
412
433
  function readSessionId(ctx: ExtensionContext): string | undefined {
413
434
  try {
@@ -1603,11 +1624,12 @@ function readLiveCredentialFacts(
1603
1624
  ): {
1604
1625
  credential: CredentialUsability | undefined;
1605
1626
  fingerprint: string | undefined;
1627
+ credentialRevision: number | undefined;
1606
1628
  } {
1607
1629
  try {
1608
1630
  const stored = readStoredCredential(providerId, authPath);
1609
1631
  if (stored === null || typeof stored !== "object") {
1610
- return { credential: undefined, fingerprint: undefined };
1632
+ return { credential: undefined, fingerprint: undefined, credentialRevision: undefined };
1611
1633
  }
1612
1634
  const record = stored as Record<string, unknown>;
1613
1635
  const expiresAtMs =
@@ -1621,10 +1643,11 @@ function readLiveCredentialFacts(
1621
1643
  ...(expiresAtMs === undefined ? {} : { expiresAtMs }),
1622
1644
  },
1623
1645
  fingerprint: accountFingerprint(record.access),
1646
+ credentialRevision: expiresAtMs,
1624
1647
  };
1625
1648
  } catch {
1626
1649
  // Missing or unreadable metadata must preserve existing reactive routing.
1627
- return { credential: undefined, fingerprint: undefined };
1650
+ return { credential: undefined, fingerprint: undefined, credentialRevision: undefined };
1628
1651
  }
1629
1652
  }
1630
1653
 
@@ -1637,7 +1660,7 @@ function readLiveCredentialFacts(
1637
1660
  * routing keep the managed superset.
1638
1661
  */
1639
1662
  type OperatorManagedAccount = Omit<OperatorAccount, "family"> &
1640
- Omit<ManagedAccount, "family"> & { readonly family: ManagedFamily };
1663
+ Omit<ManagedAccount, "family"> & { readonly family: ManagedFamily; readonly credentialRevision?: number };
1641
1664
 
1642
1665
  export type AccountGroupEnforcementReason =
1643
1666
  | "unknown-group"
@@ -1729,7 +1752,7 @@ function operatorAccounts(
1729
1752
  // Project the stored credential at this boundary into bounded metadata only.
1730
1753
  // The raw access value is used transiently by accountFingerprint and never
1731
1754
  // enters OperatorAccount, ManagedAccount, routing state, or diagnostics.
1732
- const { credential, fingerprint } = readLiveCredentialFacts(
1755
+ const { credential, fingerprint, credentialRevision } = readLiveCredentialFacts(
1733
1756
  slot.providerId,
1734
1757
  authPath,
1735
1758
  );
@@ -1746,6 +1769,7 @@ function operatorAccounts(
1746
1769
  modelIds: models.map((candidate) => candidate.id),
1747
1770
  displayName: slot.providerId,
1748
1771
  ...(credential === undefined ? {} : { credential }),
1772
+ ...(credentialRevision === undefined ? {} : { credentialRevision }),
1749
1773
  ...(fingerprint === undefined
1750
1774
  ? {}
1751
1775
  : { accountFingerprint: fingerprint }),
@@ -1962,9 +1986,10 @@ function delegateOwnedApiSet(): WeakSet<object> {
1962
1986
  * True when this extension API belongs to a delegate-owned in-process session
1963
1987
  * rather than the real foreground originator.
1964
1988
  *
1965
- * This read must happen while the factory runs inside bindExtensions(). An active
1966
- * current bind has a defined AsyncLocalStorage frame; a legacy bind has positive
1967
- * depth. Either signal marks the API persistently, matching pi-delegate's own
1989
+ * Read at session_start, which bindExtensions() emits inside the trusted frame,
1990
+ * not while the loader executes the factory. An active current bind has a defined
1991
+ * AsyncLocalStorage frame; a legacy bind has positive depth. Either signal marks
1992
+ * the API persistently, matching pi-delegate's own
1968
1993
  * shouldSkipForegroundLifecycleForDelegateOwnedApi contract.
1969
1994
  */
1970
1995
  function isDelegateOwnedApi(api: Parameters<ExtensionFactory>[0]): boolean {
@@ -2031,7 +2056,7 @@ function delegateWorkerOriginOwnerSessionId(
2031
2056
 
2032
2057
  /**
2033
2058
  * Persist an inherited effective result under the child's own session id without
2034
- * consulting the child's real cwd/default policy. This makes a durable child a
2059
+ * consulting the child's real cwd/default policy. This makes a delegate child a
2035
2060
  * valid parent for the next durable or in-process hop while preserving the
2036
2061
  * inherited source exactly.
2037
2062
  */
@@ -2117,13 +2142,9 @@ function buildLivePiCatalogSnapshot(
2117
2142
  export const createMultiAccountExtension =
2118
2143
  (options: MultiAccountExtensionOptions = {}): ExtensionFactory =>
2119
2144
  async (pi) => {
2120
- // Read pi-delegate's bind scope while the factory body runs synchronously
2121
- // inside bindExtensions(). A delegate-owned in-process worker/clone session
2122
- // shares the foreground's TUI, so its route indicator would publish to the
2123
- // same qualified widget key and overwrite the real foreground footer. The
2124
- // footer is foreground-only; every other surface (provider registration,
2125
- // routing, cost attribution) stays active for the worker.
2126
- const delegateOwnedSession = isDelegateOwnedApi(pi);
2145
+ // Ownership becomes available at session_start, not factory loading. Keep
2146
+ // the bind-time result for later routing and foreground-only UI callbacks.
2147
+ let delegateOwnedSession = false;
2127
2148
  // Copy only pi-delegate's two named child-identity fields. Public request
2128
2149
  // objects and caller-provided env maps never cross this trust boundary.
2129
2150
  const delegateDriverChildMarker = process.env[DELEGATE_DRIVER_CHILD_ENV];
@@ -2260,6 +2281,17 @@ export const createMultiAccountExtension =
2260
2281
  let latestLogicalPhysicalProviderId: string | undefined;
2261
2282
  const logicalTerminalAssociations = createLogicalTerminalAssociationStore();
2262
2283
  let config: MultiAccountConfig = DEFAULT_CONFIG;
2284
+ // Discovery defaults are not request authorization. Cleared only by a
2285
+ // complete validated load; a failed reload keeps the last valid policy.
2286
+ let policyConfigError: string | undefined =
2287
+ "Multi-account configuration is not loaded. Run /multi-account reload or /reload.";
2288
+ const loadGlobalConfig = (): MultiAccountConfig => {
2289
+ try {
2290
+ return readConfig(configPath);
2291
+ } catch (error) {
2292
+ throw new Error(configRepairMessage(error));
2293
+ }
2294
+ };
2263
2295
  const defaultCostReportReader = createDefaultCostReportReader({
2264
2296
  config: () => config,
2265
2297
  // `context` is undefined until `session_start` assigns it below; this
@@ -2398,11 +2430,23 @@ export const createMultiAccountExtension =
2398
2430
  return executeLogicalModelSwitch(args, dependencies);
2399
2431
  };
2400
2432
 
2401
- const openRouterPolicy = () =>
2402
- resolveOpenRouterEnvironmentPolicy({
2433
+ const openRouterPolicy = () => {
2434
+ const policy = resolveOpenRouterEnvironmentPolicy({
2403
2435
  environment: openRouterEnvironment,
2404
2436
  sessionDisabled: openRouterSessionDisabled,
2405
2437
  });
2438
+ // In-process workers share the parent's environment, so lineage env
2439
+ // alone cannot exclude them. Only retained trusted bind ownership
2440
+ // supplies this additional veto; caller-shaped origins never do.
2441
+ return delegateOwnedSession
2442
+ ? {
2443
+ enabled: false as const,
2444
+ reason: "delegate-session" as const,
2445
+ conversationEgressConsented: policy.conversationEgressConsented,
2446
+ delegatesAllowed: false as const,
2447
+ }
2448
+ : policy;
2449
+ };
2406
2450
  const openRouterModel = (
2407
2451
  modelId: string,
2408
2452
  modelContext: ExtensionContext | undefined = context,
@@ -2468,7 +2512,9 @@ export const createMultiAccountExtension =
2468
2512
  | undefined;
2469
2513
  let accountGroupResolutionInitialized = false;
2470
2514
  let accountGroupScopeBlocked = false;
2471
- const blockAccountGroupScope = (key: string, message: string): void => {
2515
+ let accountGroupBlockReason: AccountGroupFailureBlockReason | undefined;
2516
+ const blockAccountGroupScope = (key: AccountGroupFailureBlockReason, message: string): void => {
2517
+ accountGroupBlockReason = key;
2472
2518
  accountGroupResolutionInitialized = true;
2473
2519
  accountGroupScopeBlocked = true;
2474
2520
  effectiveAccountGroupResolution = undefined;
@@ -2479,10 +2525,12 @@ export const createMultiAccountExtension =
2479
2525
  ): EffectiveAccountGroupResolution => {
2480
2526
  accountGroupResolutionInitialized = true;
2481
2527
  accountGroupScopeBlocked = false;
2528
+ accountGroupBlockReason = undefined;
2482
2529
  effectiveAccountGroupResolution = resolution;
2483
2530
  return resolution;
2484
2531
  };
2485
2532
  const accountGroupScopeRestrictsRouting = (): boolean =>
2533
+ policyConfigError !== undefined ||
2486
2534
  accountGroupScopeBlocked ||
2487
2535
  effectiveAccountGroupResolution === undefined ||
2488
2536
  effectiveAccountGroupResolution.source !== "unrestricted";
@@ -2500,6 +2548,10 @@ export const createMultiAccountExtension =
2500
2548
  const initializeSessionAccountGroupScope = (
2501
2549
  accountContext: ExtensionContext | undefined,
2502
2550
  ): void => {
2551
+ if (policyConfigError !== undefined) {
2552
+ blockAccountGroupScope("config-invalid", policyConfigError);
2553
+ return;
2554
+ }
2503
2555
  const sessionManager = accountContext?.sessionManager as
2504
2556
  | AccountGroupSessionManager
2505
2557
  | undefined;
@@ -2546,14 +2598,23 @@ export const createMultiAccountExtension =
2546
2598
  );
2547
2599
  return;
2548
2600
  }
2549
- const parentOverride =
2550
- sessionAccountGroups.readOverride(parentSessionId);
2551
- if (parentOverride === undefined) {
2552
- resolveOwnSessionAccountGroup(sessionManager, cwd);
2601
+ const inherited =
2602
+ sessionAccountGroups.readCachedResolution(parentSessionId);
2603
+ if (inherited === undefined) {
2604
+ blockAccountGroupScope(
2605
+ "delegate-resolution-unavailable",
2606
+ "The delegate parent's effective account group is unavailable; managed routing is blocked for this worker.",
2607
+ );
2553
2608
  return;
2554
2609
  }
2555
- sessionAccountGroups.setOverride(sessionManager, parentOverride);
2556
- resolveOwnSessionAccountGroup(sessionManager, cwd);
2610
+ rememberAccountGroupResolution(
2611
+ cacheInheritedAccountGroupResolution(
2612
+ sessionAccountGroups,
2613
+ sessionManager,
2614
+ cwd,
2615
+ inherited,
2616
+ ),
2617
+ );
2557
2618
  return;
2558
2619
  }
2559
2620
  if (delegateDriverIdentityPresent) {
@@ -2600,6 +2661,7 @@ export const createMultiAccountExtension =
2600
2661
  accounts: readonly OperatorManagedAccount[],
2601
2662
  accountContext: ExtensionContext | undefined,
2602
2663
  ): OperatorManagedAccount[] => {
2664
+ if (policyConfigError !== undefined) return [];
2603
2665
  if (!accountGroupResolutionInitialized) {
2604
2666
  initializeSessionAccountGroupScope(accountContext);
2605
2667
  }
@@ -2648,13 +2710,17 @@ export const createMultiAccountExtension =
2648
2710
  (account): account is OperatorAccount & SubscriptionManagedAccount =>
2649
2711
  isRoutingEligibleAccountFamily(account),
2650
2712
  );
2651
- const physicalAccountsForRouting = (nowMs: number): ManagedAccount[] =>
2652
- physicalOperatorAccounts().map((account) => {
2713
+ const physicalAccountsForRouting = (
2714
+ nowMs: number,
2715
+ accounts: readonly OperatorManagedAccount[] = physicalOperatorAccounts(),
2716
+ ): ManagedAccount[] =>
2717
+ accounts.map((account) => {
2653
2718
  const {
2654
2719
  providerId,
2655
2720
  family,
2656
2721
  credentialType,
2657
2722
  credential,
2723
+ credentialRevision,
2658
2724
  accountFingerprint,
2659
2725
  modelIds,
2660
2726
  } = account;
@@ -2681,6 +2747,7 @@ export const createMultiAccountExtension =
2681
2747
  : { accountFingerprint }),
2682
2748
  ...(modelIds === undefined ? {} : { modelIds }),
2683
2749
  ...(fleetUsage === undefined ? {} : { fleetUsage }),
2750
+ ...(account.credentialRevision === undefined ? {} : { credentialRevision: account.credentialRevision }),
2684
2751
  };
2685
2752
  });
2686
2753
  const subscriptionAccountsForRouting = (
@@ -2898,6 +2965,10 @@ export const createMultiAccountExtension =
2898
2965
  authJsonPath: authPath,
2899
2966
  config,
2900
2967
  });
2968
+ for (const slot of discovered.slots) {
2969
+ const revision = normalizeCredentialRevision(slot.expiresAtMs ?? NaN);
2970
+ if (revision !== undefined) state.observeCredentialRevision(slot.providerId, slot.family, revision);
2971
+ }
2901
2972
  // When codex is disabled (host OAuth surface unavailable), strip codex
2902
2973
  // slots so the disabled sentinel's throwing callbacks are never reached.
2903
2974
  discovery = codexEnabled
@@ -3116,44 +3187,58 @@ export const createMultiAccountExtension =
3116
3187
  };
3117
3188
  };
3118
3189
 
3119
- const resolveCommandAccountGroup = (): AccountGroupCommandStatus => {
3120
- const liveContext = context;
3121
- if (
3122
- liveContext === undefined ||
3123
- typeof liveContext.sessionManager?.getSessionId !== "function"
3124
- ) {
3125
- throw new Error("The live session identity is unavailable.");
3190
+ const readCommandAccountGroupStatus = (): AccountGroupCommandStatus => {
3191
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
3192
+ const resolution = effectiveAccountGroupResolution;
3193
+ if (accountGroupScopeBlocked || resolution === undefined) {
3194
+ throw new Error("The effective session account group is unavailable; managed routing is blocked.");
3126
3195
  }
3127
- const resolution = resolveOwnSessionAccountGroup(
3128
- liveContext.sessionManager,
3129
- liveContext.cwd,
3130
- );
3131
3196
  if (resolution.source === "unrestricted") return { resolution };
3132
3197
  const members = config.accountGroups?.[resolution.groupId] ?? [];
3133
3198
  const nowMs = Date.now();
3134
- const routingAccounts = physicalAccountsForRouting(nowMs);
3199
+ const snapshot = readAccountGroupRegistrySnapshot(context?.modelRegistry);
3200
+ const providerIds = new Set([...members, ...snapshot.providerIds]);
3201
+ providerIds.delete(LOGICAL_PROVIDER_ID);
3135
3202
  return {
3136
3203
  resolution,
3137
- members: members.map((providerId) => {
3138
- const eligible =
3139
- selectAvailableManagedAccount({
3140
- accounts: routingAccounts.filter(
3141
- (account) => account.providerId === providerId,
3142
- ),
3143
- state,
3144
- nowMs,
3145
- }) !== undefined;
3146
- return {
3147
- providerId,
3148
- eligible,
3149
- reason: eligible
3150
- ? "routing-eligible"
3151
- : "not currently routing-eligible",
3152
- };
3204
+ members: [...providerIds].map((providerId) => {
3205
+ const availability = accountGroupMemberAvailability(providerId, members, snapshot);
3206
+ if (!availability.eligible) return availability;
3207
+ if (providerId === OPENROUTER_PROVIDER_ID) {
3208
+ return { providerId, eligible: false, reason: "metered OpenRouter disabled by active group" };
3209
+ }
3210
+ const slot = classifyProviderId({ providerId, credentialType: "unknown" });
3211
+ if (slot === null) return availability;
3212
+ const fleetUsage = isAllowedFamily(slot.family)
3213
+ ? sharedUsageHint(
3214
+ usage.routingUsage(providerId, slot.family, nowMs),
3215
+ usage.activeExhaustionHoldUntilMs(providerId, slot.family, nowMs),
3216
+ )
3217
+ : undefined;
3218
+ const eligible = !disabledProviders.has(providerId) && logicalAccountEligible(
3219
+ { providerId, exhausted: snapshotIndicatesExhaustion(fleetUsage, nowMs, "all-observed") },
3220
+ state, nowMs,
3221
+ );
3222
+ return { providerId, eligible, reason: eligible ? availability.reason : "not currently routing-eligible" };
3153
3223
  }),
3154
3224
  };
3155
3225
  };
3156
3226
 
3227
+ // Explicit operator actions resolve this session's own policy. Observational
3228
+ // status must instead preserve the effective result inherited at startup.
3229
+ const resolveCommandAccountGroup = (): AccountGroupCommandStatus => {
3230
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
3231
+ const liveContext = context;
3232
+ if (
3233
+ liveContext === undefined ||
3234
+ typeof liveContext.sessionManager?.getSessionId !== "function"
3235
+ ) {
3236
+ throw new Error("The live session identity is unavailable.");
3237
+ }
3238
+ resolveOwnSessionAccountGroup(liveContext.sessionManager, liveContext.cwd);
3239
+ return readCommandAccountGroupStatus();
3240
+ };
3241
+
3157
3242
  const commands = new MultiAccountCommandController({
3158
3243
  state,
3159
3244
  usage,
@@ -3197,7 +3282,7 @@ export const createMultiAccountExtension =
3197
3282
  sessionAccountGroups.clearOverride(sessionManager);
3198
3283
  return resolveCommandAccountGroup();
3199
3284
  },
3200
- status: resolveCommandAccountGroup,
3285
+ status: readCommandAccountGroupStatus,
3201
3286
  },
3202
3287
  logicalRoutingState: () => logicalRoutingState,
3203
3288
  disabledProviders,
@@ -3282,9 +3367,12 @@ export const createMultiAccountExtension =
3282
3367
  setModel,
3283
3368
  rediscover,
3284
3369
  addSlot,
3285
- reloadGlobalConfig: async () => readConfig(configPath),
3370
+ reloadGlobalConfig: async () => loadGlobalConfig(),
3286
3371
  onConfigReload: async (nextConfig) => {
3287
3372
  config = nextConfig;
3373
+ policyConfigError = undefined;
3374
+ accountGroupResolutionInitialized = false;
3375
+ initializeSessionAccountGroupScope(context);
3288
3376
  await rediscover();
3289
3377
  },
3290
3378
  routingConfig: {
@@ -3332,8 +3420,8 @@ export const createMultiAccountExtension =
3332
3420
  // Group scope is stricter than the optional metered rung. For unrestricted
3333
3421
  // sessions, consent, delegate exclusion, session disablement, model syntax,
3334
3422
  // and the positive budget cap remain the authority for OpenRouter.
3335
- // A named account group is a closed dispatch set. Metered OpenRouter is
3336
- // intentionally outside every group and therefore cannot widen exhaustion.
3423
+ // A named group remains closed to metered OpenRouter even when listed.
3424
+ // Membership alone cannot enable or widen the existing metered gate.
3337
3425
  if (accountGroupScopeRestrictsRouting()) return false;
3338
3426
  const policy = openRouterPolicy();
3339
3427
  if (!policy.enabled) return false;
@@ -3475,12 +3563,31 @@ export const createMultiAccountExtension =
3475
3563
  providerId: string,
3476
3564
  inputContext: ExtensionContext,
3477
3565
  ): boolean => {
3566
+ if (policyConfigError !== undefined) return false;
3478
3567
  if (!accountGroupScopeRestrictsRouting()) return true;
3568
+ // Pi virtual routers may choose arbitrary physical targets. No public
3569
+ // cross-host hook here can authorize those targets before they send.
3570
+ // Check the selected model before the extension-owned unified exception.
3571
+ if (isAccountGroupVirtualModel(inputContext.model ?? currentModel)) return false;
3479
3572
  if (providerId === LOGICAL_PROVIDER_ID) return true;
3573
+ // Listing OpenRouter does not enable a metered route. Issue 138 owns
3574
+ // changing that gate; all existing consent/budget/delegate controls stay.
3575
+ if (providerId === OPENROUTER_PROVIDER_ID) return false;
3576
+ const resolution = effectiveAccountGroupResolution;
3577
+ if (accountGroupScopeBlocked || resolution === undefined || resolution.source === "unrestricted") {
3578
+ return false;
3579
+ }
3580
+ const members = config.accountGroups?.[resolution.groupId] ?? [];
3581
+ const modelId = inputContext.model?.id ?? currentModel?.id;
3582
+ const availability = accountGroupMemberAvailability(
3583
+ providerId, members,
3584
+ readAccountGroupRegistrySnapshot(inputContext.modelRegistry), modelId,
3585
+ );
3586
+ if (!availability.eligible) return false;
3587
+ // Membership never enrolls custom providers in extension-owned routing.
3588
+ if (classifyProviderId({ providerId, credentialType: "unknown" }) === null) return true;
3480
3589
  try {
3481
- return subscriptionOperatorAccounts(context ?? inputContext).some(
3482
- (account) => account.providerId === providerId,
3483
- );
3590
+ return physicalOperatorAccounts(context ?? inputContext).some((account) => account.providerId === providerId);
3484
3591
  } catch {
3485
3592
  return false;
3486
3593
  }
@@ -3525,12 +3632,29 @@ export const createMultiAccountExtension =
3525
3632
  ): Promise<{ readonly action: "continue" | "handled" }> => {
3526
3633
  const activeProviderId =
3527
3634
  inputContext.model?.provider ?? currentModel?.provider;
3635
+ if (policyConfigError !== undefined && activeProviderId === undefined) {
3636
+ inputContext.ui.notify(policyConfigError, "warning");
3637
+ return { action: "handled" };
3638
+ }
3528
3639
  if (
3529
3640
  activeProviderId === undefined ||
3530
3641
  providerAllowedByActiveGroup(activeProviderId, inputContext)
3531
3642
  ) {
3532
3643
  return { action: "continue" };
3533
3644
  }
3645
+ if (policyConfigError !== undefined) {
3646
+ inputContext.ui.notify(policyConfigError, "warning");
3647
+ return { action: "handled" };
3648
+ }
3649
+ if (isAccountGroupVirtualModel(inputContext.model ?? currentModel)) {
3650
+ // Never reinterpret a virtual selection as a managed physical origin
3651
+ // or replace it silently, even under a managed provider's name.
3652
+ inputContext.ui.notify(
3653
+ "Multi-account blocked a virtual model under the active account group. Select a physical model allowed by the group.",
3654
+ "warning",
3655
+ );
3656
+ return { action: "handled" };
3657
+ }
3534
3658
  const origin = accountGroupOrigin(
3535
3659
  activeProviderId,
3536
3660
  inputContext.model?.id ?? currentModel?.id,
@@ -3573,7 +3697,7 @@ export const createMultiAccountExtension =
3573
3697
  replacement = undefined;
3574
3698
  }
3575
3699
  }
3576
- if (replacement !== undefined) {
3700
+ if (replacement !== undefined && !isAccountGroupVirtualModel(replacement)) {
3577
3701
  try {
3578
3702
  if (await setModel(replacement)) {
3579
3703
  if (origin !== undefined) {
@@ -3780,6 +3904,11 @@ export const createMultiAccountExtension =
3780
3904
  */
3781
3905
  const settleTurn = async (): Promise<void> => {
3782
3906
  openRouterInputApproved = false;
3907
+ if (policyConfigError !== undefined) {
3908
+ pendingFailure = undefined;
3909
+ pendingOpenRouterFailure = false;
3910
+ return;
3911
+ }
3783
3912
  if (pendingOpenRouterFailure) {
3784
3913
  pendingOpenRouterFailure = false;
3785
3914
  const restored = await restoreOpenRouterOrigin();
@@ -3799,6 +3928,24 @@ export const createMultiAccountExtension =
3799
3928
  const origin = turnRouteOrigin;
3800
3929
  if (!classified || origin === undefined) return;
3801
3930
  const { providerId, family, failure } = classified;
3931
+ if (origin.logical) {
3932
+ // Unified recovers inside the model call, so its final failure already
3933
+ // spent the call's bounded sends, and message_end recorded its quota
3934
+ // effects. Settlement adds only a detached usage refresh for a quota
3935
+ // failure and a terminal-auth invalidation: never a switch, park,
3936
+ // OpenRouter rung, route pin, or fixed continuation.
3937
+ // Direct physical selections keep the legacy path below.
3938
+ clearTurnRouteOrigin();
3939
+ if (classified.failedAtMs !== undefined) {
3940
+ refreshUsageAfterLogicalQuotaFailure(
3941
+ providerId,
3942
+ family,
3943
+ classified.failedAtMs,
3944
+ );
3945
+ }
3946
+ await invalidateOnTerminalAuth(providerId, family, failure);
3947
+ return;
3948
+ }
3802
3949
  const accounts = physicalOperatorAccounts();
3803
3950
  const nowMs = Date.now();
3804
3951
  const routingAccounts = physicalAccountsForRouting(nowMs);
@@ -4159,6 +4306,15 @@ export const createMultiAccountExtension =
4159
4306
  const lastFailure = new Map<string, ProviderFailureSignal>();
4160
4307
  const handledFailures = new WeakSet<object>();
4161
4308
  const observedMessages = new WeakSet<object>();
4309
+ // Operator advice for a structured refusal. A delegate-owned in-process
4310
+ // session shares the foreground UI, so only the foreground advises.
4311
+ const refusalAdvisor = createRefusalAdvisor({
4312
+ // The advisor is constructed before binding; read retained ownership
4313
+ // when advising rather than freezing the factory-time foreground value.
4314
+ get foreground() {
4315
+ return !delegateOwnedSession;
4316
+ },
4317
+ });
4162
4318
  const recordUsage = (
4163
4319
  observation: () => UsageObservation | undefined,
4164
4320
  ): void => {
@@ -4308,8 +4464,9 @@ export const createMultiAccountExtension =
4308
4464
  const activeProviderId =
4309
4465
  runContext.model?.provider ?? currentModel?.provider;
4310
4466
  if (
4311
- activeProviderId !== undefined &&
4312
- !providerAllowedByActiveGroup(activeProviderId, runContext)
4467
+ (activeProviderId === undefined && policyConfigError !== undefined) ||
4468
+ (activeProviderId !== undefined &&
4469
+ !providerAllowedByActiveGroup(activeProviderId, runContext))
4313
4470
  ) {
4314
4471
  openRouterInputApproved = false;
4315
4472
  diagnostics.record(
@@ -4318,11 +4475,11 @@ export const createMultiAccountExtension =
4318
4475
  "A custom-message run selected a provider outside the active account group and was aborted before provider dispatch.",
4319
4476
  { providerId: activeProviderId },
4320
4477
  );
4478
+ runContext.abort();
4321
4479
  runContext.ui.notify(
4322
- "Multi-account aborted a provider run outside the active account group.",
4480
+ policyConfigError ?? "Multi-account aborted a provider run outside the active account group.",
4323
4481
  "warning",
4324
4482
  );
4325
- runContext.abort();
4326
4483
  return;
4327
4484
  }
4328
4485
  const ownsActiveOpenRouter =
@@ -4351,6 +4508,10 @@ export const createMultiAccountExtension =
4351
4508
  pi.on(
4352
4509
  "session_start",
4353
4510
  guarded("lifecycle.session-start", async (_event, startupContext) => {
4511
+ // Pi executes factories during resource loading, before pi-delegate
4512
+ // opens its trusted bind frame. Capture ownership here before any
4513
+ // UI or routing initialization, and retain it after the frame ends.
4514
+ delegateOwnedSession ||= isDelegateOwnedApi(pi);
4354
4515
  context = startupContext;
4355
4516
  try {
4356
4517
  logicalRouteIndicator?.shutdown();
@@ -4410,7 +4571,27 @@ export const createMultiAccountExtension =
4410
4571
  now: Date.now,
4411
4572
  });
4412
4573
  currentModel = startupContext.model;
4413
- config = readConfig(configPath);
4574
+ try {
4575
+ config = loadGlobalConfig();
4576
+ policyConfigError = undefined;
4577
+ } catch (error) {
4578
+ // loadGlobalConfig produces only bounded, field-only guidance.
4579
+ const message = (error as Error).message;
4580
+ if (policyConfigError !== undefined) {
4581
+ policyConfigError = message;
4582
+ const raw = readInstalledDeclaration(configPath) as
4583
+ | { accountLimit?: unknown }
4584
+ | undefined;
4585
+ config = {
4586
+ ...DEFAULT_CONFIG,
4587
+ accountLimit: isAccountLimit(raw?.accountLimit)
4588
+ ? raw.accountLimit
4589
+ : DEFAULT_CONFIG.accountLimit,
4590
+ };
4591
+ }
4592
+ diagnostics.record("warning", "config.invalid", message);
4593
+ startupContext.ui.notify(message, "warning");
4594
+ }
4414
4595
  // Resolve and durably cache the session policy before rediscovery can
4415
4596
  // publish any request-capable provider surface.
4416
4597
  accountGroupResolutionInitialized = false;
@@ -4575,10 +4756,32 @@ export const createMultiAccountExtension =
4575
4756
  : decorateLogicalAttributionLifecycle(store, indicator);
4576
4757
  },
4577
4758
  deps: {
4759
+ captureSelectionSnapshot(modelId) {
4760
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
4761
+ if (!accountGroupResolutionInitialized) initializeSessionAccountGroupScope(context);
4762
+ const resolution = effectiveAccountGroupResolution;
4763
+ const members = resolution === undefined || resolution.source === "unrestricted" ? [] : [...(config.accountGroups?.[resolution.groupId] ?? [])];
4764
+ const policy = resolution === undefined || accountGroupScopeBlocked || policyConfigError !== undefined
4765
+ ? { kind: "blocked" as const, reason: policyConfigError !== undefined ? "config-invalid" as const : accountGroupBlockReason ?? "context-unavailable" }
4766
+ : { kind: "resolved" as const, resolution: resolution.source === "unrestricted" ? { source: "unrestricted" as const } : { source: resolution.source, groupId: resolution.groupId }, members };
4767
+ const accountLimit = config.accountLimit;
4768
+ const nowMs = Date.now();
4769
+ const rows = operatorAccounts(context, discovery, authPath, accountLimit).filter((account) => !disabledProviders.has(account.providerId));
4770
+ const allAccounts = logicalAccountsFromManaged(physicalAccountsForRouting(nowMs, rows), nowMs);
4771
+ const authorized = new Set(enforceSessionAccountGroup(rows, context).map((row) => row.providerId));
4772
+ const registry = readAccountGroupRegistrySnapshot(context?.modelRegistry);
4773
+ const physicalModels = context?.modelRegistry.getAll().filter((row) => !isAccountGroupVirtualModel(row)) ?? [];
4774
+ const otherCandidates = [...registry.providerIds].filter((providerId) => providerId !== LOGICAL_PROVIDER_ID && providerId !== OPENROUTER_PROVIDER_ID && classifyProviderId({ providerId, credentialType: "unknown" }) === null).map((providerId) => {
4775
+ const availability = accountGroupMemberAvailability(providerId, [providerId], registry, modelId);
4776
+ return { providerId, eligible: availability.eligible, reason: availability.reason, servesModel: physicalModels.some((row) => row.provider === providerId && row.id === modelId) };
4777
+ });
4778
+ return { accounts: allAccounts.filter((row) => authorized.has(row.providerId)), group: { policy, accountLimit, allAccounts, otherCandidates } };
4779
+ },
4578
4780
  // A getter, not an array. `clear`, `disable`, expiry and
4579
4781
  // provider-reported exhaustion must take effect on the next
4580
4782
  // request, not at the next restart.
4581
4783
  get accounts() {
4784
+ if (policyConfigError !== undefined) throw new Error(policyConfigError);
4582
4785
  const nowMs = Date.now();
4583
4786
  return logicalAccountsFromManaged(
4584
4787
  physicalAccountsForRouting(nowMs),
@@ -4588,16 +4791,27 @@ export const createMultiAccountExtension =
4588
4791
  dispatch: createLogicalDispatch(context.modelRegistry),
4589
4792
  onPublicTerminal: logicalTerminalAssociations.bindPublicTerminal,
4590
4793
  state,
4591
- routePin: {
4592
- get: () => state.getLogicalRoutePin(),
4593
- consume: (generation, requestedModelId) =>
4594
- state.consumeLogicalRoutePin(generation, requestedModelId),
4595
- clear: () => state.clearLogicalRoutePin(),
4794
+ // A recovered-past attempt never reaches message_end; commit its
4795
+ // account effects through its own exact association.
4796
+ onSupersededTerminal: (physical: AssistantMessage) => {
4797
+ commitSupersededLogicalFailure(physical);
4798
+ },
4799
+ get recoveryTiming() {
4800
+ return {
4801
+ recoveryIdleTimeoutMs: config.recoveryIdleTimeoutMs,
4802
+ recoveryAbsoluteTimeoutMs: config.recoveryAbsoluteTimeoutMs,
4803
+ recoveryStallTimeoutMs: config.recoveryStallTimeoutMs,
4804
+ };
4596
4805
  },
4597
4806
  modelVendor,
4598
4807
  get tierModelMap() {
4599
4808
  return config.tierModelMap;
4600
4809
  },
4810
+ // A getter, so a reload that turns same-family failover off
4811
+ // stops the next call's in-call account switch.
4812
+ get sameFamilyFailover() {
4813
+ return config.sameFamilyFailover;
4814
+ },
4601
4815
  ...(config.crossFamilyChainEnabled
4602
4816
  ? { crossFamilyChains: config.crossFamilyChains }
4603
4817
  : {}),
@@ -4660,20 +4874,22 @@ export const createMultiAccountExtension =
4660
4874
  // a fault in the logical gate must not take them down with it.
4661
4875
  diagnostics.recordError("logical.registration", error);
4662
4876
  }
4663
- void Promise.resolve()
4664
- .then(() =>
4665
- warmer.start(
4666
- () =>
4667
- projectAutomaticAccountCandidates(
4668
- discovery,
4669
- config.accountLimit,
4670
- ),
4671
- config,
4672
- ),
4673
- )
4674
- .catch((error) => {
4675
- diagnostics.recordError("credential.warmer.start", error);
4676
- });
4877
+ if (policyConfigError === undefined) {
4878
+ void Promise.resolve()
4879
+ .then(() =>
4880
+ warmer.start(
4881
+ () =>
4882
+ projectAutomaticAccountCandidates(
4883
+ discovery,
4884
+ config.accountLimit,
4885
+ ),
4886
+ config,
4887
+ ),
4888
+ )
4889
+ .catch((error) => {
4890
+ diagnostics.recordError("credential.warmer.start", error);
4891
+ });
4892
+ }
4677
4893
  // Pi restores the session model inside createAgentSession, strictly
4678
4894
  // BEFORE extensions load, so a managed alias does not exist yet and the
4679
4895
  // host falls back to a base provider. Now that the aliases are
@@ -4740,6 +4956,7 @@ export const createMultiAccountExtension =
4740
4956
  guarded(
4741
4957
  "lifecycle.before-agent-start",
4742
4958
  async (_event, upcomingContext) => {
4959
+ if (policyConfigError !== undefined) return;
4743
4960
  // The warmer and usage fetcher each project independently, so
4744
4961
  // removing one call-site guard cannot expose the sibling sink.
4745
4962
  void Promise.resolve()
@@ -4775,6 +4992,10 @@ export const createMultiAccountExtension =
4775
4992
  const accounts = subscriptionOperatorAccounts(
4776
4993
  context ?? upcomingContext,
4777
4994
  );
4995
+ for (const account of accounts) {
4996
+ const revision = normalizeCredentialRevision(account.credentialRevision ?? NaN);
4997
+ if (revision !== undefined) state.observeCredentialRevision(account.providerId, account.family, revision);
4998
+ }
4778
4999
  const nowMs = Date.now();
4779
5000
  const managedAccounts: SubscriptionManagedAccount[] = accounts.map(
4780
5001
  ({
@@ -4991,6 +5212,137 @@ export const createMultiAccountExtension =
4991
5212
  });
4992
5213
  }),
4993
5214
  );
5215
+ /**
5216
+ * A unified terminal-auth failure: invalidate the account, unless an
5217
+ * explicit 401 on a subscription account is repaired by the one forced
5218
+ * refresh the session allows it. The refresh only restores the account for
5219
+ * later calls; it never sends this call again.
5220
+ */
5221
+ const invalidateOnTerminalAuth = async (
5222
+ providerId: string,
5223
+ family: ManagedFamily,
5224
+ failure: ProviderFailureSignal,
5225
+ ): Promise<void> => {
5226
+ // An explicit 401 gets the one forced refresh whatever else the body
5227
+ // says, as direct settlement does: a server-revoked token can still
5228
+ // look locally valid.
5229
+ if (
5230
+ failure.httpStatus === 401 &&
5231
+ forcedCredentialRefresher !== undefined &&
5232
+ isAllowedFamily(family)
5233
+ ) {
5234
+ const outcome = await forcedCredentialRefresher
5235
+ .attempt(providerId, family)
5236
+ .catch(() => "failed" as const);
5237
+ if (outcome === "refreshed") return;
5238
+ }
5239
+ if (classifyFailure(failure).category !== "terminal-auth") return;
5240
+ state.invalidateAccount({
5241
+ providerId,
5242
+ family,
5243
+ reason: "terminal-auth-failure",
5244
+ invalidatedAtMs: Date.now(),
5245
+ });
5246
+ };
5247
+ /**
5248
+ * One opportunistic usage refresh for the physical account a unified
5249
+ * quota failure refused on, as direct settlement does for its own.
5250
+ *
5251
+ * Detached and fail-soft: it improves routing state and never delays or
5252
+ * changes the call. Only named fields reach the fetcher, and only for a
5253
+ * live subscription account; an owning-vendor API account has no pooled
5254
+ * usage to refresh.
5255
+ */
5256
+ const refreshUsageAfterLogicalQuotaFailure = (
5257
+ providerId: string,
5258
+ family: ManagedFamily,
5259
+ failedAtMs: number,
5260
+ ): void => {
5261
+ try {
5262
+ const account = physicalOperatorAccounts().find(
5263
+ (candidate) =>
5264
+ candidate.providerId === providerId && candidate.family === family,
5265
+ );
5266
+ if (account === undefined || !isRoutingEligibleAccountFamily(account)) {
5267
+ return;
5268
+ }
5269
+ void usageFetcher
5270
+ .refreshAfterFailure(
5271
+ {
5272
+ providerId: account.providerId,
5273
+ family: account.family,
5274
+ ...(account.credentialType === undefined
5275
+ ? {}
5276
+ : { credentialType: account.credentialType }),
5277
+ },
5278
+ config,
5279
+ failedAtMs,
5280
+ )
5281
+ .catch(() => undefined);
5282
+ } catch {
5283
+ // A refresh is an improvement, never a step the call depends on.
5284
+ }
5285
+ };
5286
+ /**
5287
+ * Account effects of one unified physical attempt the call recovered past.
5288
+ *
5289
+ * It never reaches message_end, so this applies what message_end and
5290
+ * settlement would: the quota limit streak and, when the provider gave no
5291
+ * recovery time, a bounded exhaustion hold, and one detached usage
5292
+ * refresh; or a terminal-auth invalidation. Its cooldown was already
5293
+ * written before the engine chose the next send. Nothing here switches the
5294
+ * model, continues, parks, or pins.
5295
+ */
5296
+ const applyLogicalFailureEffects = (
5297
+ association: {
5298
+ readonly route: LogicalRouteFact;
5299
+ readonly response?: ProviderFailureSignal;
5300
+ readonly failure?: ProviderFailureSignal;
5301
+ },
5302
+ dispatchedModelId: string,
5303
+ ): void => {
5304
+ const route = association.route;
5305
+ const providerId = route.providerId;
5306
+ const failure: ProviderFailureSignal = {
5307
+ ...(association.failure ?? {}),
5308
+ ...(association.response ?? {}),
5309
+ modelId: dispatchedModelId,
5310
+ };
5311
+ if (classifyFailure(failure).category === "quota-rate-limit") {
5312
+ const failedAtMs = Date.now();
5313
+ state.recordLimitError(providerId, failedAtMs);
5314
+ // Only the subscription families have shared usage state; an
5315
+ // owning-vendor API account is billed per request, not pooled.
5316
+ if (isAllowedFamily(route.family) && refusalWithoutRecoveryTime(failure)) {
5317
+ usage.recordExhaustionHold(providerId, route.family, failedAtMs);
5318
+ }
5319
+ refreshUsageAfterLogicalQuotaFailure(providerId, route.family, failedAtMs);
5320
+ }
5321
+ void invalidateOnTerminalAuth(providerId, route.family, failure).catch(() => undefined);
5322
+ };
5323
+ const commitSupersededLogicalFailure = (physical: AssistantMessage): void => {
5324
+ const association = logicalTerminalAssociations.consume(physical);
5325
+ let accepted = false;
5326
+ try {
5327
+ if (
5328
+ association !== undefined &&
5329
+ association.outcome === "fail" &&
5330
+ association.dispatchedModelId !== undefined &&
5331
+ boundedPhysicalIdentity(association.dispatchedModelId)
5332
+ ) {
5333
+ applyLogicalFailureEffects(association, association.dispatchedModelId);
5334
+ accepted = true;
5335
+ }
5336
+ } catch {
5337
+ diagnostics.record(
5338
+ "warning",
5339
+ "logical.attribution",
5340
+ "A recovered unified attempt's account effects could not be applied.",
5341
+ );
5342
+ } finally {
5343
+ logicalTerminalAssociations.complete(association, accepted);
5344
+ }
5345
+ };
4994
5346
  // Observation and classification only. Every switch, park, or resume waits
4995
5347
  // for `agent_settled`; see `settleTurn` above.
4996
5348
  pi.on("message_end", async (event, messageContext) => {
@@ -5153,6 +5505,17 @@ export const createMultiAccountExtension =
5153
5505
  acceptedLogicalAssociation,
5154
5506
  );
5155
5507
  }
5508
+ // Advice only: names the physical route that refused, never routes.
5509
+ refusalAdvisor.advise(
5510
+ originalMessage,
5511
+ messageContext,
5512
+ acceptedLogicalAssociation && logicalAssociation !== undefined
5513
+ ? {
5514
+ providerId: logicalAssociation.route.providerId,
5515
+ modelId: logicalAssociation.dispatchedModelId,
5516
+ }
5517
+ : {},
5518
+ );
5156
5519
 
5157
5520
  try {
5158
5521
  const validOriginalMessage = originalMessage as AssistantMessage;
@@ -5240,6 +5603,8 @@ export const createMultiAccountExtension =
5240
5603
  ) {
5241
5604
  return;
5242
5605
  }
5606
+ // Advice only: a managed account's structured refusal never routes.
5607
+ refusalAdvisor.advise(event.message, messageContext, identity);
5243
5608
  const subscriptionFamily = isRoutingEligibleAccountFamily(messageSlot)
5244
5609
  ? messageSlot.family
5245
5610
  : undefined;