@centerforagenticai/pi-multi-account 0.1.1 → 0.1.2
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.
- package/README.md +64 -962
- package/package.json +1 -1
- package/src/index.ts +21 -64
- package/src/openai-adapter.ts +18 -8
- package/src/pi-ai-entrypoints.ts +138 -0
- package/src/provider-registration.ts +155 -7
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import {
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { join } from "node:path";
|
|
6
5
|
import type { Api, AssistantMessage, Model } from "@earendil-works/pi-ai";
|
|
7
6
|
import { readStoredCredential } from "@earendil-works/pi-coding-agent";
|
|
8
7
|
import type {
|
|
@@ -29,6 +28,10 @@ import {
|
|
|
29
28
|
createOpenAiAliasProviderConfig,
|
|
30
29
|
openaiPlatformModels,
|
|
31
30
|
} from "./openai-adapter.js";
|
|
31
|
+
import {
|
|
32
|
+
loadMaintainedCodexStream,
|
|
33
|
+
resolveInstalledPiAiVersion,
|
|
34
|
+
} from "./pi-ai-entrypoints.js";
|
|
32
35
|
import {
|
|
33
36
|
ALLOWED_FAMILIES,
|
|
34
37
|
DEFAULT_CONFIG,
|
|
@@ -405,6 +408,16 @@ function readInstalledDeclaration(targetPath: string): unknown {
|
|
|
405
408
|
}
|
|
406
409
|
}
|
|
407
410
|
|
|
411
|
+
/** The session's id, or undefined when the host context cannot supply one. */
|
|
412
|
+
function readSessionId(ctx: ExtensionContext): string | undefined {
|
|
413
|
+
try {
|
|
414
|
+
const id = (ctx.sessionManager as { getSessionId?: () => unknown }).getSessionId?.();
|
|
415
|
+
return typeof id === "string" && id.length > 0 ? id : undefined;
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
408
421
|
/** The complete declaration-gated startup seam, exported for causal registration contracts. */
|
|
409
422
|
export function registerLogicalProviderAtMatchedStartup(input: {
|
|
410
423
|
pi: Parameters<typeof registerLogicalProviderForSession>[0];
|
|
@@ -413,6 +426,8 @@ export function registerLogicalProviderAtMatchedStartup(input: {
|
|
|
413
426
|
deps: LogicalProviderDeps;
|
|
414
427
|
attributionStoreFactory?: () => LogicalAttributionLifecycle;
|
|
415
428
|
registerLogicalApi?: LogicalApiRegistrar;
|
|
429
|
+
/** The registering session's id; routes `unified` calls back to their caller. */
|
|
430
|
+
sessionId?: string | undefined;
|
|
416
431
|
}): {
|
|
417
432
|
status: ReturnType<typeof inspectInstalledDeclaration>;
|
|
418
433
|
expectedModels: ReturnType<typeof buildModelDeclaration>["models"];
|
|
@@ -424,6 +439,7 @@ export function registerLogicalProviderAtMatchedStartup(input: {
|
|
|
424
439
|
deps,
|
|
425
440
|
attributionStoreFactory,
|
|
426
441
|
registerLogicalApi,
|
|
442
|
+
sessionId,
|
|
427
443
|
} = input;
|
|
428
444
|
const expected = buildModelDeclaration(liveCatalogs);
|
|
429
445
|
const status = inspectInstalledDeclaration(installed, expected.models);
|
|
@@ -442,6 +458,7 @@ export function registerLogicalProviderAtMatchedStartup(input: {
|
|
|
442
458
|
expected.models,
|
|
443
459
|
attributedDeps,
|
|
444
460
|
registerLogicalApi,
|
|
461
|
+
sessionId === undefined ? {} : { sessionId },
|
|
445
462
|
);
|
|
446
463
|
}
|
|
447
464
|
return { status, expectedModels: expected.models };
|
|
@@ -1242,24 +1259,6 @@ function continuationReason(
|
|
|
1242
1259
|
}
|
|
1243
1260
|
}
|
|
1244
1261
|
|
|
1245
|
-
async function loadMaintainedCodexStream(): Promise<
|
|
1246
|
-
NonNullable<ProviderConfig["streamSimple"]>
|
|
1247
|
-
> {
|
|
1248
|
-
const publicRoot = await import("@earendil-works/pi-ai");
|
|
1249
|
-
const compatibilityStream = (
|
|
1250
|
-
publicRoot as unknown as Record<string, unknown>
|
|
1251
|
-
)["streamSimpleOpenAICodexResponses"];
|
|
1252
|
-
if (typeof compatibilityStream === "function") {
|
|
1253
|
-
return compatibilityStream as NonNullable<ProviderConfig["streamSimple"]>;
|
|
1254
|
-
}
|
|
1255
|
-
const maintainedApi = await import(
|
|
1256
|
-
"@earendil-works/pi-ai/api/openai-codex-responses"
|
|
1257
|
-
);
|
|
1258
|
-
return maintainedApi.streamSimple as unknown as NonNullable<
|
|
1259
|
-
ProviderConfig["streamSimple"]
|
|
1260
|
-
>;
|
|
1261
|
-
}
|
|
1262
|
-
|
|
1263
1262
|
/**
|
|
1264
1263
|
* Fail-closed sentinel used only when the host codex OAuth surface is
|
|
1265
1264
|
* unavailable at session_start (host shape drift). Its OAuth callbacks throw a
|
|
@@ -2065,50 +2064,6 @@ function cacheInheritedAccountGroupResolution(
|
|
|
2065
2064
|
}
|
|
2066
2065
|
|
|
2067
2066
|
|
|
2068
|
-
/**
|
|
2069
|
-
* Resolves the actually-installed `@earendil-works/pi-ai` package version by
|
|
2070
|
-
* walking up from its resolved `providers/anthropic.models` module file to
|
|
2071
|
-
* the nearest `package.json` whose `name` matches. A local, bounded,
|
|
2072
|
-
* offline disk read -- never a network request -- so the report's recorded
|
|
2073
|
-
* pricing-method provenance names whichever pinned catalog this process
|
|
2074
|
-
* actually loaded instead of a hard-coded string that could drift from it.
|
|
2075
|
-
* Shares its shape with the standalone CLI's own offline resolver in
|
|
2076
|
-
* `standalone-cli.ts`; the two adapters read from different sources (a live
|
|
2077
|
-
* `ExtensionContext.modelRegistry` here, the pinned package directly there)
|
|
2078
|
-
* and so cannot share one function without coupling this extension entry
|
|
2079
|
-
* point to the separate standalone-CLI module.
|
|
2080
|
-
*/
|
|
2081
|
-
function resolveInstalledPiAiVersion(): string {
|
|
2082
|
-
try {
|
|
2083
|
-
const moduleUrl = import.meta.resolve(
|
|
2084
|
-
"@earendil-works/pi-ai/providers/anthropic.models",
|
|
2085
|
-
);
|
|
2086
|
-
let directory = dirname(fileURLToPath(moduleUrl));
|
|
2087
|
-
for (let depth = 0; depth < 6; depth += 1) {
|
|
2088
|
-
try {
|
|
2089
|
-
const candidate = JSON.parse(
|
|
2090
|
-
readFileSync(join(directory, "package.json"), "utf-8"),
|
|
2091
|
-
) as { readonly name?: unknown; readonly version?: unknown };
|
|
2092
|
-
if (
|
|
2093
|
-
candidate.name === "@earendil-works/pi-ai" &&
|
|
2094
|
-
typeof candidate.version === "string" &&
|
|
2095
|
-
candidate.version.length > 0
|
|
2096
|
-
) {
|
|
2097
|
-
return `pi-ai@${candidate.version}`;
|
|
2098
|
-
}
|
|
2099
|
-
} catch {
|
|
2100
|
-
// Keep walking toward the installed package root.
|
|
2101
|
-
}
|
|
2102
|
-
const parent = dirname(directory);
|
|
2103
|
-
if (parent === directory) break;
|
|
2104
|
-
directory = parent;
|
|
2105
|
-
}
|
|
2106
|
-
} catch {
|
|
2107
|
-
// Resolution failure falls through to the bounded fallback below.
|
|
2108
|
-
}
|
|
2109
|
-
return "pi-ai@unknown";
|
|
2110
|
-
}
|
|
2111
|
-
|
|
2112
2067
|
/**
|
|
2113
2068
|
* Builds the extension surfaces' live Pi-catalog snapshot from the session's
|
|
2114
2069
|
* real `ExtensionContext.modelRegistry.getAll()` -- the same public,
|
|
@@ -4531,6 +4486,8 @@ export const createMultiAccountExtension =
|
|
|
4531
4486
|
installed,
|
|
4532
4487
|
liveCatalogs,
|
|
4533
4488
|
registerLogicalApi,
|
|
4489
|
+
// Optional routing key; an unreadable id must never block registration.
|
|
4490
|
+
sessionId: readSessionId(startupContext),
|
|
4534
4491
|
attributionStoreFactory: () => {
|
|
4535
4492
|
const store = createAttributionStore({
|
|
4536
4493
|
onResponse: (route, response) => {
|
package/src/openai-adapter.ts
CHANGED
|
@@ -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` `
|
|
16
|
-
*
|
|
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 {
|
|
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 `
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
939
|
+
release();
|
|
794
940
|
throw error;
|
|
795
941
|
}
|
|
796
|
-
return
|
|
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
|