@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/README.md CHANGED
@@ -6,7 +6,7 @@ Concurrent Anthropic, OpenAI Codex, and Google Antigravity OAuth accounts for Pi
6
6
 
7
7
  ## What it does
8
8
 
9
- When a managed account reaches a limit or loses authorization, `pi-multi-account` waits for Pi's own retries to settle, selects an eligible route, and sends one fixed continuation. It never replays the failed provider request.
9
+ When a managed account reaches a limit or loses authorization, `pi-multi-account` waits for Pi's own retries to settle, selects an eligible route, and sends one fixed continuation. It never replays the failed provider request, with one bounded exception: a `unified` logical call that fails before showing any output may move once to another account serving the same model.
10
10
 
11
11
  - Registers numbered OAuth account aliases without copying credentials out of Pi's `AuthStorage`.
12
12
  - Routes within a provider family first, then through explicit owning-vendor or cross-family policy.
@@ -54,7 +54,7 @@ The extension reads one machine-global file and no project-local config:
54
54
  $PI_CODING_AGENT_DIR/pi-multi-account/config.json
55
55
  ```
56
56
 
57
- When `PI_CODING_AGENT_DIR` is unset, the root is `~/.pi/agent`. Missing configuration uses conservative defaults. Same-family failover and usage fetches ship on; cross-family routing and OpenRouter ship off. Malformed or unknown fields fail extension initialization closed while Pi keeps running.
57
+ When `PI_CODING_AGENT_DIR` is unset, the root is `~/.pi/agent`. Missing configuration uses conservative defaults. Same-family failover and usage fetches ship on; cross-family routing and OpenRouter ship off. Malformed or unknown fields keep discovery, `/login`, and repair commands available, but block requests and automatic routing until valid config is loaded. Correct the global config and run `/multi-account reload` or `/reload`. A failed reload retains the last valid policy.
58
58
 
59
59
  | Key | Default | Purpose |
60
60
  | --- | --- | --- |
@@ -65,13 +65,27 @@ When `PI_CODING_AGENT_DIR` is unset, the root is `~/.pi/agent`. Missing configur
65
65
  | `accountGroups` and defaults | `{}` | Restrict a session, exact cwd, or global fallback to named account allow-lists. |
66
66
  | `usageFetchEnabled` | all managed families `true` | Enable fail-soft provider usage fetches. |
67
67
 
68
+ Account groups may list exact configured Pi provider IDs, including built-in API
69
+ and custom providers. Unknown, removed, or unavailable members stay inactive;
70
+ other listed usable members can serve, with no fallback outside the group.
71
+ Membership does not transfer credential or routing ownership to this extension.
72
+ Under an active group, select physical models: host virtual models can choose
73
+ other providers and are unsupported. Physical models on mixed providers still work.
74
+ Unrestricted virtual selection keeps the host's behavior.
75
+ When a group prevents `unified` selection, the turn error names the group, its
76
+ original source, and a bounded cause. It reports outside-group availability
77
+ without naming excluded accounts. This guidance does not authorize a send or
78
+ prove that request credentials have resolved.
79
+ Listing `openrouter` does not enable it: the current named-group metered block
80
+ remains in force.
81
+
68
82
  The full schema, defaults, examples, timing controls, labels, and rate-history fields are in [Configuration reference](docs/configuration.md).
69
83
 
70
84
  ## When it runs
71
85
 
72
86
  The extension discovers accounts and registers aliases at `session_start`. It checks account-group and OpenRouter safety before a run, tracks model selection and provider responses, repairs failed assistant history at `message_end`, and releases resources at `session_shutdown`.
73
87
 
74
- Reactive routing starts only at `agent_settled`, after Pi's provider retry and compaction loop. A classified provider failure can schedule one fixed `deliverAs: "followUp"` continuation. It does not replay the prompt, provider request, or uncertain tool work. More detail is in [Routing and recovery](docs/routing.md).
88
+ Reactive routing starts only at `agent_settled`, after Pi's provider retry and compaction loop. A classified provider failure can schedule one fixed `deliverAs: "followUp"` continuation. It does not replay the prompt, provider request, or uncertain tool work. A `unified` call is different: it recovers inside the call, before any output, with at most two physical sends, and never continues after settlement. `recoveryStallTimeoutMs` bounds the wait for a physical attempt's response to start. More detail is in [Routing and recovery](docs/routing.md#in-call-recovery).
75
89
 
76
90
  ## Develop
77
91
 
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.1.4",
2
+ "version": "0.1.6",
3
3
  "description": "Global Anthropic and OpenAI Codex multi-account OAuth routing for Pi.",
4
4
  "type": "module",
5
5
  "bin": {
@@ -59,11 +59,11 @@
59
59
  "undici": "^8.10.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@earendil-works/pi-ai": "0.84.4",
63
- "@earendil-works/pi-coding-agent": "0.84.4",
64
- "@earendil-works/pi-tui": "0.84.4",
62
+ "@earendil-works/pi-ai": "^1.0.2",
63
+ "@earendil-works/pi-coding-agent": "^1.0.2",
64
+ "@earendil-works/pi-tui": "^1.0.2",
65
65
  "@types/node": "^24.0.0",
66
- "typebox": "1.3.7",
66
+ "typebox": "1.3.34",
67
67
  "typescript": "^5.9.0",
68
68
  "vitest": "^4.1.11"
69
69
  },
@@ -47,8 +47,8 @@
47
47
  "@earendil-works/pi-coding-agent": ">=0.80.8"
48
48
  },
49
49
  "devDependencies": {
50
- "@earendil-works/pi-ai": "0.84.4",
51
- "@earendil-works/pi-coding-agent": "0.84.4",
50
+ "@earendil-works/pi-ai": ">=0.84.4",
51
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
52
52
  "typescript": "^5.9.3"
53
53
  }
54
54
  }
@@ -10,6 +10,7 @@ import {
10
10
  type Model,
11
11
  type SimpleStreamOptions,
12
12
  type StopReason,
13
+ type ToolCall,
13
14
  } from "@earendil-works/pi-ai";
14
15
  import { isClaudeOAuthAccessToken, USER_AGENT } from "./auth.js";
15
16
  import {
@@ -20,6 +21,10 @@ import {
20
21
  } from "./convert.js";
21
22
  import { projectContext } from "./context.js";
22
23
  import { buildAnthropicSystemPrompt } from "./prompt.js";
24
+ import {
25
+ createTransportActivityFetch,
26
+ transportActivityListener,
27
+ } from "./transport-activity.js";
23
28
 
24
29
  const REQUIRED_BETAS = [
25
30
  "claude-code-20250219",
@@ -132,12 +137,19 @@ export function streamAnthropicOAuth(
132
137
 
133
138
  if (isOAuth) defaultHeaders.authorization = `Bearer ${apiKey}`;
134
139
 
140
+ // A caller that supplies onTransportActivity hears about every response
141
+ // body chunk, including the pings the SDK drops; otherwise the SDK keeps
142
+ // its default fetch.
143
+ const onTransportActivity = transportActivityListener(options);
135
144
  const client = new Anthropic({
136
145
  baseURL: model.baseUrl,
137
146
  apiKey: isOAuth ? null : apiKey,
138
147
  authToken: isOAuth ? apiKey : null,
139
148
  defaultHeaders,
140
149
  dangerouslyAllowBrowser: true,
150
+ ...(onTransportActivity === undefined
151
+ ? {}
152
+ : { fetch: createTransportActivityFetch(onTransportActivity) }),
141
153
  });
142
154
 
143
155
  const maxTokens =
@@ -221,10 +233,18 @@ export function streamAnthropicOAuth(
221
233
  // which throws under fine-grained-tool-streaming (input may be invalid
222
234
  // mid-flight) and aborts the turn. The raw stream yields the same
223
235
  // RawMessageStreamEvents; tool args are already parsed leniently below.
236
+ // A finite non-negative integer request-local maxRetries bounds the SDK's
237
+ // own retries (0 sends exactly once); any other value keeps the SDK default.
238
+ const maxRetries = options?.maxRetries;
224
239
  const { data: anthropicStream, response: httpResponse } =
225
240
  await client.messages
226
241
  .create(params, {
227
242
  signal: options?.signal,
243
+ ...(Number.isFinite(maxRetries) &&
244
+ Number.isInteger(maxRetries) &&
245
+ (maxRetries ?? -1) >= 0
246
+ ? { maxRetries }
247
+ : {}),
228
248
  })
229
249
  .withResponse();
230
250
 
@@ -363,10 +383,9 @@ export function streamAnthropicOAuth(
363
383
  ) {
364
384
  block.partialJson += event.delta.partial_json;
365
385
  try {
366
- block.arguments = JSON.parse(block.partialJson) as Record<
367
- string,
368
- unknown
369
- >;
386
+ block.arguments = JSON.parse(
387
+ block.partialJson,
388
+ ) as ToolCall["arguments"];
370
389
  } catch {}
371
390
  stream.push({
372
391
  type: "toolcall_delta",
@@ -402,10 +421,9 @@ export function streamAnthropicOAuth(
402
421
  });
403
422
  } else if (block.type === "toolCall") {
404
423
  try {
405
- block.arguments = JSON.parse(block.partialJson) as Record<
406
- string,
407
- unknown
408
- >;
424
+ block.arguments = JSON.parse(
425
+ block.partialJson,
426
+ ) as ToolCall["arguments"];
409
427
  } catch {}
410
428
  delete (block as { partialJson?: string }).partialJson;
411
429
  stream.push({
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Byte-level transport liveness for one streamed Anthropic request.
3
+ *
4
+ * The Anthropic SDK drops the server's `ping` events before they reach a
5
+ * stream consumer, so a model that thinks silently for minutes is
6
+ * indistinguishable from a dead connection at the event level. A caller that
7
+ * needs to tell them apart passes `onTransportActivity` in the request options;
8
+ * this wrapper then reports every response body chunk (pings included) without
9
+ * reading, buffering, or retaining its content.
10
+ *
11
+ * The callback receives no arguments. A throwing callback is contained so it
12
+ * can never break the response. Without the option the SDK keeps its default
13
+ * fetch and the request path is unchanged.
14
+ */
15
+
16
+ export type TransportActivityListener = () => void;
17
+
18
+ export function transportActivityListener(
19
+ options: unknown,
20
+ ): TransportActivityListener | undefined {
21
+ if (typeof options !== "object" || options === null) return undefined;
22
+ const listener = (options as { onTransportActivity?: unknown })
23
+ .onTransportActivity;
24
+ return typeof listener === "function"
25
+ ? (listener as TransportActivityListener)
26
+ : undefined;
27
+ }
28
+
29
+ export function createTransportActivityFetch(
30
+ onActivity: TransportActivityListener,
31
+ ): typeof fetch {
32
+ // Read the global at construction, as the SDK's own default does.
33
+ const baseFetch = globalThis.fetch;
34
+ const notify = (): void => {
35
+ try {
36
+ onActivity();
37
+ } catch {
38
+ // Liveness reporting is best-effort and must not break the response.
39
+ }
40
+ };
41
+ return async (input, init) => {
42
+ const response = await baseFetch(input, init);
43
+ const body = response.body;
44
+ if (body === null) return response;
45
+ const observed = body.pipeThrough(
46
+ new TransformStream<Uint8Array, Uint8Array>({
47
+ transform(chunk, controller) {
48
+ notify();
49
+ controller.enqueue(chunk);
50
+ },
51
+ }),
52
+ );
53
+ return new Response(observed, {
54
+ status: response.status,
55
+ statusText: response.statusText,
56
+ headers: response.headers,
57
+ });
58
+ };
59
+ }
@@ -60,13 +60,13 @@
60
60
  "@earendil-works/pi-coding-agent": ">=0.80.0"
61
61
  },
62
62
  "devDependencies": {
63
- "@earendil-works/pi-ai": "^0.84.2",
63
+ "@earendil-works/pi-ai": ">=0.84.2",
64
64
  "@eslint/js": "^10.0.1",
65
65
  "@types/node": "^26.1.1",
66
66
  "eslint": "^10.7.0",
67
67
  "eslint-config-prettier": "^10.1.8",
68
68
  "globals": "^17.7.0",
69
- "pi-ai-087": "npm:@earendil-works/pi-ai@0.87.1",
69
+ "pi-ai-087": "npm:@earendil-works/pi-ai@>=0.87.1",
70
70
  "prettier": "^3.9.5",
71
71
  "typescript": "^6.0.3",
72
72
  "typescript-eslint": "^8.64.0"
@@ -19,6 +19,7 @@ import {
19
19
  registerDiscoveredModelEnums,
20
20
  restoreDynamicModelEnums,
21
21
  snapshotDynamicModelEnums,
22
+ type AntigravityModelConfig,
22
23
  } from "./models.js";
23
24
 
24
25
  export const DEFAULT_CATALOG_REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000;
@@ -144,7 +145,7 @@ function apiKeyFromCredential(credential: Credential | undefined): string | unde
144
145
  return undefined;
145
146
  }
146
147
 
147
- function toStoredModels(models: ProviderModelConfig[]): Model<Api>[] {
148
+ function toStoredModels(models: AntigravityModelConfig[]): Model<Api>[] {
148
149
  return models.map((model) => ({
149
150
  ...model,
150
151
  api: ANTIGRAVITY_API,
@@ -1,9 +1,9 @@
1
- import type { ProviderModelConfig } from "@earendil-works/pi-coding-agent";
1
+ import type { AntigravityModelConfig } from "./models.js";
2
2
  import { ThinkingEffort } from "../types/enums.js";
3
3
  import type { AntigravityRouting, ModelInfoRaw } from "../types/types.js";
4
4
 
5
5
  export type AntigravityCatalog = {
6
- models: ProviderModelConfig[];
6
+ models: AntigravityModelConfig[];
7
7
  routing: Record<string, AntigravityRouting>;
8
8
  };
9
9
 
@@ -108,7 +108,7 @@ export function buildAntigravityCatalog(
108
108
 
109
109
  if (groups.size === 0) return fallback;
110
110
 
111
- const models: ProviderModelConfig[] = [];
111
+ const models: AntigravityModelConfig[] = [];
112
112
  const routing: Record<string, AntigravityRouting> = {};
113
113
 
114
114
  for (const group of groups.values()) {
@@ -222,8 +222,8 @@ function displayFamily(displayName: string | undefined): string | undefined {
222
222
 
223
223
  function synthesizeModel(
224
224
  group: RuntimeGroup,
225
- fallbackModels: ProviderModelConfig[],
226
- ): { model: ProviderModelConfig; routing: AntigravityRouting } {
225
+ fallbackModels: AntigravityModelConfig[],
226
+ ): { model: AntigravityModelConfig; routing: AntigravityRouting } {
227
227
  const template = familyTemplate(group.publicId, fallbackModels);
228
228
  const advertisedLevels = advertisedThinkingLevels(group);
229
229
  const routing = routingFromVariants(group.publicId, group.variants, group.unsuffixed);
@@ -322,8 +322,10 @@ function routingFromVariants(
322
322
  };
323
323
  }
324
324
 
325
- function thinkingLevelMapFromLevels(levels: Set<string>): ProviderModelConfig["thinkingLevelMap"] {
326
- const map: NonNullable<ProviderModelConfig["thinkingLevelMap"]> = {};
325
+ function thinkingLevelMapFromLevels(
326
+ levels: Set<string>,
327
+ ): AntigravityModelConfig["thinkingLevelMap"] {
328
+ const map: NonNullable<AntigravityModelConfig["thinkingLevelMap"]> = {};
327
329
  for (const level of PI_LEVELS) {
328
330
  map[level] = levels.has(level) ? level : null;
329
331
  }
@@ -333,8 +335,8 @@ function thinkingLevelMapFromLevels(levels: Set<string>): ProviderModelConfig["t
333
335
 
334
336
  function familyTemplate(
335
337
  publicId: string,
336
- fallbackModels: ProviderModelConfig[],
337
- ): ProviderModelConfig | undefined {
338
+ fallbackModels: AntigravityModelConfig[],
339
+ ): AntigravityModelConfig | undefined {
338
340
  if (/^gemini-.*-flash/i.test(publicId)) {
339
341
  return fallbackModels.find((model) => /^gemini-.*-flash/i.test(model.id));
340
342
  }
@@ -403,7 +405,7 @@ function parseGeminiVersion(id: string): number {
403
405
  return Number(match[1]) * 1000 + Number(match[2] || 0);
404
406
  }
405
407
 
406
- function comparePublicModels(a: ProviderModelConfig, b: ProviderModelConfig): number {
408
+ function comparePublicModels(a: AntigravityModelConfig, b: AntigravityModelConfig): number {
407
409
  const rankA = modelRank(a.id);
408
410
  const rankB = modelRank(b.id);
409
411
  if (rankA[0] !== rankB[0]) return rankA[0] - rankB[0];
@@ -3,6 +3,13 @@ import type { AntigravityRouting, ThinkingWire } from "../types/types.js";
3
3
  import { ThinkingEffort } from "../types/enums.js";
4
4
  import type { AntigravityCatalog } from "./grouping.js";
5
5
 
6
+ /**
7
+ * Chat-model registration shape. Pi 1.0 widened `ProviderModelConfig` to a union
8
+ * of chat, image, and classifier configs; Antigravity registers chat models only.
9
+ * On older Pi hosts the type is not a union and this resolves to it unchanged.
10
+ */
11
+ export type AntigravityModelConfig = Extract<ProviderModelConfig, { reasoning: boolean }>;
12
+
6
13
  export const PROVIDER_ID = "antigravity";
7
14
  export const PROVIDER_NAME = "Antigravity";
8
15
 
@@ -210,10 +217,10 @@ const thinkingLevelMaps = {
210
217
  xhigh: null,
211
218
  max: null,
212
219
  },
213
- } satisfies Record<string, ProviderModelConfig["thinkingLevelMap"]>;
220
+ } satisfies Record<string, AntigravityModelConfig["thinkingLevelMap"]>;
214
221
 
215
222
  /** Same set as `agy models`, collapsed to public Pi model IDs. */
216
- export const ANTIGRAVITY_MODELS: ProviderModelConfig[] = [
223
+ export const ANTIGRAVITY_MODELS: AntigravityModelConfig[] = [
217
224
  {
218
225
  id: "gemini-3.8-flash",
219
226
  name: "Gemini 3.8 Flash (Antigravity)",
@@ -296,10 +303,10 @@ export const ANTIGRAVITY_MODELS: ProviderModelConfig[] = [
296
303
  },
297
304
  ];
298
305
 
299
- let currentModels: ProviderModelConfig[] = ANTIGRAVITY_MODELS;
306
+ let currentModels: AntigravityModelConfig[] = ANTIGRAVITY_MODELS;
300
307
  let currentRouting: Record<string, AntigravityRouting> = { ...ANTIGRAVITY_ROUTING };
301
308
 
302
- export function getCurrentAntigravityModels(): ProviderModelConfig[] {
309
+ export function getCurrentAntigravityModels(): AntigravityModelConfig[] {
303
310
  return currentModels;
304
311
  }
305
312
 
@@ -0,0 +1,149 @@
1
+ import { isAccountGroupMemberReference, type AccountGroupMemberAvailability } from "./account-group-members.js";
2
+ import { MANAGED_FAMILIES } from "./config.js";
3
+ import type { EffectiveAccountGroupResolution } from "./group-policy.js";
4
+
5
+ export type AccountGroupFailureBlockReason =
6
+ | "delegate-origin-unresolved" | "context-unavailable"
7
+ | "driver-origin-unresolved" | "delegate-resolution-unavailable"
8
+ | "driver-resolution-unavailable" | "config-invalid" | "resolution-failed";
9
+ const BLOCK_REASONS: readonly AccountGroupFailureBlockReason[] = [
10
+ "delegate-origin-unresolved", "context-unavailable", "driver-origin-unresolved",
11
+ "delegate-resolution-unavailable", "driver-resolution-unavailable", "config-invalid", "resolution-failed",
12
+ ];
13
+
14
+ /** Already-authorized policy only; never resolve a cwd or a delegate identity here. */
15
+ export type AccountGroupFailurePolicy =
16
+ | { readonly kind: "resolved"; readonly resolution: EffectiveAccountGroupResolution; readonly members: readonly string[] }
17
+ | { readonly kind: "blocked"; readonly reason: AccountGroupFailureBlockReason };
18
+
19
+ /** Copied availability facts, not credentials, status metadata or raw provider errors. */
20
+ export interface AccountGroupFailureCandidate extends Pick<AccountGroupMemberAvailability, "providerId" | "eligible"> {
21
+ readonly servesModel: boolean;
22
+ readonly authenticated?: boolean;
23
+ readonly exhausted?: boolean;
24
+ readonly coolingDown?: boolean;
25
+ readonly reason?: AccountGroupMemberAvailability["reason"];
26
+ }
27
+
28
+ export type AccountGroupFailureReason =
29
+ | AccountGroupFailureBlockReason | "policy-unavailable"
30
+ | "no-serving-members" | "no-managed-serving-members"
31
+ | "authentication-unavailable" | "cooldown" | "exhausted"
32
+ | "authorization-snapshot-unavailable" | "availability-unresolved";
33
+
34
+ export interface AccountGroupFailureView {
35
+ readonly reason: AccountGroupFailureReason;
36
+ readonly source?: EffectiveAccountGroupResolution["source"];
37
+ readonly groupId?: string;
38
+ readonly outsideEligible: boolean;
39
+ readonly message: string;
40
+ readonly help: string;
41
+ }
42
+
43
+ export interface AccountGroupFailureInput {
44
+ readonly policy: AccountGroupFailurePolicy;
45
+ readonly modelId: string;
46
+ readonly accountLimit: number;
47
+ readonly candidates: readonly AccountGroupFailureCandidate[];
48
+ }
49
+
50
+ // Pinned pi-ai retry.js/overflow.js act on error prose, not a group error code.
51
+ // Plain labels cannot contain the space-bearing patterns. Exclude the remaining
52
+ // compact patterns too; never manufacture an AssistantMessage to validate text.
53
+ const HOST_TRIGGER_LABEL = /overloaded|rate.?limit|429|500|502|503|504|524|service.?unavailable|server.?error|internal.?error|provider.?returned.?error|network.?error|connection.?(?:error|refused|lost)|getaddrinfo|ENOTFOUND|EAI_AGAIN|upstream.?connect|timeout|terminated|websocket.?(?:closed|error)|ResourceExhausted|request_too_large|model_context_window_exceeded|context[_]length[_]exceeded/i;
54
+
55
+ function safeLabel(value: string, maxLength: number, fallback: string): string {
56
+ return typeof value === "string" && value.length <= maxLength &&
57
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) && !HOST_TRIGGER_LABEL.test(value)
58
+ ? value : fallback;
59
+ }
60
+
61
+ function managedProvider(providerId: string): boolean {
62
+ return MANAGED_FAMILIES.some((family) => providerId === family || providerId.startsWith(`${family}-account-`));
63
+ }
64
+
65
+ const REASON_TEXT: Readonly<Record<AccountGroupFailureReason, readonly [string, string]>> = {
66
+ "delegate-origin-unresolved": ["delegate-origin-unresolved: the delegate parent could not be verified", "Start a worker from a verified parent session."],
67
+ "context-unavailable": ["context-unavailable: the session policy context is unavailable", "Wait for the session context, then try again."],
68
+ "driver-origin-unresolved": ["driver-origin-unresolved: the durable delegate parent could not be verified", "Start a child from a verified parent session."],
69
+ "delegate-resolution-unavailable": ["delegate-resolution-unavailable: the parent policy is unavailable", "Restore the verified parent session policy before starting a worker."],
70
+ "driver-resolution-unavailable": ["driver-resolution-unavailable: the durable parent policy is unavailable", "Restore the verified parent session policy before starting a child."],
71
+ "config-invalid": ["config-invalid: the account policy configuration is invalid", "Correct the global configuration and run /multi-account reload."],
72
+ "resolution-failed": ["resolution-failed: the session policy could not be read safely", "Repair the session account-group store before trying again."],
73
+ "policy-unavailable": ["the authorized account-group policy is unavailable", "Check /multi-account group status before trying again."],
74
+ "no-serving-members": ["no listed member serves this model", "Select a model served by a listed member or choose another group."],
75
+ "no-managed-serving-members": ["no eligible managed member serves this unified model", "Select a listed physical model or choose a group with managed members."],
76
+ "authentication-unavailable": ["serving managed members need usable authorization; sign in", "Sign in with /login, then run /multi-account rediscover, or choose another group."],
77
+ "cooldown": ["serving managed members are cooling down", "Wait for recovery or choose another group."],
78
+ "exhausted": ["serving managed members have exhausted their available usage", "Wait for usage to recover or choose another group."],
79
+ "authorization-snapshot-unavailable": ["the authorization snapshot is unavailable", "Check provider availability, then try again or choose another group."],
80
+ "availability-unresolved": ["no eligible managed member is available; the cause is not established", "Check /multi-account group status, sign in if needed, or choose another group."],
81
+ };
82
+
83
+ function failureView(
84
+ reason: AccountGroupFailureReason,
85
+ modelId: string,
86
+ outsideEligible: boolean,
87
+ resolution?: Exclude<EffectiveAccountGroupResolution, { readonly source: "unrestricted" }>,
88
+ ): AccountGroupFailureView {
89
+ const [text, help] = REASON_TEXT[reason];
90
+ const model = safeLabel(modelId, 128, "selected-model");
91
+ const groupId = resolution === undefined ? undefined : safeLabel(resolution.groupId, 64, "selected-group");
92
+ const source = resolution?.source;
93
+ const heading = groupId === undefined ? "Account group scope" : `Account group "${groupId}" (${source})`;
94
+ const message = `${heading} cannot serve ${model}: ${text}. ${help}${outsideEligible ? " Eligible accounts exist outside this group; they remain excluded." : ""}`;
95
+ // Only named output facts; never return caller policy, candidate objects or prose.
96
+ if (groupId === undefined || source === undefined) {
97
+ return Object.freeze({ reason, outsideEligible, message, help });
98
+ }
99
+ return Object.freeze({ reason, groupId, source, outsideEligible, message, help });
100
+ }
101
+
102
+ /**
103
+ * Format a failed unified selection from one call's already-authorized facts.
104
+ * This observes nothing, changes no policy and grants no dispatch permission.
105
+ * Undefined preserves the caller's existing unrestricted error byte-for-byte.
106
+ * Mixed or unobserved causes stay unresolved, never guessed as missing auth.
107
+ */
108
+ export function formatAccountGroupFailure(input: AccountGroupFailureInput): AccountGroupFailureView | undefined {
109
+ const policy = input.policy;
110
+ if (policy.kind === "blocked") {
111
+ const reason = Object.hasOwn(REASON_TEXT, policy.reason) && BLOCK_REASONS.includes(policy.reason)
112
+ ? policy.reason : "policy-unavailable";
113
+ return failureView(reason, input.modelId, false);
114
+ }
115
+ if (policy.kind !== "resolved") return failureView("policy-unavailable", input.modelId, false);
116
+ const resolution = policy.resolution;
117
+ if (resolution.source === "unrestricted") return undefined;
118
+ if (resolution.source !== "session-override" && resolution.source !== "cwd-default" && resolution.source !== "global-default") {
119
+ return failureView("policy-unavailable", input.modelId, false);
120
+ }
121
+ if (!Number.isInteger(input.accountLimit) || input.accountLimit < 1 || input.accountLimit > 32) {
122
+ return failureView("policy-unavailable", input.modelId, false, resolution);
123
+ }
124
+ const validId = (id: string): boolean => isAccountGroupMemberReference(id, input.accountLimit, MANAGED_FAMILIES);
125
+ const members = new Set(policy.members.filter(validId));
126
+ const candidates = input.candidates.filter((row) => validId(row.providerId)).map((row) => ({
127
+ providerId: row.providerId,
128
+ servesModel: row.servesModel === true,
129
+ eligible: row.eligible === true,
130
+ authenticated: row.authenticated === false ? false : undefined,
131
+ exhausted: row.exhausted === true,
132
+ coolingDown: row.coolingDown === true,
133
+ reason: row.reason === "authentication unavailable" || row.reason === "authorization snapshot unavailable" ? row.reason : undefined,
134
+ }));
135
+ const inside = candidates.filter((row) => members.has(row.providerId));
136
+ const serving = inside.filter((row) => row.servesModel === true);
137
+ const managed = serving.filter((row) => managedProvider(row.providerId));
138
+ if (managed.some((row) => row.eligible === true)) return undefined;
139
+ const outsideEligible = candidates.some((row) => !members.has(row.providerId) && row.servesModel === true && row.eligible === true);
140
+ let reason: AccountGroupFailureReason;
141
+ if (inside.some((row) => row.reason === "authorization snapshot unavailable")) reason = "authorization-snapshot-unavailable";
142
+ else if (serving.length === 0) reason = "no-serving-members";
143
+ else if (managed.length === 0 || serving.some((row) => !managedProvider(row.providerId) && row.eligible === true)) reason = "no-managed-serving-members";
144
+ else if (managed.every((row) => row.authenticated === false || row.reason === "authentication unavailable")) reason = "authentication-unavailable";
145
+ else if (managed.every((row) => row.exhausted === true)) reason = "exhausted";
146
+ else if (managed.every((row) => row.coolingDown === true)) reason = "cooldown";
147
+ else reason = "availability-unresolved";
148
+ return failureView(reason, input.modelId, outsideEligible, resolution);
149
+ }
@@ -0,0 +1,139 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ type GroupMemberRegistry = Pick<
4
+ ExtensionContext["modelRegistry"],
5
+ "getAll" | "getAvailable"
6
+ > & Partial<Pick<
7
+ ExtensionContext["modelRegistry"],
8
+ "getRegisteredProviderIds" | "getProviderAuthStatus"
9
+ >>;
10
+
11
+ export type AccountGroupMemberReason =
12
+ | "recognized and available"
13
+ | "excluded by active group"
14
+ | "unknown or removed provider"
15
+ | "authentication unavailable"
16
+ | "model unavailable"
17
+ | "virtual model unsupported by active group"
18
+ | "authorization snapshot unavailable";
19
+
20
+ export interface AccountGroupMemberAvailability {
21
+ readonly providerId: string;
22
+ readonly eligible: boolean;
23
+ readonly reason: AccountGroupMemberReason;
24
+ }
25
+
26
+ export interface AccountGroupRegistrySnapshot {
27
+ readonly providerIds: ReadonlySet<string>;
28
+ readonly modelProviderIds: ReadonlySet<string>;
29
+ readonly availableModels: readonly {
30
+ readonly providerId: string;
31
+ readonly modelId: string;
32
+ readonly api: string;
33
+ }[] | undefined;
34
+ }
35
+
36
+ /** Public Pi virtual API marker; provider identity/auth cannot confine its targets. */
37
+ export function isAccountGroupVirtualModel(
38
+ model: Readonly<{ api: string }> | undefined,
39
+ ): boolean {
40
+ return model?.api === "pi-virtual";
41
+ }
42
+
43
+ /** Syntax alone grants no authority and does not enroll a provider in routing. */
44
+ export function isAccountGroupMemberReference(
45
+ value: unknown,
46
+ accountLimit: number,
47
+ managedFamilies: readonly string[],
48
+ ): value is string {
49
+ if (
50
+ typeof value !== "string" ||
51
+ !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(value)
52
+ ) return false;
53
+ if (!Number.isInteger(accountLimit) || accountLimit < 1 || accountLimit > 32) {
54
+ return false;
55
+ }
56
+ for (const family of managedFamilies) {
57
+ if (value === family) return true;
58
+ const prefix = `${family}-account-`;
59
+ if (!value.startsWith(prefix)) continue;
60
+ const suffix = value.slice(prefix.length);
61
+ const slot = Number(suffix);
62
+ return Number.isSafeInteger(slot) && slot >= 2 &&
63
+ slot <= accountLimit && String(slot) === suffix;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ /** Public synchronous snapshots only: no credential resolver, refresh, label or key. */
69
+ export function readAccountGroupRegistrySnapshot(
70
+ registry: GroupMemberRegistry | undefined,
71
+ ): AccountGroupRegistrySnapshot {
72
+ const providerIds = new Set<string>();
73
+ const modelProviderIds = new Set<string>();
74
+ let availableModels: AccountGroupRegistrySnapshot["availableModels"];
75
+ try {
76
+ for (const row of registry?.getAll() ?? []) {
77
+ providerIds.add(row.provider);
78
+ modelProviderIds.add(row.provider);
79
+ }
80
+ for (const id of registry?.getRegisteredProviderIds?.() ?? []) {
81
+ providerIds.add(id);
82
+ }
83
+ } catch {
84
+ return {
85
+ providerIds: new Set(), modelProviderIds: new Set(),
86
+ availableModels: undefined,
87
+ };
88
+ }
89
+ try {
90
+ // getAvailable() is cached catalog/auth-check metadata, not resolved request
91
+ // credentials. A configured command can pass without executing. A failed
92
+ // metadata read excludes only its member; request auth remains host-owned.
93
+ const authConfigured = new Map<string, boolean>();
94
+ availableModels = registry?.getAvailable().filter((row) => {
95
+ if (!authConfigured.has(row.provider)) {
96
+ try {
97
+ const configured = registry.getProviderAuthStatus === undefined ||
98
+ registry.getProviderAuthStatus(row.provider).configured === true;
99
+ authConfigured.set(row.provider, configured);
100
+ } catch {
101
+ authConfigured.set(row.provider, false);
102
+ }
103
+ }
104
+ return authConfigured.get(row.provider) === true;
105
+ }).map((row) => ({ providerId: row.provider, modelId: row.id, api: row.api }));
106
+ } catch {
107
+ availableModels = undefined;
108
+ }
109
+ return { providerIds, modelProviderIds, availableModels };
110
+ }
111
+
112
+ /** Exact membership and live availability, independent of managed routing ownership. */
113
+ export function accountGroupMemberAvailability(
114
+ providerId: string,
115
+ members: readonly string[],
116
+ snapshot: AccountGroupRegistrySnapshot,
117
+ modelId?: string,
118
+ ): AccountGroupMemberAvailability {
119
+ let reason: AccountGroupMemberReason;
120
+ const available = snapshot.availableModels;
121
+ if (!snapshot.providerIds.has(providerId)) reason = "unknown or removed provider";
122
+ else if (!members.includes(providerId)) reason = "excluded by active group";
123
+ else if (available === undefined) reason = "authorization snapshot unavailable";
124
+ else if (!snapshot.modelProviderIds.has(providerId)) reason = "model unavailable";
125
+ else if (!available.some((row) => row.providerId === providerId)) {
126
+ reason = "authentication unavailable";
127
+ } else if (modelId !== undefined && !available.some(
128
+ (row) => row.providerId === providerId && row.modelId === modelId,
129
+ )) {
130
+ reason = "model unavailable";
131
+ } else if (!available.some((row) =>
132
+ row.providerId === providerId &&
133
+ (modelId === undefined || row.modelId === modelId) &&
134
+ !isAccountGroupVirtualModel(row),
135
+ )) {
136
+ reason = "virtual model unsupported by active group";
137
+ } else reason = "recognized and available";
138
+ return { providerId, eligible: reason === "recognized and available", reason };
139
+ }