@centerforagenticai/pi-multi-account 0.1.1 → 0.1.4

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.
@@ -12,15 +12,21 @@
12
12
  *
13
13
  * Catalog source is capture-or-compose: the live model registry's base `openai`
14
14
  * provider catalog when the host has registered it (the pinned Pi 0.84.4 ships
15
- * it), else the pinned `@earendil-works/pi-ai` `openaiProvider()` factory. Base
16
- * providers are only read, never mutated or re-registered.
15
+ * it), else the pinned `@earendil-works/pi-ai` built-in `openai` catalog (the
16
+ * same `OPENAI_MODELS` the `openaiProvider()` factory wraps). Base providers are
17
+ * only read, never mutated or re-registered.
18
+ *
19
+ * The built-in catalog is read through `@earendil-works/pi-ai/providers/all`,
20
+ * not `@earendil-works/pi-ai/providers/openai`: Pi's extension loader aliases
21
+ * only the pi-ai root, `/compat`, `/oauth` and `/providers/all`, so any other
22
+ * pi-ai subpath resolves under the aliased root file and fails extension load.
17
23
  */
18
24
 
19
25
  import type {
20
26
  ProviderConfig,
21
27
  ProviderModelConfig,
22
28
  } from "@earendil-works/pi-coding-agent";
23
- import { openaiProvider } from "@earendil-works/pi-ai/providers/openai";
29
+ import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all";
24
30
  import { cloneProviderModelCatalog } from "./catalog-rebinding.js";
25
31
 
26
32
  /** pi-ai model API id for the OpenAI platform Responses API. */
@@ -45,7 +51,7 @@ export class OpenAiAdapterContractError extends Error {
45
51
  /**
46
52
  * Model configs for the OpenAI platform catalog, deeply isolated from any
47
53
  * registry-owned object. Prefers the live registry's base `openai` catalog;
48
- * falls back to the pinned pi-ai `openaiProvider()` factory when the host has
54
+ * falls back to the pinned pi-ai built-in `openai` catalog when the host has
49
55
  * not registered a base `openai` provider. The registry-owned `provider`
50
56
  * identity is stripped so the caller can re-point each model at the alias id.
51
57
  */
@@ -70,11 +76,15 @@ function tryCaptureRegistryModels(
70
76
  }
71
77
 
72
78
  function composeModelsFromFactory(): readonly ProviderModelConfig[] {
73
- const provider = openaiProvider();
74
- const models = provider.getModels();
75
- if (models.length === 0) {
79
+ if (typeof getBuiltinModels !== "function") {
80
+ throw new OpenAiAdapterContractError(
81
+ "The pinned pi-ai providers/all entry no longer exports getBuiltinModels.",
82
+ );
83
+ }
84
+ const models: unknown = getBuiltinModels(OPENAI_BASE_PROVIDER);
85
+ if (!Array.isArray(models) || models.length === 0) {
76
86
  throw new OpenAiAdapterContractError(
77
- "The pinned pi-ai openai provider factory yielded no models.",
87
+ "The pinned pi-ai built-in openai catalog yielded no models.",
78
88
  );
79
89
  }
80
90
  return models.map((model: unknown) => {
@@ -0,0 +1,138 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ProviderConfig } from "@earendil-works/pi-coding-agent";
5
+ import { CodexContractError } from "./codex-adapter.js";
6
+
7
+ /**
8
+ * Host boundary: Pi's extension loader aliases only the pi-ai root, `/compat`,
9
+ * `/oauth` and `/providers/all`. Every other `@earendil-works/pi-ai/<subpath>`
10
+ * resolves beneath the aliased root file (`dist/compat.js/<subpath>`) and
11
+ * cannot load, so entries here try loader-safe specifiers first and keep any
12
+ * other subpath behind a guarded fallback for plain Node. See UPSTREAM.md.
13
+ */
14
+
15
+ type StreamSimple = NonNullable<ProviderConfig["streamSimple"]>;
16
+
17
+ /** pi-ai specifiers the Codex stream resolver may import. */
18
+ export type CodexStreamSpecifier =
19
+ | "@earendil-works/pi-ai"
20
+ | "@earendil-works/pi-ai/api/openai-codex-responses"
21
+ | "@earendil-works/pi-ai/compat";
22
+
23
+ export type ModuleImporter = (specifier: CodexStreamSpecifier) => Promise<unknown>;
24
+
25
+ // Literal specifiers keep the imports visible to Pi's loader and to bundlers.
26
+ const defaultImporter: ModuleImporter = (specifier) => {
27
+ switch (specifier) {
28
+ case "@earendil-works/pi-ai":
29
+ return import("@earendil-works/pi-ai");
30
+ case "@earendil-works/pi-ai/api/openai-codex-responses":
31
+ return import("@earendil-works/pi-ai/api/openai-codex-responses");
32
+ case "@earendil-works/pi-ai/compat":
33
+ return import("@earendil-works/pi-ai/compat");
34
+ }
35
+ };
36
+
37
+ function field(module: unknown, key: string): unknown {
38
+ return module !== null && typeof module === "object"
39
+ ? (module as Record<string, unknown>)[key]
40
+ : undefined;
41
+ }
42
+
43
+ /**
44
+ * Resolves the maintained pi-ai Codex Responses stream, in order:
45
+ * 1. the public root's legacy `streamSimpleOpenAICodexResponses` (present when
46
+ * Pi's loader aliases the root to `dist/compat.js`);
47
+ * 2. `api/openai-codex-responses` `streamSimple`, guarded (plain Node only);
48
+ * 3. `/compat` `streamSimpleOpenAICodexResponses`, then
49
+ * `openAICodexResponsesApi().streamSimple`.
50
+ * Fails closed with {@link CodexContractError} when none yields a function.
51
+ */
52
+ export async function loadMaintainedCodexStream(
53
+ importModule: ModuleImporter = defaultImporter,
54
+ ): Promise<StreamSimple> {
55
+ const root = await importModule("@earendil-works/pi-ai");
56
+ const rootStream = field(root, "streamSimpleOpenAICodexResponses");
57
+ if (typeof rootStream === "function") return rootStream as StreamSimple;
58
+ try {
59
+ const maintained = field(
60
+ await importModule("@earendil-works/pi-ai/api/openai-codex-responses"),
61
+ "streamSimple",
62
+ );
63
+ if (typeof maintained === "function") return maintained as StreamSimple;
64
+ } catch {
65
+ // Not resolvable under Pi's aliased loader; try the compat entry.
66
+ }
67
+ try {
68
+ const compat = await importModule("@earendil-works/pi-ai/compat");
69
+ const legacy = field(compat, "streamSimpleOpenAICodexResponses");
70
+ if (typeof legacy === "function") return legacy as StreamSimple;
71
+ const factory = field(compat, "openAICodexResponsesApi");
72
+ if (typeof factory === "function") {
73
+ const lazy = field((factory as () => unknown)(), "streamSimple");
74
+ if (typeof lazy === "function") return lazy as StreamSimple;
75
+ }
76
+ } catch {
77
+ // Fall through to the fail-closed contract error.
78
+ }
79
+ throw new CodexContractError(
80
+ "No pi-ai entry exposes the maintained openai-codex-responses streamSimple.",
81
+ );
82
+ }
83
+
84
+ export interface PiAiVersionSources {
85
+ readonly resolve: (specifier: string) => string;
86
+ readonly readFile: (path: string) => string;
87
+ }
88
+
89
+ const defaultVersionSources: PiAiVersionSources = {
90
+ resolve: (specifier) => import.meta.resolve(specifier),
91
+ readFile: (path) => readFileSync(path, "utf-8"),
92
+ };
93
+
94
+ /**
95
+ * Resolves the installed `@earendil-works/pi-ai` version by walking up from
96
+ * its resolved `/compat` (then root) entry to the nearest `package.json`
97
+ * named `@earendil-works/pi-ai`. Local, bounded and offline. Returns
98
+ * `pi-ai@unknown` when neither specifier resolves to a versioned manifest.
99
+ * The standalone CLI keeps its own resolver (`standalone-cli.ts`), which runs
100
+ * outside Pi's loader.
101
+ */
102
+ export function resolveInstalledPiAiVersion(
103
+ sources: PiAiVersionSources = defaultVersionSources,
104
+ ): string {
105
+ for (const specifier of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
106
+ const version = versionFrom(specifier, sources);
107
+ if (version !== undefined) return version;
108
+ }
109
+ return "pi-ai@unknown";
110
+ }
111
+
112
+ function versionFrom(specifier: string, sources: PiAiVersionSources): string | undefined {
113
+ try {
114
+ let directory = dirname(fileURLToPath(sources.resolve(specifier)));
115
+ for (let depth = 0; depth < 6; depth += 1) {
116
+ try {
117
+ const candidate = JSON.parse(
118
+ sources.readFile(join(directory, "package.json")),
119
+ ) as { readonly name?: unknown; readonly version?: unknown };
120
+ if (
121
+ candidate.name === "@earendil-works/pi-ai" &&
122
+ typeof candidate.version === "string" &&
123
+ candidate.version.length > 0
124
+ ) {
125
+ return `pi-ai@${candidate.version}`;
126
+ }
127
+ } catch {
128
+ // Keep walking toward the installed package root.
129
+ }
130
+ const parent = dirname(directory);
131
+ if (parent === directory) break;
132
+ directory = parent;
133
+ }
134
+ } catch {
135
+ // Resolution failure lets the caller try its next specifier.
136
+ }
137
+ return undefined;
138
+ }
@@ -573,6 +573,35 @@ function hasStreamResult(
573
573
  return "result" in value && typeof value.result === "function";
574
574
  }
575
575
 
576
+ const NO_ACTIVE_LOGICAL_SESSION_MESSAGE =
577
+ "No active unified logical provider session is available.";
578
+
579
+ /** Terminal error stream for a call no live session can serve. */
580
+ function noActiveLogicalSessionStream(modelId: string): AssistantMessageEventStream {
581
+ const stream = createAssistantMessageEventStream();
582
+ const error: AssistantMessage = {
583
+ role: "assistant",
584
+ content: [],
585
+ api: LOGICAL_PROVIDER_ID,
586
+ provider: LOGICAL_PROVIDER_ID,
587
+ model: modelId,
588
+ usage: {
589
+ input: 0,
590
+ output: 0,
591
+ cacheRead: 0,
592
+ cacheWrite: 0,
593
+ totalTokens: 0,
594
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
595
+ },
596
+ stopReason: "error",
597
+ errorMessage: NO_ACTIVE_LOGICAL_SESSION_MESSAGE,
598
+ timestamp: Date.now(),
599
+ };
600
+ stream.push({ type: "error", reason: "error", error });
601
+ stream.end(error);
602
+ return stream;
603
+ }
604
+
576
605
  function deferredLogicalProviderStream(
577
606
  upstream: Promise<AsyncIterable<unknown>>,
578
607
  ): AssistantMessageEventStream {
@@ -650,15 +679,90 @@ export const LOGICAL_API_SOURCE_ID = "hyphagroup-pi-multi-account-unified";
650
679
  */
651
680
  export type LogicalApiRegistrar = (
652
681
  logicalStream: NonNullable<ProviderConfig["streamSimple"]>,
682
+ sessionId?: string,
653
683
  ) => () => void;
654
684
 
655
685
  type LogicalStream = NonNullable<ProviderConfig["streamSimple"]>;
656
686
 
657
687
  interface LogicalApiStreamOwner {
658
688
  readonly owner: symbol;
689
+ /** The registering session's id, when known; the key a caller routes by. */
690
+ readonly sessionId: string | undefined;
659
691
  readonly stream: LogicalStream;
660
692
  }
661
693
 
694
+ /**
695
+ * Every live session's own logical stream, process-wide.
696
+ *
697
+ * In-process delegate workers and forks share the foreground session's
698
+ * `ModelRuntime`, so a worker's `registerProvider("unified")` replaces the
699
+ * foreground's `streamSimple` in that shared map, and Pi never restores it when
700
+ * the worker ends. Every registered stream is therefore a router: it serves the
701
+ * session whose id the caller passed (`options.sessionId`, which Pi sets on every
702
+ * agent-loop request), and never a released session. Without this, a foreground
703
+ * turn after a worker ended ran through the worker's invalidated extension
704
+ * context and failed with Pi's stale-ctx error until restart.
705
+ *
706
+ * Held on `globalThis` because Pi loads each session's extension with
707
+ * `moduleCache: false`, so module-level state is not shared between sessions.
708
+ */
709
+ const LIVE_LOGICAL_SESSIONS_KEY = Symbol.for(
710
+ "hyphagroup.pi-multi-account.unified-live-sessions",
711
+ );
712
+
713
+ function liveLogicalSessions(): LogicalApiStreamOwner[] {
714
+ const holder = globalThis as unknown as Record<
715
+ symbol,
716
+ LogicalApiStreamOwner[] | undefined
717
+ >;
718
+ let sessions = holder[LIVE_LOGICAL_SESSIONS_KEY];
719
+ if (sessions === undefined) {
720
+ sessions = [];
721
+ holder[LIVE_LOGICAL_SESSIONS_KEY] = sessions;
722
+ }
723
+ return sessions;
724
+ }
725
+
726
+ /** Test-only: forget every live session so suites cannot leak routing state. */
727
+ export function resetLogicalSessionRoutingForTests(): void {
728
+ liveLogicalSessions().length = 0;
729
+ }
730
+
731
+ function callerSessionId(options: unknown): string | undefined {
732
+ if (typeof options !== "object" || options === null) return undefined;
733
+ const sessionId = (options as { sessionId?: unknown }).sessionId;
734
+ return typeof sessionId === "string" && sessionId.length > 0
735
+ ? sessionId
736
+ : undefined;
737
+ }
738
+
739
+ /**
740
+ * Pick the live stream that should serve one call: the caller's own session when
741
+ * its id is known and live, else the preferred owner if still live, else the
742
+ * newest live session. Ids Pi generates per call (compaction summaries receive a
743
+ * fresh routing id) match no session and take the fallback.
744
+ */
745
+ function selectLogicalSessionStream(
746
+ candidates: readonly LogicalApiStreamOwner[],
747
+ options: unknown,
748
+ preferredOwner?: symbol,
749
+ ): LogicalApiStreamOwner | undefined {
750
+ const sessionId = callerSessionId(options);
751
+ if (sessionId !== undefined) {
752
+ for (let index = candidates.length - 1; index >= 0; index -= 1) {
753
+ const candidate = candidates[index];
754
+ if (candidate?.sessionId === sessionId) return candidate;
755
+ }
756
+ }
757
+ if (preferredOwner !== undefined) {
758
+ const preferred = candidates.find(
759
+ (candidate) => candidate.owner === preferredOwner,
760
+ );
761
+ if (preferred !== undefined) return preferred;
762
+ }
763
+ return candidates.at(-1);
764
+ }
765
+
662
766
  interface LogicalApiRegistryGeneration {
663
767
  readonly provider: NonNullable<ReturnType<typeof getApiProvider>>;
664
768
  readonly streams: LogicalApiStreamOwner[];
@@ -678,7 +782,10 @@ let currentLogicalApiGeneration: LogicalApiRegistryGeneration | undefined;
678
782
  function createLogicalApiRegistryGeneration(): LogicalApiRegistryGeneration {
679
783
  const streams: LogicalApiStreamOwner[] = [];
680
784
  const currentStream: LogicalStream = (model, context, options) => {
681
- const active = streams.at(-1);
785
+ // Select from the process-global list, not this module instance's
786
+ // `streams`: each session loads its own module copy, so a sibling's
787
+ // generation would otherwise know only its own sessions.
788
+ const active = selectLogicalSessionStream(liveLogicalSessions(), options);
682
789
  if (active === undefined) {
683
790
  throw new Error("No active unified logical provider session is available.");
684
791
  }
@@ -732,7 +839,7 @@ function acquireLogicalApiRegistryGeneration(): LogicalApiRegistryGeneration {
732
839
  */
733
840
  export function createLogicalApiRegistrarForFactoryGeneration(): LogicalApiRegistrar {
734
841
  let factoryGeneration: LogicalApiRegistryGeneration | undefined;
735
- return (logicalStream) => {
842
+ return (logicalStream, sessionId) => {
736
843
  const registered = getApiProvider(LOGICAL_PROVIDER_ID);
737
844
  const generation =
738
845
  factoryGeneration !== undefined && registered === factoryGeneration.provider
@@ -741,7 +848,7 @@ export function createLogicalApiRegistrarForFactoryGeneration(): LogicalApiRegis
741
848
  factoryGeneration = generation;
742
849
 
743
850
  const owner = Symbol("logical-session-stream");
744
- generation.streams.push({ owner, stream: logicalStream });
851
+ generation.streams.push({ owner, sessionId, stream: logicalStream });
745
852
  let released = false;
746
853
  return () => {
747
854
  if (released) return;
@@ -760,6 +867,7 @@ function registerProjectedLogicalProvider(
760
867
  models: ModelDeclarationRow[],
761
868
  deps: LogicalProviderDeps,
762
869
  registerLogicalApi: LogicalApiRegistrar,
870
+ sessionId?: string,
763
871
  ): () => void {
764
872
  const provider = createLogicalProvider(deps);
765
873
  const streamSimple: NonNullable<ProviderConfig["streamSimple"]> = (
@@ -770,12 +878,34 @@ function registerProjectedLogicalProvider(
770
878
  deferredLogicalProviderStream(
771
879
  Promise.resolve(provider.streamSimple(model, context, options)),
772
880
  );
881
+ const ownEntry: LogicalApiStreamOwner = {
882
+ owner: Symbol("logical-session"),
883
+ sessionId,
884
+ stream: streamSimple,
885
+ };
886
+ // The host-map stream routes by caller rather than closing over this session:
887
+ // a sibling in-process session may later own this slot of the shared runtime.
888
+ const routedStreamSimple: NonNullable<ProviderConfig["streamSimple"]> = (
889
+ model,
890
+ context,
891
+ options,
892
+ ) => {
893
+ const selected = selectLogicalSessionStream(
894
+ liveLogicalSessions(),
895
+ options,
896
+ ownEntry.owner,
897
+ );
898
+ // Never fall back to a released session: its dependencies may hold an
899
+ // invalidated extension context.
900
+ if (selected === undefined) return noActiveLogicalSessionStream(model.id);
901
+ return selected.stream(model, context, options);
902
+ };
773
903
  const providerConfig: ProviderConfig = {
774
904
  name: LOGICAL_PROVIDER_DISPLAY_NAME,
775
905
  api: LOGICAL_PROVIDER_ID,
776
906
  baseUrl: DECLARATION_BASE_URL,
777
907
  models,
778
- streamSimple,
908
+ streamSimple: routedStreamSimple,
779
909
  };
780
910
  // Activate the compat stream BEFORE publishing the host provider, and bind it
781
911
  // to the exact same logical stream the host provider uses. A throwing registrar
@@ -784,16 +914,32 @@ function registerProjectedLogicalProvider(
784
914
  // selection, failover, attribution, or request options. The registrar is
785
915
  // deliberately NOT wrapped here: swallowing its failure would publish a host
786
916
  // provider whose `unified` API cannot resolve.
787
- const releaseLogicalStream = registerLogicalApi(streamSimple);
917
+ const releaseLogicalStream = registerLogicalApi(streamSimple, sessionId);
918
+ const live = liveLogicalSessions();
919
+ // A reload re-registers the same session id; its previous registration is dead.
920
+ if (sessionId !== undefined) {
921
+ for (let index = live.length - 1; index >= 0; index -= 1) {
922
+ if (live[index]?.sessionId === sessionId) live.splice(index, 1);
923
+ }
924
+ }
925
+ live.push(ownEntry);
926
+ let released = false;
927
+ const release = (): void => {
928
+ if (released) return;
929
+ released = true;
930
+ const index = live.indexOf(ownEntry);
931
+ if (index !== -1) live.splice(index, 1);
932
+ releaseLogicalStream();
933
+ };
788
934
  try {
789
935
  pi.registerProvider(LOGICAL_PROVIDER_ID, providerConfig);
790
936
  } catch (error) {
791
937
  // Remove only this failed session's stream. The process-global API entry is
792
938
  // generation-owned and remains available to any preceding live session.
793
- releaseLogicalStream();
939
+ release();
794
940
  throw error;
795
941
  }
796
- return releaseLogicalStream;
942
+ return release;
797
943
  }
798
944
 
799
945
  function safeAttributionCall(call: () => void): void {
@@ -860,6 +1006,7 @@ export function registerLogicalProviderForSession(
860
1006
  projectedModels: unknown[],
861
1007
  deps: LogicalProviderDeps,
862
1008
  registerLogicalApi: LogicalApiRegistrar = registerLogicalApiWithCompat,
1009
+ options: { readonly sessionId?: string } = {},
863
1010
  ): LogicalProviderSessionRegistration {
864
1011
  const models = assertProjectedManagedModels(projectedModels);
865
1012
  const rawLifecycle: LogicalAttributionLifecycle = deps.attribution ?? {
@@ -882,6 +1029,7 @@ export function registerLogicalProviderForSession(
882
1029
  models,
883
1030
  sessionDeps,
884
1031
  registerLogicalApi,
1032
+ options.sessionId,
885
1033
  );
886
1034
  } catch (error) {
887
1035
  // registerProjectedLogicalProvider already removed any activated session
package/src/routing.ts CHANGED
@@ -98,7 +98,18 @@ export interface PausedRoute {
98
98
  readonly retryAfterMs: number | null;
99
99
  }
100
100
 
101
- export type ReactiveRouteDecision = SelectedRoute | PausedRoute;
101
+ /**
102
+ * A structured refusal or unknown provider stop. The failed account is kept
103
+ * as-is: no cooldown, no invalidation, no alternative, and no continuation.
104
+ * The stop is deterministic for the request context, so another account would
105
+ * only spend another request on the same answer.
106
+ */
107
+ export interface RetainedRoute {
108
+ readonly status: "retained";
109
+ readonly classification: FailureClassification;
110
+ }
111
+
112
+ export type ReactiveRouteDecision = SelectedRoute | PausedRoute | RetainedRoute;
102
113
 
103
114
  export interface HealthSelectionDecision {
104
115
  readonly providerId: string;
@@ -1092,6 +1103,11 @@ export function routeAfterFailure(options: {
1092
1103
  throw new TypeError("failedAccount must be a canonical managed provider.");
1093
1104
  }
1094
1105
  const classification = classifyFailure(failure);
1106
+ // Before any state observation: a retained stop writes nothing and routes
1107
+ // nowhere, so settlement cannot turn it into a switch or a continuation.
1108
+ if (classification.accountAction === "retain-account") {
1109
+ return { status: "retained", classification };
1110
+ }
1095
1111
  if (
1096
1112
  classification.category === "config" &&
1097
1113
  failure.code === "model_not_found" &&