@centerforagenticai/pi-multi-account 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
package/README.md ADDED
@@ -0,0 +1,999 @@
1
+ # pi-multi-account
2
+
3
+ `pi-multi-account` keeps Pi working when an Anthropic or OpenAI Codex OAuth
4
+ account reaches a limit or loses authorization. It registers numbered account
5
+ aliases, observes provider health, and moves a settled turn to an eligible
6
+ account. **Failover** means moving that turn after the active account fails.
7
+
8
+ The extension is OAuth-first. Credentials remain in Pi's `AuthStorage`.
9
+ Discovery uses Pi's public credential adapter first and a narrow read-only
10
+ `auth.json` fallback when that adapter is unavailable. Both paths project only
11
+ presence, expiry, a derived fingerprint, and an optional Codex label. Raw values
12
+ do not leave that boundary. The package never writes `auth.json` or puts tokens
13
+ in its own state or diagnostics. OpenRouter is available only as an explicitly
14
+ enabled, budgeted final rung in a non-delegate Pi session. It is off by default
15
+ and blocked inside delegate sessions.
16
+
17
+ ## Routing behavior
18
+
19
+ The route order is:
20
+
21
+ 1. another healthy subscription account in the turn's starting family;
22
+ 2. a same-vendor account that uses the vendor's own pay-per-token API;
23
+ 3. an explicitly configured cross-vendor subscription destination;
24
+ 4. explicitly enabled OpenRouter with a successful budget reservation;
25
+ 5. a bounded parked turn that waits for a permitted managed account to recover.
26
+
27
+ A **parked turn** is held without replaying the failed request. The extension
28
+ re-checks live account state for up to 30 minutes. `/multi-account stop`
29
+ cancels it.
30
+
31
+ Routing has these guarantees:
32
+
33
+ - Same-family subscription failover prefers an eligible account whose catalog
34
+ serves the active model. If none does, routing may choose another eligible
35
+ subscription account and use its catalog head.
36
+ - The owning-vendor API tier stays within the model's vendor. It uses the exact
37
+ requested model ID when the destination catalog contains it, then a matching
38
+ `tierModelMap` entry. If neither is available, that account is skipped. It
39
+ never substitutes the account's catalog head.
40
+ - Cross-family subscription failover is directional and disabled by default.
41
+ It uses the first supported model in `preferredModels` for the destination
42
+ family, then its catalog head if no preference is supported.
43
+ - Parked recovery keeps the same family policy as the failure that created it.
44
+ Polling does not create cooldowns or widen the route.
45
+ - A confirmed switch sends one fixed follow-up message with `deliverAs:
46
+ "followUp"`. It never resubmits the original prompt or provider request.
47
+ - A failed model selection sends no follow-up.
48
+ - Two 429 responses within 15 minutes temporarily invalidate an optimistic usage
49
+ reading. A provider snapshot cannot keep an account selectable while live
50
+ requests prove that it is limited.
51
+ - Authentication refresh is bounded to one forced attempt per provider and
52
+ session. A changed account identity is rejected rather than silently replacing
53
+ the selected account.
54
+ - Watchdogs cancel stalled continuations. Every timeout is retained as a
55
+ diagnostic, while identical operator notices are limited to one per 15
56
+ minutes across continuations.
57
+
58
+ Pi performs its own provider retries before the extension acts. Reactive routing
59
+ runs at `agent_settled`, after Pi's retry and compaction loop has finished. A
60
+ visible pause before failover is normally Pi's backoff, not an extra retry made
61
+ by this extension.
62
+
63
+ ## Unified logical model provider
64
+
65
+ The extension can register one extra provider, `unified`, whose models come from
66
+ the managed subscription catalogs. A unified OpenAI model can run on either an
67
+ `openai-codex` subscription or the owning-vendor `openai` API tier. Routing tries
68
+ eligible subscriptions first. It uses the API tier only when the exact model ID
69
+ is in that tier's catalog or `tierModelMap.openai` explicitly maps the unified ID
70
+ to a catalog model. An unresolved API model is skipped; routing never chooses the
71
+ API catalog head as a substitute.
72
+
73
+ Public assistant stream events, including the final result, carry `unified` as
74
+ provider and API and the selected logical model ID. Physical account identities
75
+ remain private to routing and usage attribution. When a logical turn finishes,
76
+ the extension records its token usage and retained provider cost under the
77
+ physical account that served it, never under `unified`. A subscription-tier turn
78
+ records subscription usage and cost; an owning-vendor API-tier turn records
79
+ provider cost only, not subscription usage. It also records normalized rate-limit
80
+ observations when the physical transport exposes response headers. Anthropic
81
+ does. Codex does on SSE, but `openai-codex-responses` does not call `onResponse` on its WebSocket branch.
82
+ Logical Codex header observations are therefore absent on WebSocket; the existing
83
+ five-minute usage poll remains the fallback.
84
+
85
+ While a `unified` model is selected, Pi renders `(unified)` in its model line and
86
+ shows one compact, right-aligned below-editor route widget under model/thinking status.
87
+ It starts at `unified(waiting)`, then shows the exact physical account serving an
88
+ attempt and its live usage, such as `unified(anthropic-account-2 · 75% left)`.
89
+ A configured account label appears last inside the parentheses. A retry replaces
90
+ the account with the new exact route. Selecting any physical or unrelated provider
91
+ removes the widget.
92
+
93
+ The indicator uses the same machine-shared quota observations as the status view.
94
+ Fresh utilization leads; fresh request or token counts are used only when utilization
95
+ is absent. Old quota is shown as `usage stale`, and an account with no quota
96
+ observation is `usage unknown`; stale values never appear as current headroom. An
97
+ optional account label is read from live config on every render and appears last.
98
+ Updates are event-driven by model selection, route attempts, usage observations, and
99
+ terminal settlement. The indicator creates no timer, polling loop, retained record,
100
+ or diagnostic state.
101
+
102
+ Selection is exact. A request for a model ID no account serves is refused, not
103
+ redirected. A cross-tier ID may differ only when the operator records that model
104
+ identity in `tierModelMap`. Substituting a catalog head, a same-family sibling,
105
+ or a vendor default would quietly run a different model than the one that was
106
+ chosen, and the reply would look entirely normal.
107
+
108
+ The provider's own rows are never marked enabled or disabled. Which models
109
+ appear in the picker is Pi's decision, expressed through its `enabledModels`
110
+ setting, and this extension does not write that setting on the operator's
111
+ behalf.
112
+
113
+ A model id served by more than one managed family is left out of the
114
+ declaration and reported. No tie-break can be right without knowing which
115
+ vendor was meant, and guessing would send the request to the wrong one.
116
+
117
+ Two commands manage the declaration in Pi's `models.json`:
118
+
119
+ | Command | Result |
120
+ | --- | --- |
121
+ | `/multi-account models install` | Write the declaration and approved Codex context defaults. Refused if a declaration is already present. |
122
+ | `/multi-account models update` | Refresh the declaration and approved Codex context defaults from the current catalogs. Refused if no declaration is present. |
123
+ | `/multi-account model [id]` | Select an exact logical model, or open the logical model picker when `id` is omitted. |
124
+ | `/multi-account-model [id]` | Deprecated one-release alias for `/multi-account model [id]`; it prints a migration notice and otherwise behaves identically. |
125
+
126
+ Pi checks the declaration once at session start. A stale declaration still
127
+ registers the fresh live projection, so logical routing stays on, while a warning
128
+ and `/multi-account status` banner point to `/multi-account models update`. An
129
+ unreadable declaration keeps logical routing off and shows the same update remedy.
130
+ A declaration that is not installed leaves the logical provider unregistered and
131
+ stays silent at startup; use `/multi-account models install` to add it. Startup
132
+ warnings appear at most once per machine per UTC day. A stale status banner stays
133
+ visible for the session until the declaration is updated and Pi restarts.
134
+
135
+ Both commands show the declaration and Codex override changes, then wait for
136
+ confirmation. The offline-approved defaults supply `contextWindow: 1050000`
137
+ when needed for `gpt-5.4`, `gpt-5.5`, `gpt-5.6-sol`, `gpt-5.6-terra`,
138
+ `gpt-5.6-luna`, and `gpt-6-astra`. During these explicit commands only, other
139
+ lowercase, versioned IDs already in the live Codex catalog can receive the same
140
+ default when that exact model's
141
+ `https://developers.openai.com/api/docs/models/<id>.md` page names the exact ID
142
+ and lists a `1,050,000 context window` under Model details. Documentation reads
143
+ send no credential or prompt, follow no redirects, and have fixed candidate,
144
+ time, and response-size limits. When more than eight valid IDs need evidence, a
145
+ UTC-day rotating window checks at most eight and reports the remainder as skipped.
146
+ Offline, malformed, mismatched, or otherwise unverified pages add no default; the
147
+ confirmation shows only a bounded reason-count summary. Every explicit install or
148
+ update verifies new IDs again; a prior override or `unified` row is not evidence.
149
+ A live catalog window already at or above `1050000` requires no new override. For
150
+ one of the six offline-approved IDs, an update removes a prior transaction-managed
151
+ `contextWindow` so the higher live value wins, preserving every other override
152
+ field and deleting the entry only when nothing remains. Operator-owned overrides
153
+ for IDs outside the offline-approved list are always retained; exact documentation
154
+ can create a missing dynamic override and project the default into `unified`, but
155
+ it does not rewrite an existing one.
156
+
157
+ The current transaction's offline-approved and newly verified ID-to-window map
158
+ powers both physical override changes and generated `unified` rows. An existing
159
+ dynamic override can therefore differ from its `unified` row: verified rows use
160
+ the documented default there, while unverified rows continue to copy the current
161
+ live catalog. Every other catalog field,
162
+ including `maxTokens` and tiered cost metadata, is preserved. This metadata
163
+ preservation does not make the separate API-equivalent estimate tier-aware.
164
+
165
+ Before reading the live catalog or model documentation, the command validates
166
+ every existing `openai-codex.modelOverrides` entry against Pi's supported shape.
167
+ If the container or any entry is malformed, it leaves the file unchanged and
168
+ does not ask for confirmation.
169
+
170
+ A successful install or update removes the old `pi-multi-account` provider entry
171
+ only when its API and reserved base URL identify it as this extension's previous
172
+ declaration. The transaction otherwise owns the `unified` declaration and the
173
+ resolved `contextWindow` fields above. It leaves every other provider, override,
174
+ and field structurally untouched: each value parses back deeply equal to the one
175
+ it replaced. The file is rewritten with standard JSON formatting, so original
176
+ indentation, number spelling, and string escapes are not preserved character for
177
+ character.
178
+
179
+ The write is atomic: the candidate goes
180
+ to an owner-only temporary file beside the target and is renamed into place, so
181
+ no reader ever sees a half-written file. If the file changes between the read
182
+ and the write, the command aborts rather than overwriting the other writer.
183
+
184
+ Before `/multi-account models update`, make a byte-for-byte backup of `models.json`
185
+ and set its mode to owner-only (`0600`). A successful update keeps no extra copy.
186
+ To roll back, restore that backup to the same path and restart Pi. The older
187
+ declaration may remain unavailable until a later update, but physical aliases and
188
+ session history are unchanged.
189
+
190
+ ### Switching logical models
191
+
192
+ Use the operator-only `/multi-account model` command to choose a logical model
193
+ without changing account policy:
194
+
195
+ ```text
196
+ /multi-account model
197
+ /multi-account model gpt-5.6-luna
198
+ /multi-account model unified/gpt-5.6-luna
199
+ ```
200
+
201
+ The deprecated `/multi-account-model [id]` alias remains for one release. It
202
+ prints a one-line notice pointing to `/multi-account model` and then follows the
203
+ same selection path.
204
+
205
+ With no argument, the command opens a searchable terminal picker. It shows only
206
+ currently available `unified` rows, in live catalog order, narrowed by
207
+ `enabledModels` or `--models` when the session has a non-empty scope. An empty
208
+ scope admits every available logical row. Each picker row renders
209
+ `<model-id> [unified]`; selection keeps the canonical bare `<model-id>` as the
210
+ model identity and never changes it to `unified/<model-id>`. Search is an
211
+ order-preserving filter over exactly five fields for each row: `unified`, the
212
+ legacy `pi-multi-account` search alias, `unified/<model-id>`, the bare `<model-id>`,
213
+ and the configured display name.
214
+
215
+ An argument must be one exact bare model ID or one exact
216
+ `unified/<model-id>` reference. Slashes inside a model ID are preserved.
217
+ Partial, fuzzy, duplicate, foreign-provider, unavailable, and out-of-scope
218
+ references are refused. The no-argument picker requires terminal UI; the direct
219
+ form remains deterministic in RPC and print contexts.
220
+
221
+ Cancellation leaves the current model unchanged. If Pi refuses or throws while
222
+ selecting the exact model, the command reports that refusal and leaves the
223
+ extension's active-model projection unchanged. Recovery output distinguishes a
224
+ missing declaration (install it), an unusable declaration (update it), scope,
225
+ availability, and invalid references. A successful `pi.setModel` call may add
226
+ Pi's normal `model_change` and `thinking_level_change` session entries. The
227
+ extension does not write those entries, settings, declarations, credentials,
228
+ retained state, or history, and the command is not available to the
229
+ `multi_account_status` agent tool.
230
+
231
+ ## Exact-model route resolver
232
+
233
+ The package root exports `resolveExactModelRoutes`, the request and result
234
+ TypeScript types, and `publishRouteResolver` / `lookupRouteResolver`. The
235
+ resolver is a read-only, credential-free policy boundary for consumers that
236
+ need exact managed routes. It does not select Pi's active model and no current
237
+ foreground or reactive path consumes it.
238
+
239
+ The process-local discovery key is
240
+ `Symbol.for("@caair/pi-multi-account/route-resolver")`. Its stored value is
241
+ an immutable `{ purpose: "exact-model-routing", version: 1, resolve }` service.
242
+ `lookupRouteResolver()` returns exactly one of `absent`, `incompatible`, or
243
+ `available`. It reads only an own data-property descriptor, never invokes a
244
+ registry accessor, and catches hostile structural inspection. Every present
245
+ malformed value maps to `incompatible`. An available service may still return
246
+ `unresolved` with `reason: "no-eligible-routes"`; that is different from absent
247
+ discovery.
248
+
249
+ Version 1 requests contain `purpose`, `version`, and an exact `modelId`, with
250
+ optional `family`, `preferredProviderId`, and `excludedProviderIds`. A bare
251
+ model ID infers its family only when exactly one managed family advertises it.
252
+ A supplied family must advertise it. A model written as
253
+ `provider/model` is physical intent when `provider` is a canonical managed
254
+ provider ID; everything after the first slash remains the exact model ID and
255
+ only that provider may be returned.
256
+
257
+ A resolved result has `reason: "eligible-routes"` and an immutable ordered
258
+ `routes` array. Each route contains only
259
+ `{ providerId, modelId, family }`. An affinity preference can reorder eligible
260
+ routes but cannot admit one. Exclusions, disabled or cleared accounts,
261
+ cooldowns, invalidation, exhaustion, dead credentials, duplicate or malformed
262
+ accounts, unsupported model observations, and missing catalog membership are
263
+ filtered before serialization. Unresolved reasons are closed:
264
+ `invalid-input`, `unsupported-purpose`, `incompatible-version`,
265
+ `unknown-model`, `ambiguous-model-family`, `family-model-mismatch`, and
266
+ `no-eligible-routes`.
267
+
268
+ Each version-1 publication replaces the frozen public facade with a new one, but
269
+ every reload-safe facade — including one a consumer retained before a reload —
270
+ dispatches through a process-local coordinator to the newest live session owner,
271
+ so a consumer does not have to look the service up again. When a session shuts
272
+ down, its owner is revoked before Pi invalidates the extension context. Between
273
+ that revocation and the next publication, and after the final owner is revoked,
274
+ a retained facade returns `unresolved` with `reason: "no-eligible-routes"`
275
+ without reaching any stale context; a fresh `lookupRouteResolver()` normally
276
+ becomes `absent` because the public slot is deleted. A higher version is
277
+ retained only when its purpose matches exactly, its version is finite and
278
+ greater than 1, and its resolver is callable; it remains `incompatible` to this
279
+ version-1 lookup contract. Malformed higher, same-version, older, and
280
+ accessor-backed values are replaced when the registry slot is replaceable.
281
+ Consumers should handle `absent` and `incompatible` as no resolver, and inspect
282
+ the result status separately when the service is available.
283
+
284
+ The first deployment of the coordinator-backed resolver, and any rollback from
285
+ it, requires a full Pi process restart, not only `/reload`: a facade created by
286
+ the older direct-closure build is frozen around the old function and cannot be
287
+ rewritten in place. The reload guarantee also covers only the paths that emit
288
+ `session_shutdown` before invalidation — the pinned reload path and
289
+ `AgentSessionRuntime` replacement or disposal. A direct low-level SDK call to
290
+ `AgentSession.dispose()` emits no such event and stays outside the guarantee; an
291
+ SDK integration that loads this resolver must use `AgentSessionRuntime`, or emit
292
+ and await `session_shutdown`, before disposing directly.
293
+
294
+ ## Accounts
295
+
296
+ The managed physical families are:
297
+
298
+ - `anthropic` — an OAuth credential is a subscription; an `api_key` credential
299
+ is the Anthropic owning-vendor API tier;
300
+ - `openai-codex` — the ChatGPT/Codex subscription tier;
301
+ - `openai` — the OpenAI owning-vendor API tier;
302
+ - `google-antigravity` — the Google Antigravity subscription tier. It has no
303
+ owning-vendor API tier: unlike `anthropic`/`openai`, there is no separate
304
+ pay-per-token `google` family, so a `google-antigravity` slot is always an
305
+ OAuth subscription account.
306
+
307
+ Proactive subscription routing, OAuth usage fetching, and the version-1
308
+ exact-model resolver remain limited to `anthropic` subscriptions and
309
+ `openai-codex`. The logical provider itself also routes an owning-vendor API
310
+ tier as a same-vendor fallback — the OpenAI `openai` API tier, and an Anthropic
311
+ `api_key` account as the Tier-2 destination described next. An Anthropic `api_key` account is not a Tier-1 peer and is
312
+ not a Codex-origin cross-vendor destination. It is reachable only as the
313
+ same-vendor Tier-2 destination of a turn that started on an Anthropic
314
+ subscription.
315
+
316
+ Each family has a base provider ID and numbered aliases from `-account-2` up to
317
+ `accountLimit`. With the default limit of four, the Codex IDs are
318
+ `openai-codex`, `openai-codex-account-2`, `openai-codex-account-3`, and
319
+ `openai-codex-account-4`; the canonical `google-antigravity` family follows the
320
+ same `google-antigravity`, `google-antigravity-account-2`, … pattern.
321
+
322
+ `accountLimit` is a whole number from `1` to `32`. `32` is the shared maximum,
323
+ and the limit applies independently to each managed family — `anthropic`,
324
+ `openai-codex`, and `google-antigravity` each get their own `1`..`accountLimit`
325
+ slot range under the one configured number (`MAX_ACCOUNT_LIMIT`). Startup rejects a persisted `accountLimit`
326
+ of `0`, `33`, a fraction, a string, or any unsafe value before it discovers any
327
+ account, and it rejects a canonical numbered `accountLabels` or
328
+ `monthlySubscriptionUsd` key above `32`. To lower the limit, remove or remap any
329
+ above-limit metadata key first, then re-login the account you still need into an
330
+ in-range slot. Stored credentials above the limit are left byte-for-byte
331
+ untouched; the extension never opens, moves, rewrites, or deletes them.
332
+
333
+ Authenticate each slot through Pi's public `/login` flow. The extension delegates
334
+ Anthropic OAuth lifecycle and ordinary requests to the in-tree
335
+ `pi-anthropic-oauth@0.2.5-intel.1` workspace, vendored byte-for-byte from the
336
+ reviewed fork commit `a52b62a05b0990ba3e2b1fa47d794b5f686435a5` before the
337
+ recorded workspace-only additions. For live models whose metadata requires
338
+ adaptive thinking, a selected reasoning level uses the extension-owned adapter.
339
+ Before either stream runs, the selector reconstructs Pi transcript system text
340
+ and active tools in the legacy context form consumed by the vendored code. The
341
+ base provider and numbered aliases share this selector, which sends one request
342
+ and never replays an adapter error. The extension re-asserts that base
343
+ registration once at session start, so another Anthropic OAuth extension that
344
+ loads later cannot restore a different request path. No separate global
345
+ installation is required. Codex aliases use Pi's native OAuth surface and
346
+ `openai-codex-responses` transport.
347
+
348
+ ### Google Antigravity
349
+
350
+ `google-antigravity` resolves from the in-tree
351
+ `pi-antigravity@0.7.2-intel.1` workspace. Its public baseline is
352
+ `Rahularya01/pi-antigravity` release `v0.7.2`; the recorded local patch series
353
+ reconstructs the reviewed fork commit
354
+ `254a08d73ff8d21c3d84583587a87c0dc7ebad12`. The series projects current Pi
355
+ transcript system and tool changes before request
356
+ conversion, avoiding the observed `MALFORMED_FUNCTION_CALL` failure. This
357
+ repository imports only the five reviewed public barrels (`src/auth`,
358
+ `src/client`, `src/models`, `src/stream`, `src/usage`) and never invokes the
359
+ vendored root factory. The root factory's base `antigravity` provider, commands,
360
+ image tool, and optional connection prewarm are therefore unreachable through
361
+ this extension. See [`NOTICE`](NOTICE) for upstream attribution and the summary
362
+ of local changes.
363
+
364
+ The current catalog has eight models: `gemini-3.8-flash`, `gemini-3.7-flash`,
365
+ `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.1-pro`, `claude-opus-4-6`,
366
+ `claude-sonnet-4-6`, and `gpt-oss-120b`. `/multi-account add google-antigravity`
367
+ registers the next free numbered slot the same way as the other families, and
368
+ `/multi-account rediscover` picks up a credential added outside the running
369
+ session.
370
+
371
+ Usage fetching honors an `AbortSignal` end to end: `UsageFetcher` forwards it
372
+ through to the pinned fork's `fetchAccountUsage(apiKey?, { signal })`, and a
373
+ bounded per-attempt deadline aborts the raw call and awaits its real
374
+ settlement — never just the deadline — before releasing the shared machine
375
+ usage lease. A lease handed off to a background drain is kept renewed at the
376
+ lease's own interval so a peer process can never acquire it and start a
377
+ duplicate real call while one is still outstanding.
378
+
379
+ Google Antigravity has no supported inner-retry behavior yet, so recovery uses
380
+ the same conservative zero-retry default already used for every non-Anthropic
381
+ family; it is not a product gap specific to this family. Reported model `cost`
382
+ fields from the upstream usage endpoint are API-style rate estimates, not
383
+ retained subscription charges — this repository keeps retained provider cost
384
+ separate from those estimates and reports an unpriced case as `unpriced`
385
+ rather than guessing. The upstream usage result's raw project ID is projected
386
+ to a bounded digest before this repository records status, diagnostics, or
387
+ history; see [Storage and privacy](#storage-and-privacy).
388
+
389
+ Authenticating and exercising a real Google account end to end is an operator
390
+ action, not something automated verification performs: `integration:verify`
391
+ and this package's own tests use only isolated temporary `AuthStorage`,
392
+ synthetic fixture credentials, and blocked or faked network transports. See
393
+ Use the same operator-controlled rollout pattern used for Anthropic and Codex
394
+ enablement.
395
+
396
+ ## Install
397
+
398
+ Pi packages run with the user's full system permissions. Review the source
399
+ before installation.
400
+
401
+ Install the public package globally:
402
+
403
+ ```sh
404
+ pi install npm:@centerforagenticai/pi-multi-account
405
+ ```
406
+
407
+ The package carries both modified provider forks inside its own `packages/`
408
+ directory. It does not fetch the private `-intel.1` package versions from a
409
+ registry; Pi SDK dependencies remain host peers.
410
+
411
+ Install a local checkout instead:
412
+
413
+ ```sh
414
+ pi install /absolute/path/to/pi-multi-account
415
+ ```
416
+
417
+ For a one-session test from a local checkout without changing settings:
418
+
419
+ ```sh
420
+ npm ci
421
+ pi -e ./src/index.ts
422
+ ```
423
+
424
+ The package manifest exposes one extension entrypoint:
425
+
426
+ ```json
427
+ {
428
+ "pi": {
429
+ "extensions": ["./src/index.ts"]
430
+ }
431
+ }
432
+ ```
433
+
434
+ Requirements:
435
+
436
+ - Node.js 22.19 or newer;
437
+ - a current Pi build with `agent_settled`, provider-response observation, dynamic
438
+ provider registration, and the public model-registry credential runtime;
439
+ - OAuth credentials stored through Pi for each managed slot.
440
+
441
+ The development lock used by TypeScript and unit tests pins
442
+ `@earendil-works/pi-ai@0.84.4` and
443
+ `@earendil-works/pi-coding-agent@0.84.4`. The release gate also runs the
444
+ extension in an isolated real Pi child. Start a new Pi process or run `/reload`
445
+ after installing or updating the package. Use `/multi-account rediscover` after
446
+ adding or changing account credentials.
447
+
448
+ ## Configuration
449
+
450
+ The extension reads one machine-global file:
451
+
452
+ ```text
453
+ $PI_CODING_AGENT_DIR/pi-multi-account/config.json
454
+ ```
455
+
456
+ When `PI_CODING_AGENT_DIR` is unset, the root is `~/.pi/agent`. The extension
457
+ does not load project-local config files. Missing config uses conservative
458
+ defaults. A malformed file, including one with an unknown top-level field,
459
+ makes extension session initialization fail closed: Pi records a sanitized
460
+ diagnostic and does not rediscover managed aliases. Pi itself stays running
461
+ because extension event handlers are crash-isolated.
462
+
463
+ ```json
464
+ {
465
+ "accountLimit": 4,
466
+ "sameFamilyFailover": true,
467
+ "crossFamilyChainEnabled": true,
468
+ "crossFamilyChains": [
469
+ { "from": "anthropic", "to": "openai-codex" }
470
+ ],
471
+ "preferredModels": {
472
+ "openai-codex": ["replace-with-a-supported-codex-model-id"],
473
+ "anthropic": ["replace-with-a-supported-anthropic-model-id"]
474
+ },
475
+ "tierModelMap": {
476
+ "openrouter": { "replace-with-a-source-model-id": "openrouter/replace-with-a-router-model-id" }
477
+ },
478
+ "watchdogIntervalMs": 30000,
479
+ "cooldownMaxMs": 300000,
480
+ "preemptiveExpiryWindowMs": 120000,
481
+ "usageFetchEnabled": {
482
+ "anthropic": true,
483
+ "openai-codex": true,
484
+ "google-antigravity": true
485
+ },
486
+ "accountLabels": {
487
+ "anthropic-account-2": "team subscription"
488
+ },
489
+ "projectLabels": {
490
+ "project-0123456789abcdef01234567": "example project"
491
+ },
492
+ "monthlySubscriptionUsd": {
493
+ "anthropic-account-2": 100,
494
+ "openai-codex-account-2": 20
495
+ }
496
+ }
497
+ ```
498
+
499
+ Replace the two `preferredModels` values with exact model IDs from the current
500
+ Anthropic and OpenAI Codex catalogs. They are destination-family IDs, not
501
+ OpenRouter IDs.
502
+
503
+ Key behavior:
504
+
505
+ | Setting | Default | Purpose |
506
+ | --- | ---: | --- |
507
+ | `accountLimit` | `4` | Maximum slot number per managed family. A whole number from `1` to `32`. |
508
+ | `sameFamilyFailover` | `true` | Permit another account in the same family. |
509
+ | `crossFamilyChainEnabled` | `false` | Enable the directional chains listed in `crossFamilyChains`. |
510
+ | `crossFamilyChains` | `[]` | Allowed cross-family transitions. Six directions are recognized, each its own explicit tuple: both `anthropic`↔`openai-codex` directions, and all four directions between `google-antigravity` and each of its two managed partners (`anthropic`, `openai-codex`). Direction matters — authorizing one direction never authorizes the reverse. |
511
+ | `preferredModels` | `{}` | Ordered destination-family models for cross-family routing. |
512
+ | `tierModelMap` | `{}` | Destination-keyed cross-tier model map (`anthropic`/`openai`/`openrouter` → `{ sourceId: destId }`). The source key is the ID shown by `unified`; the value is the same model's physical destination ID. Owning-vendor API and OpenRouter routing first use an exact catalog match, then this map, and fail closed when neither resolves. IDs are bounded to 256 characters and the map holds at most 256 entries total. The `"*"` source key is rejected. |
513
+ | `watchdogIntervalMs` | `30000` | No-progress interval before cancelling a continuation. |
514
+ | `cooldownMaxMs` | `300000` | Maximum bounded cooldown. |
515
+ | `preemptiveExpiryWindowMs` | `120000` | Prefer a fresher same-family credential before expiry. Set `0` to disable. |
516
+ | `usageFetchEnabled` | all `true` | Enable fail-soft provider usage fetches by family. |
517
+ | `accountLabels` | `{}` | Operator display labels keyed by canonical provider ID. |
518
+ | `projectLabels` | `{}` | Display labels keyed by `project-<24 hex>` digest. |
519
+ | `monthlySubscriptionUsd` | `{}` | Legacy, retroactive monthly price for subscription-value reporting, kept readable for compatibility. Superseded by `accountRateHistory` (see `multi-account account set-plan`); reading or writing either field never converts, deletes, or migrates the other, and neither reporting surface sums them together. |
520
+
521
+ `/multi-account reload` validates and reloads this file. It assigns the complete
522
+ persisted config and rediscovers accounts and provider catalogs. A failed reload
523
+ leaves the last valid in-memory config active and returns a sanitized command
524
+ error.
525
+
526
+ ### Configuring cross-family routing
527
+
528
+ `/multi-account configure` is the only production path that writes this file. It
529
+ is interactive and runs in the Pi terminal UI only. Use it to view the current
530
+ policy, authorize one Anthropic↔Codex direction, and set the best-first
531
+ destination model list for that direction.
532
+
533
+ It writes three fields: `crossFamilyChainEnabled`, `crossFamilyChains`, and
534
+ `preferredModels`. Every other field is carried over from a fresh read of the
535
+ file taken at commit time, so an unrelated setting is never lost. `tierModelMap`
536
+ is one of those carried-over fields: `configure` preserves it unchanged and
537
+ shows only a bounded destination/entry-count summary, never a per-entry editor.
538
+ The dialog offers every direction whose two families both have a discovered
539
+ account, so an Antigravity-involving direction (`anthropic`↔`google-antigravity`
540
+ or `google-antigravity`↔`openai-codex`) appears the same way the existing
541
+ Anthropic↔Codex directions do, once both sides of that pair have a
542
+ rediscovered account.
543
+
544
+ The write is short and cooperative:
545
+
546
+ - dialogs hold no lock. The extension takes a `config.lock` lease only after you
547
+ answer, with at most three attempts inside 300 ms;
548
+ - under that lease it re-reads the file and abandons the change if the routing
549
+ policy on disk moved while the dialog was open;
550
+ - the replacement is atomic, uses a `0700` directory, and lands at mode `0600`;
551
+ - the new routing policy reaches the current Pi process immediately, without
552
+ account rediscovery. Other running processes keep their existing policy until
553
+ `/multi-account reload` or a restart.
554
+
555
+ The command refuses to widen policy on its own. It authorizes one direction at a
556
+ time, never the reverse direction, and it rejects a model list that would leave
557
+ any managed destination account without a preferred model. Removing an
558
+ authorization still means editing the file and running `/multi-account reload`.
559
+
560
+ Duplicate `crossFamilyChains` entries are collapsed to their first occurrence
561
+ when the file is read. That does not change which routes are allowed. The first
562
+ explicit write also materializes omitted fields at their existing default
563
+ values.
564
+
565
+ Recover from an interrupted write or an outside edit with `/multi-account
566
+ reload`. If a `config.lock` file is left behind, confirm that no Pi process is
567
+ committing, then remove it.
568
+
569
+ ## Optional OpenRouter last resort
570
+
571
+ OpenRouter requires credentials available to Pi plus three extension policy
572
+ variables in the non-delegate Pi session. Authenticate with Pi's OpenRouter
573
+ OAuth `/login` flow or provide `OPENROUTER_API_KEY`. For example:
574
+
575
+ ```sh
576
+ export OPENROUTER_API_KEY='<secret>'
577
+ export PI_MULTI_ACCOUNT_OPENROUTER_ENABLED=conversation-egress
578
+ export PI_MULTI_ACCOUNT_OPENROUTER_MODEL='<provider>/<model-id>'
579
+ export PI_MULTI_ACCOUNT_OPENROUTER_DAILY_USD_LIMIT=10
580
+ ```
581
+
582
+ After authenticating, run `pi --no-extensions --list-models openrouter`.
583
+ Copy the model column only from a row whose provider column is exactly
584
+ `openrouter`. The extension rejects `~`-prefixed aliases and rows from providers
585
+ such as `openrouter-plus`. Replace the placeholder before starting Pi.
586
+
587
+ The exact enable value records consent to send the conversation outside the two
588
+ managed subscription families. Any missing, malformed, or partial setting keeps
589
+ the rung disabled. The daily limit must be greater than zero and no more than
590
+ `1000`, with at most four decimal places.
591
+
592
+ `PI_MULTI_ACCOUNT_OPENROUTER_MODEL` is the default OpenRouter destination when
593
+ `tierModelMap.openrouter` has no entry for the turn's original model. At runtime,
594
+ the extension applies that default only to the current source model; it does not
595
+ create or accept a `"*"` map entry. A specific map entry wins. The resolved ID
596
+ must exist in the live OpenRouter catalog and have bounded pricing; otherwise the
597
+ turn parks without a reservation.
598
+
599
+ Before each metered turn, the extension reserves a conservative worst-case cost
600
+ from Pi's live model catalog. Reservations are machine-global, per project, and
601
+ per UTC day. They are stored under a bounded `project-<digest>` key in
602
+ `openrouter-budget.json`. A missing file means zero reserved. A malformed file,
603
+ failed lease, unknown price, or uncertain write disables the rung for that turn.
604
+ Reservations are not reduced or refunded after a response. One OpenRouter
605
+ provider failure disables the rung for the rest of the session.
606
+
607
+ OpenRouter is never sticky. Before another metered reservation, the extension
608
+ checks managed accounts again. Any available subscription account still blocks
609
+ OpenRouter as before. An owning-vendor API account blocks it only when that
610
+ account can resolve and serve the turn's original model. Delegate environments
611
+ identified by `PI_DELEGATE_LINEAGE_*` cannot use this rung.
612
+
613
+ ## Commands and agent tool
614
+
615
+ `/multi-account` supports:
616
+
617
+ | Command | Result |
618
+ | --- | --- |
619
+ | `status [account-id\|--json]` | Health, active model, utilization, credential freshness, and metered-rung state. |
620
+ | `limits` | Token totals, remaining quotas, and human-readable recovery intervals. |
621
+ | `models [account-id]` | Available and session-rejected models by account. |
622
+ | `model [id]` | Select a unified logical model by exact id; with no id, show the selectable models. Replaces the deprecated `/multi-account-model`. |
623
+ | `cost [day\|week\|month\|quarter\|half-year\|year]` | Retained provider cost and separate API-equivalent estimates. |
624
+ | `log [lines]` | The latest sanitized diagnostics; default 20, maximum 100. |
625
+ | `rediscover` | Refresh account metadata and provider slots. |
626
+ | `add <family> [slot]` | Register a new OAuth login slot. Authentication still uses Pi `/login`. |
627
+ | `remove <account-id>` | Use Pi's public removal API when available; otherwise direct the operator to `/logout`. |
628
+ | `clear <account-id>` | Clear process-local state, disable the slot, and preserve credentials. |
629
+ | `next` | Report the next healthy account without switching. |
630
+ | `switch <account-id>` | Make an explicit operator-requested switch. |
631
+ | `stop` | Cancel parked or queued automatic continuation work. |
632
+ | `reset` | Clear process-local routing, usage, watchdog, continuation, and disabled state. |
633
+ | `reload` | Validate and reload machine-global config. |
634
+ | `configure` | Configure cross-family routing directions and destination models. Interactive TUI only. |
635
+ | `group use <id>` | Bind this session id to one configured account group. |
636
+ | `group reset` | Clear this session override and return to the exact-cwd default, global default, or unrestricted routing. |
637
+ | `group status` | Show the effective group and source plus each member's current eligible or blocked reason. |
638
+ | `enable` | Re-enable managed accounts and reset process-local state. |
639
+ | `disable <family>` | Disable one managed family in the current process. |
640
+
641
+ The registered `multi_account_status` tool exposes only `status`, `limits`,
642
+ `models`, `cost`, and `log`. Every `group` action and every other subcommand remains
643
+ operator-only. An agent can inspect general account state but cannot set or reset a
644
+ session group, edit cwd/global defaults, or change account policy or lifecycle state.
645
+ Automatic failover is a reaction to a classified provider failure;
646
+ discretionary account changes remain operator actions.
647
+
648
+ Account groups are named allow-lists in machine-global config. `accountGroups` maps
649
+ a group id to canonical account ids from any managed subscription family in
650
+ `ALLOWED_FAMILIES`; `accountGroupCwdDefaults` maps an exact absolute directory to
651
+ a configured group; `defaultAccountGroup` is
652
+ the optional machine-wide fallback. Resolution order is session override, exact-cwd
653
+ default, global default, then unrestricted. A selected unknown, empty, exhausted, or
654
+ otherwise ineligible group fails closed rather than widening to another account.
655
+
656
+ ## Command autocomplete
657
+
658
+ Both commands offer argument suggestions as you type. Pi calls each command's
659
+ `getArgumentCompletions` callback with the text after the command name and
660
+ replaces the whole argument prefix with the chosen item's `value`. Suggestions
661
+ are read-only: the callback parses the prefix, never runs the command, and never
662
+ reads a credential or a human label. It reads model objects only to filter and
663
+ order logical rows, and emits and retains only named fields (a bare value and a
664
+ display label), never a credential, a human label, or an arbitrary model or
665
+ account property. It returns freshly copied items or `null`, so a failure in one
666
+ source suppresses suggestions rather than leaking anything.
667
+
668
+ ### `/multi-account` grammar
669
+
670
+ The `/multi-account` callback walks a finite grammar. Every suggested `value` is
671
+ the full argument prefix to insert, for example `status --json` or
672
+ `add anthropic 3`, not a bare token.
673
+
674
+ - With no argument it lists all 19 subcommands in source order: `status`,
675
+ `limits`, `models`, `model`, `cost`, `log`, `rediscover`, `add`, `remove`,
676
+ `clear`, `next`, `switch`, `stop`, `reset`, `reload`, `configure`, `enable`,
677
+ `disable`, `group`.
678
+ - `status` suggests `--json` and every account ID.
679
+ - `models` suggests `install`, `update`, and every account ID.
680
+ - `model` suggests every selectable unified logical model id, with the same
681
+ labels and bare-id insertion as `/multi-account-model`.
682
+ - `remove`, `clear`, and `switch` suggest every account ID.
683
+ - `group` suggests `use`, `reset`, and `status`; `group use` then suggests every
684
+ configured group id.
685
+ - `cost` suggests exactly `day`, `week`, `month`, `quarter`, `half-year`, `year`.
686
+ - `log` suggests exactly `1`, `20`, `50`, `100`.
687
+ - `add` suggests `anthropic` then `openai-codex`; after a family it suggests the
688
+ ascending, current, free, in-range numbered slots for that family.
689
+ - `disable` suggests `anthropic` then `openai-codex`.
690
+ - `limits`, `rediscover`, `next`, `stop`, `reset`, `reload`, `configure`, and
691
+ `enable` take no argument and suggest nothing.
692
+
693
+ The account IDs offered are only discovered slots within the current limit that
694
+ also back a live model.
695
+
696
+ Add-slot suggestions follow live state. They exclude occupied, spare, and
697
+ out-of-range slots, skip the `openai-codex` family while it is disabled, and
698
+ refresh after `rediscover` or a config change. Selecting a complete prefix such
699
+ as `add anthropic 3` runs that exact slot. A prefix matches a suggestion by
700
+ fuzzy search until it exactly names one; an exact terminal token then closes to
701
+ `null` so a valid, complete command is not re-suggested.
702
+
703
+ ### `/multi-account model` logical completion
704
+
705
+ The `/multi-account model` and deprecated `/multi-account-model` callbacks share
706
+ the same logical completion projection. They build a fresh logical inventory each time
707
+ and suggest only currently available `unified` rows, in live catalog order,
708
+ narrowed by session scope. Search is an order-preserving filter over `unified`,
709
+ the legacy `pi-multi-account` search alias, `unified/<model-id>`, the bare
710
+ `<model-id>`, and the configured display name. Completion labels render
711
+ `<model-id> [unified]`; the inserted `value` stays
712
+ the canonical bare `<model-id>` and is never `unified/<model-id>`. Slashes inside
713
+ a model ID are preserved.
714
+
715
+ A duplicate row whose bare ID collides with a prefixed form is omitted, a
716
+ physical (non-logical) row never appears, and a closed inventory returns `null`.
717
+ As with the command grammar, an exact bare ID that names a retained candidate
718
+ closes to `null` rather than re-suggesting the model you already typed. The
719
+ direct switch form still rejects a partial, fuzzy, or foreign reference: it acts
720
+ only on one exact bare ID or one exact `unified/<id>` reference.
721
+
722
+ ### Limits
723
+
724
+ Suggestions need Pi's terminal UI. Argument autocomplete is a host non-goal in
725
+ RPC, JSON, and print (`pi -p`) contexts; the callbacks run zero times there and
726
+ handler output is identical with or without them. Re-opening the argument
727
+ picker with Tab after a token is a host behavior this extension does not change.
728
+ The built-in `/model` picker is unchanged. Only the extension-owned
729
+ `/multi-account model` picker rows and completion labels render `[unified]`;
730
+ their search includes the
731
+ `unified` field described above.
732
+
733
+ ## Usage and cost intelligence
734
+
735
+ Usage comes from two independent record types:
736
+
737
+ - response records carry token totals and retained provider cost;
738
+ - provider headers and fail-soft usage fetches carry utilization, remaining
739
+ quota, and recovery times.
740
+
741
+ A token record with no utilization does not hide a real utilization reading.
742
+ Fresh local and machine-shared observations inform routing. Stale observations
743
+ remain display evidence only.
744
+
745
+ `/multi-account cost` reports two separate facts:
746
+
747
+ 1. Pi's retained provider cost;
748
+ 2. an API-equivalent estimate based on a bounded cached public rate snapshot.
749
+
750
+ The estimate is not a bill. Missing, stale, or malformed pricing stays
751
+ `unpriced`; it is never treated as zero or substituted for retained cost.
752
+
753
+ Reported cost and value figures are scoped to this extension's own retained
754
+ provider-response history; they never read or sum a delegate rollup. Joining
755
+ delegate usage remains separate future work.
756
+
757
+ Full-detail utilization-window and response-cost history is retained for 90
758
+ days. The first append on each UTC day removes older recognized records under
759
+ the history lock; a capacity-bound append repeats expiry before refusing the
760
+ write. Closed UTC calendar periods are immutable. Days derive from raw history; ISO weeks and months derive from days;
761
+ quarters, half-years, and years derive from months. A missing child period is a
762
+ coverage gap, not zero usage. Period closure runs on the first observation in a
763
+ new UTC day and before cost rendering under one machine-global lease. It does
764
+ not create one timer per Pi process.
765
+
766
+ ## Standalone CLI
767
+
768
+ The package also ships a standalone `multi-account` shell command (`bin` entry
769
+ `multi-account`, `scripts/multi-account.mjs`) that runs independent of any
770
+ running Pi session.
771
+
772
+ ### `multi-account cost`
773
+
774
+ ```text
775
+ multi-account cost
776
+ multi-account cost --period quarter --timezone America/New_York --format json
777
+ multi-account cost --from 2026-01-01 --to 2026-04-01 --timezone UTC --format json
778
+ multi-account cost --from 2026-01-01T12:00:00-05:00 --to 2026-01-02T12:00:00-05:00 --format json
779
+ multi-account cost --all-history --format text
780
+ multi-account cost refresh-pricing
781
+ multi-account cost close-periods
782
+ ```
783
+
784
+ Plain `cost` (default `--period month --format text`) is a strict read-only,
785
+ offline probe. It never contacts a provider, refreshes pricing, closes a
786
+ period, migrates configuration, edits an account rate, or creates a catalog,
787
+ including on an empty first run. It feeds the same pure report projection as
788
+ `/multi-account cost` and the `multi_account_status` agent tool. Its
789
+ tier-aware API-equivalent pricing draws on the same installed-catalog data
790
+ those surfaces use: this package's own pinned `@earendil-works/pi-ai`
791
+ dependency inside the standalone process, and the running session's live
792
+ model registry inside Pi.
793
+
794
+ `--period <day|week|month|quarter|half-year|year>`, paired `--from`/`--to`,
795
+ and `--all-history` are mutually exclusive; `--format` is `text` or `json`
796
+ (one versioned document); `--timezone` is an IANA zone (default `UTC`) that
797
+ sets calendar-period boundaries and local midnight for date-only custom
798
+ bounds. Account-cost allocation always splits at UTC calendar-month
799
+ boundaries regardless of this display timezone. A custom bound is an ISO date
800
+ (`YYYY-MM-DD`) or an RFC 3339 timestamp with an explicit `Z` or numeric
801
+ offset; the range is start-inclusive and end-exclusive.
802
+
803
+ `multi-account cost refresh-pricing` and `multi-account cost close-periods`
804
+ are explicit write actions, kept separate from the read-only report path.
805
+ They reach the same authorized OpenRouter pricing cache and machine-leased
806
+ period closer the existing slash/tool observation-driven closure already uses.
807
+
808
+ Exit codes are stable across every standalone command: `0` success
809
+ (including a declined confirmation or a truthful empty report), `1` an
810
+ unexpected internal failure, `2` a syntax, argument, or domain validation
811
+ failure, `3` when retained data cannot satisfy the requested precision, `4`
812
+ for corrupt retained cost history, and `5` for an explicit
813
+ `refresh-pricing`/`close-periods` action failure. Text or JSON goes to
814
+ standard output; diagnostics go to standard error.
815
+
816
+ ### `multi-account account set-plan`
817
+
818
+ ```text
819
+ multi-account account set-plan <account-id> --type <preset-id> --effective-from <timestamp> [--monthly-usd <amount>]
820
+ ```
821
+
822
+ Assigns a shipped or operator-added catalog preset as an account's new
823
+ effective rate record. `--effective-from` is always required as an RFC 3339
824
+ instant with an explicit `Z` or numeric offset; it is never inferred from a
825
+ history start or renewal boundary. The command resolves the preset, prints a
826
+ preview naming the account, provider, account type, preset, monthly rate,
827
+ effective instant, and catalog version, then asks for a literal `y`/`yes`
828
+ confirmation before writing anything. `--monthly-usd` overrides the preset's
829
+ default rate; an explicit `0` is a valid rate and differs from having no rate
830
+ at all. A later catalog edit never rewrites an existing rate record or a past
831
+ report result.
832
+
833
+ This command never migrates the legacy `monthlySubscriptionUsd` value: it does
834
+ not read, convert, or delete it, and it never invents an `--effective-from`
835
+ from a history start or renewal boundary. Recording rate history for an
836
+ account that already has a legacy value requires this explicit,
837
+ operator-supplied instant; there is no automatic migration path.
838
+
839
+ Run `multi-account --help`, `multi-account cost --help`, or
840
+ `multi-account account set-plan --help` for the exact current grammar.
841
+
842
+ ## Storage and privacy
843
+
844
+ All files live below `$PI_CODING_AGENT_DIR/pi-multi-account/` or the equivalent
845
+ `~/.pi/agent/pi-multi-account/` default.
846
+
847
+ | File | Default file bound | Contents |
848
+ | --- | ---: | --- |
849
+ | `config.json` | operator-managed | Configuration; no credential values. |
850
+ | `config.lock` | 2 KiB | Short machine-global lease, mode `0600`, held only while `configure` commits. |
851
+ | `usage.ndjson` | 512 KiB | Per-account token and rate-limit observations shared across Pi processes. |
852
+ | `diagnostics.ndjson` | 1 MiB | Sanitized routing events, mode `0600`; compaction keeps the newest complete records. |
853
+ | `window-history.ndjson` | 128 MiB, 90 days | Utilization-window history. |
854
+ | `cost-history.ndjson` | ≤512 MiB, 90 days | Bounded response-cost observations; the byte cap never exceeds Node's safe decoded-string limit. |
855
+ | `*-history.ndjson.retention` | one integer line | UTC-day marker that limits ordinary expiry compaction to once per store per day. |
856
+ | `cost-period-digest.ndjson` | 64 MiB | Immutable closed-period project/account/model rollups. |
857
+ | `api-pricing.json` | 1,000,000 bytes | Cached public pricing data. |
858
+ | `openrouter-budget.json` | 64 KiB, 512 projects | Per-project UTC-day worst-case OpenRouter reservations; inactive entries expire after seven days. |
859
+ | `declaration-notice.json` | 1 KiB | UTC-day and stale/not-installed condition for the throttled startup warning. |
860
+
861
+ History expiry is append-driven. The first append to each store on a new UTC day
862
+ removes recognized records older than 90 days. A capacity-bound append may repeat
863
+ that check during the same day. An idle store retains its existing bytes until
864
+ its next append.
865
+
866
+ Stop every running Pi process before upgrading across the `usage.ndjson` append-cap
867
+ change, then restart them. Older processes do not take the new usage mutation
868
+ lease. Mixing old and new processes can bypass the cap or race usage compaction.
869
+
870
+ Observation, diagnostic, digest, pricing, and budget stores may contain
871
+ canonical provider IDs, model IDs, token and cost numbers, timestamps, bounded
872
+ error categories, and `project-<digest>` keys. They do not contain OAuth tokens,
873
+ API keys, authorization headers, request or response bodies, prompts, assistant
874
+ text, tool content, request IDs, raw provider errors, or raw project paths.
875
+ Human account and project labels remain in `config.json`; renderers resolve them
876
+ at output time instead of copying them into retained observations.
877
+
878
+ Persistent diagnostics reuse the same sanitizer as `/multi-account log`.
879
+ Oversized, malformed, or unsafe records are rejected. Storage failures are
880
+ fail-soft for managed subscription routing and fail-closed for a metered budget
881
+ reservation.
882
+
883
+ ## Architecture
884
+
885
+ | Layer | Modules |
886
+ | --- | --- |
887
+ | Provider integration | `upstream-anthropic.ts`, `anthropic-context-compat.ts`, `anthropic-alias-stream.ts`, `codex-adapter.ts`, `provider-registration.ts`, `catalog-rebinding.ts` |
888
+ | Account discovery and credentials | `discovery.ts`, `credential-lifecycle.ts`, `credential-refresh.ts`, `warmer.ts`, `account-labels.ts` |
889
+ | Routing policy | `runtime-state.ts`, `error-classification.ts`, `cooldowns.ts`, `routing.ts`, `preflight.ts`, `model-support.ts` |
890
+ | Continuation lifecycle | `continuation.ts`, `watchdog.ts`, `compaction.ts`, `lifecycle.ts` |
891
+ | Operator surfaces | `commands.ts`, `command-completions.ts`, `fuzzy.ts`, `status-view.ts`, `logical-route-indicator.ts`, `diagnostics.ts`, `diagnostic-store.ts`, `logical-model-switcher.ts`, `logical-model-selector.ts` |
892
+ | Usage and shared state | `usage.ts`, `shared-usage.ts`, `usage-fetch.ts`, `window-history.ts`, `history-store.ts`, `machine-lease.ts` |
893
+ | Cost intelligence | `cost-history.ts`, `cost-digest.ts`, `cost-digest-store.ts`, `cost-period-closer.ts`, `cost-report*.ts`, `coverage-attestation.ts` |
894
+ | Metered last resort | `openrouter-fallback.ts`, `openrouter-budget.ts`, `pricing-cache.ts`, `api-pricing.ts` |
895
+ | Composition | `index.ts` |
896
+
897
+ The extension does not register `before_provider_request`. The captured in-tree
898
+ Anthropic configuration still owns OAuth callbacks, headers, and ordinary
899
+ requests. A narrow stateless selector sends adaptive requests to the
900
+ MIT-licensed local adapter and leaves every other request on the vendored
901
+ `0.2.5-intel.1` stream. Before invoking either stream, the selector reconstructs current Pi
902
+ transcript system text and active tool declarations in the legacy context form
903
+ that both implementations consume. The adaptive adapter imports the vendored
904
+ prompt, message, and tool helpers and changes only adaptive-thinking fields.
905
+ The base provider and numbered aliases use that same selector; it does not
906
+ replay errors. Because Pi merges a later provider registration over an earlier
907
+ one, the extension registers that base configuration again once at session
908
+ start. This is its only permitted base-provider exception.
909
+
910
+ ## Known boundaries
911
+
912
+ - Pi defaults to three retries for a retryable provider error before
913
+ `agent_settled`; Pi settings can change that count. This extension cannot
914
+ suppress the host retry layer.
915
+ - Same-turn replay is intentionally excluded. Recovery uses a fixed continuation
916
+ message after settlement.
917
+ - Provider usage endpoints are not guaranteed to be available. Fetches time out,
918
+ fail soft, and report their bounded state.
919
+ - Only sessions that load this extension can use its failover lifecycle. At this
920
+ release, the `pi-delegate` package may load project extensions in workers, but
921
+ its isolated supervisor and collapse sessions run with extensions disabled.
922
+ Provider errors from those calls are outside this package.
923
+ - The current `pi-fork-delegate` repository's worker runtime can discover
924
+ numbered alias models yet fail to resolve their alias credentials. Use a base
925
+ provider for delegated work until the upstream runtime fix is deployed and
926
+ verified.
927
+ - OpenRouter is a non-delegate-session escape hatch, not a delegate fallback and
928
+ not a replacement for managed OAuth accounts.
929
+ - Cross-family subscription model IDs are not portable. Configure
930
+ `preferredModels` for every enabled subscription destination family or accept
931
+ its catalog head. Owning-vendor API and OpenRouter routes instead use
932
+ `tierModelMap` and fail closed rather than choosing a catalog head.
933
+ - The `config.lock` lease binds cooperating Pi processes. An editor that ignores
934
+ it can still replace the file. `configure` narrows that window by re-reading
935
+ under the lease, but a change landing between that read and the atomic rename
936
+ is not detectable.
937
+ - `google-antigravity` has no supported inner-retry behavior yet, so recovery
938
+ reserves zero inner retries for it, the same conservative default already
939
+ used for `openai` and `openai-codex`.
940
+ - Automated verification never performs a real Google sign-in. Exercising a
941
+ live `google-antigravity` account end to end is an operator action performed
942
+ after `integration:verify` passes, the same deferred pattern already used for
943
+ Anthropic and Codex.
944
+
945
+ ## Verification
946
+
947
+ The canonical release gate is `integration:verify`. Its child smoke requires
948
+ the current Pi session path. Run Pi's public `/session` command in this checkout
949
+ and copy its `File:` value:
950
+
951
+ ```sh
952
+ npm ci
953
+ PI_BIN="$(command -v pi)" \
954
+ PI_MULTI_ACCOUNT_CURRENT_SESSION='/absolute/path/from-pi-session.jsonl' \
955
+ npm run integration:verify
956
+ ```
957
+
958
+ `PI_BIN` is required whenever the Pi executable is not beside `process.execPath`.
959
+ The child smoke looks only next to the running Node binary, so a Homebrew or
960
+ nvm-managed Node needs this override. The session file must be real and have a
961
+ recorded cwd matching this checkout.
962
+
963
+ The gate runs TypeScript, all Vitest tests with file parallelism disabled, the
964
+ credential canary, the isolated child smoke, `upstream:check`, and
965
+ `npm pack --dry-run`. `upstream:check` verifies both exact bundled provider
966
+ dependencies and their workspace links, each package's structured `UPSTREAM.md`,
967
+ and every file in its recorded baseline manifest. It reverse-applies each
968
+ package's recorded patch series, verifies the clean baseline, applies the series
969
+ forward, and compares the result with the vendored package. The check makes no
970
+ network request and fails on an unrecorded changed, added, or removed file,
971
+ including files below nested `node_modules` or `.test-dist` directories. Only
972
+ those generated directories at a package root are excluded.
973
+ `npm run upstream:verify-baseline` is the
974
+ separate network check that fetches each recorded upstream commit; it is not part
975
+ of `check` or the CI gate.
976
+
977
+ The Vitest suite's `actual-delegate-runtime` case requires `PI_DELEGATE_PACKAGE_DIR`
978
+ (the installed `pi-delegate` package root) and is otherwise skipped, so the
979
+ release gate additionally requires that variable to be set, failing closed
980
+ rather than passing green on a silently skipped probe. That case measures the
981
+ delegate worker's own bundled Pi SDK version at runtime instead of assuming it
982
+ matches the host `PI_BIN` — this repository's development lock pins the host to
983
+ `@earendil-works/pi-coding-agent@0.84.4`, but a worker session resolves its own
984
+ installed copy independently, and the two are not assumed equal.
985
+
986
+ The smoke starts real offline Pi child processes with fresh `HOME` and
987
+ `PI_CODING_AGENT_DIR` directories. It blocks external sockets and model prompts,
988
+ checks production-only loading and both coexistence orders, and verifies that
989
+ the selected settings, `AuthStorage`, and invoking session files do not change.
990
+
991
+ Every behavioral change must also pass a mutation control: break production in
992
+ the specific way the defect would occur, prove a named test fails, then restore
993
+ from a backup and rerun the test.
994
+
995
+ ## License
996
+
997
+ Our code is available under the [MIT License](LICENSE). This package also
998
+ contains modified MIT-licensed copies of `pi-anthropic-oauth` and
999
+ `pi-antigravity`; see [NOTICE](NOTICE) and each fork's included `LICENSE` file.