@centerforagenticai/pi-multi-account 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,346 @@
1
+ /**
2
+ * Editable, versioned subscription-plan preset catalog.
3
+ *
4
+ * The catalog ships as JSON data at `config/subscription-plans.v1.json` so an
5
+ * operator can inspect and edit it without reading TypeScript. This module
6
+ * only validates and merges that data; it never encodes provider, account
7
+ * type, or price information as a TypeScript constant, switch statement, or
8
+ * map.
9
+ *
10
+ * Every shipped preset is an operator-provided monthly-equivalent USD
11
+ * comparison default, not a researched provider entitlement or independently
12
+ * verified official price. A label containing "5x" or "20x" is display text
13
+ * only -- this module derives no token allowance, quota multiplier,
14
+ * throughput, or availability claim from it, and reads no provider quota
15
+ * source.
16
+ *
17
+ * Machine-global configuration (`src/config.ts`) may supply
18
+ * `subscriptionPlanCatalogOverrides`, keyed by preset id: a key that matches a
19
+ * shipped id replaces that preset; any other key adds a validated custom
20
+ * preset. `src/config.ts` documents "No project-local override loading." --
21
+ * there is no project-scoped catalog layer, and this module never reads one.
22
+ */
23
+
24
+ import { readFileSync } from "node:fs";
25
+ import { dirname, join } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ export const CATALOG_PROVIDERS = ["chatgpt", "anthropic"] as const;
29
+ export type CatalogProvider = (typeof CATALOG_PROVIDERS)[number];
30
+
31
+ export const CATALOG_ACCOUNT_TYPES = ["individual", "business", "team"] as const;
32
+ export type CatalogAccountType = (typeof CATALOG_ACCOUNT_TYPES)[number];
33
+
34
+ export const CATALOG_PRODUCT_BASES = ["per-account"] as const;
35
+ export type CatalogProductBasis = (typeof CATALOG_PRODUCT_BASES)[number];
36
+
37
+ export const MAX_PRESET_ID_LENGTH = 64;
38
+ const MAX_LABEL_LENGTH = 64;
39
+ const MAX_PROVENANCE_LENGTH = 256;
40
+
41
+ /** Lowercase, hyphen-separated identifier shape shared by every preset id. */
42
+ export const PRESET_ID_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
43
+
44
+ export interface SubscriptionPlanPreset {
45
+ readonly id: string;
46
+ readonly provider: CatalogProvider;
47
+ readonly accountType: CatalogAccountType;
48
+ readonly label: string;
49
+ readonly monthlyUsd: number;
50
+ readonly productBasis: CatalogProductBasis;
51
+ readonly provenance: string;
52
+ }
53
+
54
+ export interface SubscriptionPlanCatalog {
55
+ readonly version: number;
56
+ readonly presets: readonly SubscriptionPlanPreset[];
57
+ }
58
+
59
+ /** Machine-global override entry: a preset's fields, keyed externally by id. */
60
+ export type SubscriptionPlanCatalogOverride = Omit<SubscriptionPlanPreset, "id">;
61
+
62
+ export class SubscriptionPlanCatalogError extends Error {
63
+ constructor(message: string) {
64
+ super(`[subscription-plan-catalog] ${message}`);
65
+ this.name = "SubscriptionPlanCatalogError";
66
+ }
67
+ }
68
+
69
+ function isRecord(value: unknown): value is Record<string, unknown> {
70
+ return typeof value === "object" && value !== null && !Array.isArray(value);
71
+ }
72
+
73
+ function isCatalogProvider(value: unknown): value is CatalogProvider {
74
+ return (
75
+ typeof value === "string" &&
76
+ (CATALOG_PROVIDERS as readonly string[]).includes(value)
77
+ );
78
+ }
79
+
80
+ function isCatalogAccountType(value: unknown): value is CatalogAccountType {
81
+ return (
82
+ typeof value === "string" &&
83
+ (CATALOG_ACCOUNT_TYPES as readonly string[]).includes(value)
84
+ );
85
+ }
86
+
87
+ function isCatalogProductBasis(value: unknown): value is CatalogProductBasis {
88
+ return (
89
+ typeof value === "string" &&
90
+ (CATALOG_PRODUCT_BASES as readonly string[]).includes(value)
91
+ );
92
+ }
93
+
94
+ function isBoundedString(value: unknown, maxLength: number): value is string {
95
+ return (
96
+ typeof value === "string" &&
97
+ value.trim().length > 0 &&
98
+ value.length <= maxLength
99
+ );
100
+ }
101
+
102
+ function validateProvider(value: unknown, context: string): CatalogProvider {
103
+ if (!isCatalogProvider(value)) {
104
+ throw new SubscriptionPlanCatalogError(
105
+ `${context}.provider must be one of [${CATALOG_PROVIDERS.join(", ")}].`,
106
+ );
107
+ }
108
+ return value;
109
+ }
110
+
111
+ function validateAccountType(value: unknown, context: string): CatalogAccountType {
112
+ if (!isCatalogAccountType(value)) {
113
+ throw new SubscriptionPlanCatalogError(
114
+ `${context}.accountType must be one of [${CATALOG_ACCOUNT_TYPES.join(", ")}].`,
115
+ );
116
+ }
117
+ return value;
118
+ }
119
+
120
+ function validateLabel(value: unknown, context: string): string {
121
+ if (!isBoundedString(value, MAX_LABEL_LENGTH)) {
122
+ throw new SubscriptionPlanCatalogError(
123
+ `${context}.label must be a non-empty string of at most ${MAX_LABEL_LENGTH} characters.`,
124
+ );
125
+ }
126
+ return value;
127
+ }
128
+
129
+ function validateMonthlyUsd(value: unknown, context: string): number {
130
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
131
+ throw new SubscriptionPlanCatalogError(
132
+ `${context}.monthlyUsd must be a finite, non-negative USD amount.`,
133
+ );
134
+ }
135
+ return value;
136
+ }
137
+
138
+ function validateProductBasis(value: unknown, context: string): CatalogProductBasis {
139
+ if (!isCatalogProductBasis(value)) {
140
+ throw new SubscriptionPlanCatalogError(
141
+ `${context}.productBasis must be one of [${CATALOG_PRODUCT_BASES.join(", ")}].`,
142
+ );
143
+ }
144
+ return value;
145
+ }
146
+
147
+ function validateProvenance(value: unknown, context: string): string {
148
+ if (!isBoundedString(value, MAX_PROVENANCE_LENGTH)) {
149
+ throw new SubscriptionPlanCatalogError(
150
+ `${context}.provenance must be a non-empty string of at most ${MAX_PROVENANCE_LENGTH} characters.`,
151
+ );
152
+ }
153
+ return value;
154
+ }
155
+
156
+ const PRESET_FIELDS = new Set([
157
+ "id",
158
+ "provider",
159
+ "accountType",
160
+ "label",
161
+ "monthlyUsd",
162
+ "productBasis",
163
+ "provenance",
164
+ ]);
165
+
166
+ /**
167
+ * Validates one candidate preset object. Allowlists exactly the fields above
168
+ * and rejects any other field -- including an entitlement-, quota-, or
169
+ * verification-timestamp-shaped one -- before it can enter the effective
170
+ * catalog. An explicit `monthlyUsd: 0` is valid.
171
+ */
172
+ export function parseSubscriptionPlanPreset(
173
+ value: unknown,
174
+ context: string,
175
+ ): SubscriptionPlanPreset {
176
+ if (!isRecord(value)) {
177
+ throw new SubscriptionPlanCatalogError(`${context} must be a JSON object.`);
178
+ }
179
+ const unknownKeys = Object.keys(value).filter((key) => !PRESET_FIELDS.has(key));
180
+ if (unknownKeys.length > 0) {
181
+ throw new SubscriptionPlanCatalogError(
182
+ `${context} has unsupported field "${unknownKeys[0]}".`,
183
+ );
184
+ }
185
+ const id = value["id"];
186
+ if (!isBoundedString(id, MAX_PRESET_ID_LENGTH) || !PRESET_ID_PATTERN.test(id)) {
187
+ throw new SubscriptionPlanCatalogError(
188
+ `${context}.id must be a lowercase, hyphen-separated identifier of at most ${MAX_PRESET_ID_LENGTH} characters.`,
189
+ );
190
+ }
191
+ return {
192
+ id,
193
+ provider: validateProvider(value["provider"], context),
194
+ accountType: validateAccountType(value["accountType"], context),
195
+ label: validateLabel(value["label"], context),
196
+ monthlyUsd: validateMonthlyUsd(value["monthlyUsd"], context),
197
+ productBasis: validateProductBasis(value["productBasis"], context),
198
+ provenance: validateProvenance(value["provenance"], context),
199
+ };
200
+ }
201
+
202
+ const OVERRIDE_FIELDS = new Set([
203
+ "provider",
204
+ "accountType",
205
+ "label",
206
+ "monthlyUsd",
207
+ "productBasis",
208
+ "provenance",
209
+ ]);
210
+
211
+ /**
212
+ * Validates one machine-global override object. The preset id is the map key
213
+ * in `subscriptionPlanCatalogOverrides`, not a field on this object, so `id`
214
+ * is rejected here as an unsupported field.
215
+ */
216
+ export function parseSubscriptionPlanCatalogOverride(
217
+ value: unknown,
218
+ context: string,
219
+ ): SubscriptionPlanCatalogOverride {
220
+ if (!isRecord(value)) {
221
+ throw new SubscriptionPlanCatalogError(`${context} must be a JSON object.`);
222
+ }
223
+ const unknownKeys = Object.keys(value).filter((key) => !OVERRIDE_FIELDS.has(key));
224
+ if (unknownKeys.length > 0) {
225
+ throw new SubscriptionPlanCatalogError(
226
+ `${context} has unsupported field "${unknownKeys[0]}".`,
227
+ );
228
+ }
229
+ return {
230
+ provider: validateProvider(value["provider"], context),
231
+ accountType: validateAccountType(value["accountType"], context),
232
+ label: validateLabel(value["label"], context),
233
+ monthlyUsd: validateMonthlyUsd(value["monthlyUsd"], context),
234
+ productBasis: validateProductBasis(value["productBasis"], context),
235
+ provenance: validateProvenance(value["provenance"], context),
236
+ };
237
+ }
238
+
239
+ /**
240
+ * Parses one complete catalog document: a positive integer `version` and an
241
+ * array of presets with distinct ids. Used for both the shipped asset and any
242
+ * test fixture that exercises catalog-shape validation.
243
+ */
244
+ export function parseSubscriptionPlanCatalog(value: unknown): SubscriptionPlanCatalog {
245
+ if (!isRecord(value)) {
246
+ throw new SubscriptionPlanCatalogError("Catalog must be a JSON object.");
247
+ }
248
+ const unknownKeys = Object.keys(value).filter(
249
+ (key) => key !== "version" && key !== "presets",
250
+ );
251
+ if (unknownKeys.length > 0) {
252
+ throw new SubscriptionPlanCatalogError(
253
+ `Catalog has unsupported field "${unknownKeys[0]}".`,
254
+ );
255
+ }
256
+ const version = value["version"];
257
+ if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
258
+ throw new SubscriptionPlanCatalogError(
259
+ "Catalog version must be a positive integer.",
260
+ );
261
+ }
262
+ const presetsValue = value["presets"];
263
+ if (!Array.isArray(presetsValue)) {
264
+ throw new SubscriptionPlanCatalogError("Catalog presets must be an array.");
265
+ }
266
+ const presets: SubscriptionPlanPreset[] = [];
267
+ const seenIds = new Set<string>();
268
+ presetsValue.forEach((candidate, index) => {
269
+ const preset = parseSubscriptionPlanPreset(candidate, `presets[${index}]`);
270
+ if (seenIds.has(preset.id)) {
271
+ throw new SubscriptionPlanCatalogError(
272
+ `Catalog contains duplicate preset id "${preset.id}".`,
273
+ );
274
+ }
275
+ seenIds.add(preset.id);
276
+ presets.push(preset);
277
+ });
278
+ return { version, presets: Object.freeze(presets) };
279
+ }
280
+
281
+ const SHIPPED_CATALOG_PATH = join(
282
+ dirname(fileURLToPath(import.meta.url)),
283
+ "..",
284
+ "config",
285
+ "subscription-plans.v1.json",
286
+ );
287
+
288
+ /**
289
+ * Loads and validates the shipped catalog asset directly from disk on every
290
+ * call. Read-only: never copies, caches to another location, or writes
291
+ * anything, including on first run.
292
+ */
293
+ export function loadShippedSubscriptionPlanCatalog(): SubscriptionPlanCatalog {
294
+ const raw = readFileSync(SHIPPED_CATALOG_PATH, "utf-8");
295
+ let parsed: unknown;
296
+ try {
297
+ parsed = JSON.parse(raw);
298
+ } catch {
299
+ throw new SubscriptionPlanCatalogError(
300
+ `Shipped catalog at ${SHIPPED_CATALOG_PATH} is not valid JSON.`,
301
+ );
302
+ }
303
+ return parseSubscriptionPlanCatalog(parsed);
304
+ }
305
+
306
+ /**
307
+ * Merges machine-global overrides into a base catalog. An override id that
308
+ * matches an existing preset id replaces that preset in place; any other
309
+ * override id appends a validated custom preset. The merged catalog keeps the
310
+ * base catalog's `version` -- overrides change presets, not the shipped
311
+ * asset's data version.
312
+ */
313
+ export function resolveSubscriptionPlanCatalog(
314
+ base: SubscriptionPlanCatalog,
315
+ overrides: Readonly<Record<string, SubscriptionPlanCatalogOverride>>,
316
+ ): SubscriptionPlanCatalog {
317
+ const byId = new Map<string, SubscriptionPlanPreset>();
318
+ for (const preset of base.presets) {
319
+ byId.set(preset.id, preset);
320
+ }
321
+ for (const [id, override] of Object.entries(overrides)) {
322
+ byId.set(id, { id, ...override });
323
+ }
324
+ return {
325
+ version: base.version,
326
+ presets: Object.freeze([...byId.values()]),
327
+ };
328
+ }
329
+
330
+ /** Loads the shipped catalog and applies machine-global overrides in one call. */
331
+ export function loadEffectiveSubscriptionPlanCatalog(
332
+ overrides: Readonly<Record<string, SubscriptionPlanCatalogOverride>>,
333
+ ): SubscriptionPlanCatalog {
334
+ return resolveSubscriptionPlanCatalog(
335
+ loadShippedSubscriptionPlanCatalog(),
336
+ overrides,
337
+ );
338
+ }
339
+
340
+ /** Finds a preset by id in an already-resolved catalog, or `undefined`. */
341
+ export function findSubscriptionPlanPreset(
342
+ catalog: SubscriptionPlanCatalog,
343
+ presetId: string,
344
+ ): SubscriptionPlanPreset | undefined {
345
+ return catalog.presets.find((preset) => preset.id === presetId);
346
+ }
@@ -0,0 +1,46 @@
1
+ export type TierModelDestination = "anthropic" | "openai" | "openrouter";
2
+
3
+ export type TierModelMap = Readonly<
4
+ Partial<Record<TierModelDestination, Readonly<Record<string, string>>>>
5
+ >;
6
+
7
+ export const MAX_TIER_MODEL_ID_LENGTH = 256;
8
+
9
+ const TIER_MODEL_DESTINATIONS: readonly TierModelDestination[] = [
10
+ "anthropic",
11
+ "openai",
12
+ "openrouter",
13
+ ];
14
+ const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/u;
15
+
16
+ function isTierModelDestination(
17
+ value: unknown,
18
+ ): value is TierModelDestination {
19
+ return TIER_MODEL_DESTINATIONS.includes(value as TierModelDestination);
20
+ }
21
+
22
+ function isValidRequestedModel(value: unknown): value is string {
23
+ return (
24
+ typeof value === "string" &&
25
+ value !== "*" &&
26
+ value.length > 0 &&
27
+ value.length <= MAX_TIER_MODEL_ID_LENGTH &&
28
+ value.trim().length > 0 &&
29
+ !CONTROL_CHARACTER.test(value)
30
+ );
31
+ }
32
+
33
+ export function resolveTierModel(
34
+ requested: string,
35
+ destination: TierModelDestination,
36
+ catalog: readonly string[],
37
+ map: TierModelMap,
38
+ ): string | undefined {
39
+ if (!isValidRequestedModel(requested) || !isTierModelDestination(destination)) {
40
+ return undefined;
41
+ }
42
+ if (catalog.includes(requested)) return requested;
43
+
44
+ const mapped = map[destination]?.[requested];
45
+ return mapped !== undefined && catalog.includes(mapped) ? mapped : undefined;
46
+ }
@@ -0,0 +1,315 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ProviderConfig,
4
+ ProviderModelConfig,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { registerApiProvider } from "@earendil-works/pi-ai/compat";
7
+ import {
8
+ ANTHROPIC_ALIAS_API,
9
+ createAnthropicAliasStream,
10
+ type AnthropicUpstreamStream,
11
+ } from "./anthropic-alias-stream.js";
12
+ import { streamAnthropicAdaptive } from "./anthropic-adaptive-stream.js";
13
+ import { toPinnedAnthropicContext } from "./anthropic-context-compat.js";
14
+
15
+ export type UpstreamAnthropicExtension = (pi: ExtensionAPI) => void;
16
+
17
+ /**
18
+ * Identifies this package's entries in pi-ai's process-global API registry, so
19
+ * ownership is explicit and removal is precise. The registry is keyed by API id
20
+ * with no ownership check, so the tag is the only ownership record there is.
21
+ */
22
+ export const ALIAS_API_SOURCE_ID = "hyphagroup-pi-multi-account";
23
+
24
+ /**
25
+ * The registration seam, injected only so a test can prove the fail-closed
26
+ * contract. Production always uses pi-ai compat's real registrar.
27
+ */
28
+ export type AliasApiRegistrar = (
29
+ aliasStream: AnthropicUpstreamStream,
30
+ ) => void;
31
+
32
+ const UPSTREAM_ENTRYPOINT = "../packages/pi-anthropic-oauth/src/index.ts";
33
+
34
+ const isRecord = (value: unknown): value is Record<PropertyKey, unknown> =>
35
+ typeof value === "object" && value !== null;
36
+
37
+ /** Loads the package entrypoint by variable specifier so TypeScript does not
38
+ * compile third-party source under this repository's stricter compiler flags.
39
+ * Pi's extension loader (jiti) resolves the exact installed TypeScript module.
40
+ */
41
+ export async function loadUpstreamAnthropicExtension(): Promise<UpstreamAnthropicExtension> {
42
+ const loaded: unknown = await import(UPSTREAM_ENTRYPOINT);
43
+ if (!isRecord(loaded) || typeof loaded.default !== "function") {
44
+ throw new UpstreamAnthropicContractError(
45
+ "package entrypoint did not export a default extension function",
46
+ );
47
+ }
48
+ return loaded.default as UpstreamAnthropicExtension;
49
+ }
50
+
51
+ type CompleteAnthropicProviderConfig = ProviderConfig & {
52
+ oauth: NonNullable<ProviderConfig["oauth"]>;
53
+ streamSimple: NonNullable<ProviderConfig["streamSimple"]>;
54
+ };
55
+
56
+ export interface CapturedAnthropicProvider {
57
+ readonly name: "anthropic";
58
+ readonly config: CompleteAnthropicProviderConfig;
59
+ }
60
+
61
+ export class UpstreamAnthropicContractError extends Error {
62
+ constructor(message: string) {
63
+ super(
64
+ `[pi-anthropic-oauth contract] ${message} Run npm run upstream:check and review UPSTREAM.md before release.`,
65
+ );
66
+ this.name = "UpstreamAnthropicContractError";
67
+ }
68
+ }
69
+
70
+ function assertCompleteConfig(
71
+ config: ProviderConfig,
72
+ ): asserts config is CompleteAnthropicProviderConfig {
73
+ if (config.api !== "anthropic-messages") {
74
+ throw new UpstreamAnthropicContractError(
75
+ `expected provider api anthropic-messages, received ${String(config.api)}`,
76
+ );
77
+ }
78
+ // pi-anthropic-oauth 0.2.0 registers the base provider WITHOUT a `models`
79
+ // field — Pi host-resolves the anthropic catalog from its built-in provider
80
+ // layer. A captured `models` array is therefore optional; alias catalogs are
81
+ // sourced from the live model registry (see index.ts), not the captured
82
+ // config. If a `models` field is present it must still be a non-empty array.
83
+ if (config.models !== undefined) {
84
+ if (!Array.isArray(config.models) || config.models.length === 0) {
85
+ throw new UpstreamAnthropicContractError(
86
+ "captured provider supplied a models field that is not a non-empty array",
87
+ );
88
+ }
89
+ }
90
+ if (!config.oauth) {
91
+ throw new UpstreamAnthropicContractError(
92
+ "captured provider did not supply OAuth callbacks",
93
+ );
94
+ }
95
+ if (typeof config.oauth.login !== "function") {
96
+ throw new UpstreamAnthropicContractError("captured OAuth login is missing");
97
+ }
98
+ if (typeof config.oauth.refreshToken !== "function") {
99
+ throw new UpstreamAnthropicContractError(
100
+ "captured OAuth refreshToken is missing",
101
+ );
102
+ }
103
+ if (typeof config.oauth.getApiKey !== "function") {
104
+ throw new UpstreamAnthropicContractError(
105
+ "captured OAuth getApiKey is missing",
106
+ );
107
+ }
108
+ if (typeof config.streamSimple !== "function") {
109
+ throw new UpstreamAnthropicContractError(
110
+ "captured provider did not supply streamSimple",
111
+ );
112
+ }
113
+ }
114
+
115
+ function createCaptureProxy(
116
+ pi: ExtensionAPI,
117
+ registrations: Array<{ name: string; config: ProviderConfig }>,
118
+ ): ExtensionAPI {
119
+ return new Proxy(pi, {
120
+ get(target, property, receiver) {
121
+ if (property === "registerProvider") {
122
+ return (name: string, config: ProviderConfig): void => {
123
+ registrations.push({ name, config });
124
+ };
125
+ }
126
+
127
+ const value = Reflect.get(target, property, receiver) as unknown;
128
+ return typeof value === "function" ? value.bind(target) : value;
129
+ },
130
+ });
131
+ }
132
+
133
+ /**
134
+ * Invokes the exact-pinned upstream entrypoint against a delegating proxy and
135
+ * returns its one Anthropic registration. Provider configuration is held only
136
+ * by reference; this path never serializes, inspects, or logs OAuth material.
137
+ */
138
+ export function captureUpstreamAnthropicProvider(
139
+ pi: ExtensionAPI,
140
+ upstream: UpstreamAnthropicExtension,
141
+ ): CapturedAnthropicProvider {
142
+ const registrations: Array<{ name: string; config: ProviderConfig }> = [];
143
+ upstream(createCaptureProxy(pi, registrations));
144
+
145
+ if (registrations.length !== 1) {
146
+ throw new UpstreamAnthropicContractError(
147
+ `expected exactly one registerProvider call, received ${registrations.length}`,
148
+ );
149
+ }
150
+
151
+ const registration = registrations[0];
152
+ if (!registration || registration.name !== "anthropic") {
153
+ throw new UpstreamAnthropicContractError(
154
+ `expected registerProvider(\"anthropic\", config), received ${registration?.name ?? "no registration"}`,
155
+ );
156
+ }
157
+
158
+ assertCompleteConfig(registration.config);
159
+ return { name: "anthropic", config: registration.config };
160
+ }
161
+
162
+ /**
163
+ * Chooses the extension-owned exact-provenance adapter only for adaptive
164
+ * thinking requests. Every other request stays on the exact-pinned OAuth
165
+ * stream. Both receive the Pi 0.84 context shape they consume, reconstructed
166
+ * from Pi 0.87 transcript declarations when needed. Errors are not retried
167
+ * through the other implementation.
168
+ */
169
+ export function createAnthropicStreamSelector(
170
+ adaptiveStream: AnthropicUpstreamStream,
171
+ pinnedStream: AnthropicUpstreamStream,
172
+ ): AnthropicUpstreamStream {
173
+ return (model, context, options) => {
174
+ const useAdaptiveStream =
175
+ (model.compat as { forceAdaptiveThinking?: boolean } | undefined)
176
+ ?.forceAdaptiveThinking === true && options?.reasoning !== undefined;
177
+ const selected = useAdaptiveStream ? adaptiveStream : pinnedStream;
178
+ return selected(model, toPinnedAnthropicContext(context), options);
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Publishes the extension-owned alias API id into pi-ai's process-global API
184
+ * registry.
185
+ *
186
+ * Pi's own provider path never needs this: `provider-composer` short-circuits to
187
+ * the extension's `streamSimple` when `model.api` matches the extension's `api`.
188
+ * But any caller that dispatches by `model.api` through pi-ai compat resolves
189
+ * through this registry instead, and threw `No API provider registered for api:
190
+ * hypha-anthropic-oauth` without it (issue #11).
191
+ *
192
+ * Both registry slots share the one alias handler that wraps the derived
193
+ * selector, so the registry entry and every alias provider config dispatch
194
+ * identically by construction. The registry stores function references and the
195
+ * source id only; Pi resolves the alias credential per request and compat
196
+ * forwards it in `options.apiKey`.
197
+ */
198
+ const registerAliasApiWithCompat: AliasApiRegistrar = (aliasStream) => {
199
+ // Deliberately uncast. `SimpleStreamOptions` extends `StreamOptions` with
200
+ // optional fields, so the one alias handler satisfies both slots today. If a
201
+ // pi-ai upgrade changes either handler signature, this must fail typecheck
202
+ // rather than be silently bridged by a cast.
203
+ registerApiProvider(
204
+ {
205
+ api: ANTHROPIC_ALIAS_API,
206
+ stream: aliasStream,
207
+ streamSimple: aliasStream,
208
+ },
209
+ ALIAS_API_SOURCE_ID,
210
+ );
211
+ };
212
+
213
+ /**
214
+ * Captures the exact-pinned registration, then derives a distinct base config
215
+ * that replaces only its stream handler with the stateless adaptive selector.
216
+ * OAuth callbacks and every other captured field remain reference-identical.
217
+ *
218
+ * Also registers the alias API id, because this is the one place that holds the
219
+ * derived selector every alias wraps, and it runs once per extension factory
220
+ * generation — which is exactly the registration's lifetime. Pi's `reload()`
221
+ * calls `resetApiProviders()` before rerunning factories, so the following
222
+ * generation restores the entry structurally.
223
+ *
224
+ * The registration is deliberately NOT wrapped in try/catch. A registrar failure
225
+ * must reject out of the extension factory so Pi omits the extension entirely,
226
+ * rather than publishing alias providers whose API id cannot resolve — which is
227
+ * the very defect this function fixes.
228
+ */
229
+ export async function registerUpstreamAnthropicProvider(
230
+ pi: ExtensionAPI,
231
+ upstream?: UpstreamAnthropicExtension,
232
+ adaptiveStream: AnthropicUpstreamStream = streamAnthropicAdaptive,
233
+ registerAliasApi: AliasApiRegistrar = registerAliasApiWithCompat,
234
+ ): Promise<CapturedAnthropicProvider> {
235
+ const extension = upstream ?? (await loadUpstreamAnthropicExtension());
236
+ const captured = captureUpstreamAnthropicProvider(pi, extension);
237
+ const streamSimple = createAnthropicStreamSelector(
238
+ adaptiveStream,
239
+ captured.config.streamSimple,
240
+ );
241
+ const adapted: CompleteAnthropicProviderConfig = {
242
+ ...captured.config,
243
+ streamSimple: streamSimple as CompleteAnthropicProviderConfig["streamSimple"],
244
+ };
245
+ const derived: CapturedAnthropicProvider = {
246
+ name: captured.name,
247
+ config: adapted,
248
+ };
249
+ registerAliasApi(createAnthropicAliasStream(streamSimple));
250
+ pi.registerProvider(derived.name, derived.config);
251
+ return derived;
252
+ }
253
+
254
+ /**
255
+ * Re-asserts the derived base registration once every extension factory has run.
256
+ *
257
+ * A coexisting standalone `pi-anthropic-oauth` extension registers `anthropic`
258
+ * from its own factory. Pi merges a later registration's defined fields over an
259
+ * earlier one, so an extension-first load order would otherwise restore the
260
+ * pinned legacy handler on the base provider and adaptive base requests would
261
+ * fall back to budget thinking. Registering the same derived configuration
262
+ * object again leaves an equivalent effective configuration and handler.
263
+ *
264
+ * This is the one bounded base-provider exception recorded in AGENTS.md. It adds
265
+ * no shared state, lock, timer, or credential path, and it must not be repeated
266
+ * on a schedule. A later `session_start` handler, a post-load dynamic
267
+ * registration, or `unregisterProvider` remains out of scope.
268
+ */
269
+ export function reassertAnthropicBaseRegistration(
270
+ pi: ExtensionAPI,
271
+ captured: CapturedAnthropicProvider,
272
+ ): void {
273
+ pi.registerProvider(captured.name, captured.config);
274
+ }
275
+
276
+ /**
277
+ * Builds an alias registration from captured upstream behavior. OAuth callback
278
+ * identities are preserved; only the API dispatch identity is rebound to the
279
+ * extension-owned adapter. The `models` catalog is REQUIRED and supplied by the
280
+ * caller from the live model registry — pi-anthropic-oauth 0.2.0 no longer
281
+ * carries a `models` field on the captured base config, and alias provider ids
282
+ * have no built-in base provider for Pi to inherit a catalog from.
283
+ */
284
+ export function createAnthropicAliasProviderConfig(
285
+ captured: CapturedAnthropicProvider,
286
+ models: readonly ProviderModelConfig[],
287
+ ): ProviderConfig {
288
+ if (models.length === 0) {
289
+ throw new UpstreamAnthropicContractError(
290
+ "cannot register an Anthropic alias with an empty model catalog",
291
+ );
292
+ }
293
+ if (captured.config.api === ANTHROPIC_ALIAS_API) {
294
+ throw new UpstreamAnthropicContractError(
295
+ "extension-owned alias API must remain distinct from the base handler",
296
+ );
297
+ }
298
+
299
+ const upstreamStream = captured.config.streamSimple as unknown as AnthropicUpstreamStream;
300
+
301
+ const aliasStream = createAnthropicAliasStream(upstreamStream) as unknown as NonNullable<
302
+ ProviderConfig["streamSimple"]
303
+ >;
304
+
305
+ return {
306
+ ...captured.config,
307
+ api: ANTHROPIC_ALIAS_API,
308
+ models: models.map((model) => ({
309
+ ...model,
310
+ api: ANTHROPIC_ALIAS_API,
311
+ })),
312
+ oauth: captured.config.oauth,
313
+ streamSimple: aliasStream,
314
+ };
315
+ }