@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.
- package/README.md +64 -962
- package/package.json +1 -1
- package/src/anthropic-adaptive-stream.ts +81 -15
- package/src/anthropic-alias-stream.ts +9 -1
- package/src/codex-adapter.ts +28 -4
- package/src/error-classification.ts +13 -1
- package/src/host-final-stop-message.ts +124 -0
- package/src/index.ts +45 -68
- package/src/logical-provider.ts +30 -1
- package/src/openai-adapter.ts +18 -8
- package/src/pi-ai-entrypoints.ts +138 -0
- package/src/provider-registration.ts +155 -7
- package/src/routing.ts +17 -1
- package/src/shared-usage.ts +83 -176
- package/src/usage-fetch.ts +59 -22
- package/src/usage.ts +29 -26
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
|
package/src/routing.ts
CHANGED
|
@@ -98,7 +98,18 @@ export interface PausedRoute {
|
|
|
98
98
|
readonly retryAfterMs: number | null;
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
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" &&
|