@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.
- package/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- package/src/window-history.ts +270 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { dirname } from "node:path";
|
|
15
|
+
import { isAccountGroupId } from "./config.js";
|
|
16
|
+
import {
|
|
17
|
+
resolveEffectiveAccountGroup,
|
|
18
|
+
type AccountGroupPolicyConfig,
|
|
19
|
+
type EffectiveAccountGroupResolution,
|
|
20
|
+
} from "./group-policy.js";
|
|
21
|
+
import { acquireMachineLease } from "./machine-lease.js";
|
|
22
|
+
|
|
23
|
+
const STORE_VERSION = 1;
|
|
24
|
+
const STORE_LEASE_TTL_MS = 5_000;
|
|
25
|
+
const MAX_SESSION_ID_LENGTH = 256;
|
|
26
|
+
|
|
27
|
+
export interface SessionIdSource {
|
|
28
|
+
getSessionId(): string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SessionAccountGroupStoreOptions {
|
|
32
|
+
readonly storePath: string;
|
|
33
|
+
readonly lockPath?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResolveAndCacheAccountGroupInput {
|
|
37
|
+
readonly sessionManager: SessionIdSource;
|
|
38
|
+
readonly cwd: string;
|
|
39
|
+
readonly config: AccountGroupPolicyConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StoredSessionGroupRecord {
|
|
43
|
+
manualOverrideGroupId?: string;
|
|
44
|
+
effective?: EffectiveAccountGroupResolution;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface SessionGroupState {
|
|
48
|
+
version: typeof STORE_VERSION;
|
|
49
|
+
sessions: Record<string, StoredSessionGroupRecord>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class SessionAccountGroupStoreError extends Error {
|
|
53
|
+
constructor(message: string) {
|
|
54
|
+
super(`[multi-account session groups] ${message}`);
|
|
55
|
+
this.name = "SessionAccountGroupStoreError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
60
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertSessionId(value: unknown): asserts value is string {
|
|
64
|
+
if (
|
|
65
|
+
typeof value !== "string" ||
|
|
66
|
+
value.length === 0 ||
|
|
67
|
+
value.length > MAX_SESSION_ID_LENGTH ||
|
|
68
|
+
/[\u0000-\u001f\u007f]/u.test(value)
|
|
69
|
+
) {
|
|
70
|
+
throw new SessionAccountGroupStoreError(
|
|
71
|
+
`session id must contain 1 through ${MAX_SESSION_ID_LENGTH} non-control characters.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sessionIdFrom(source: SessionIdSource): string {
|
|
77
|
+
const sessionId = source.getSessionId();
|
|
78
|
+
assertSessionId(sessionId);
|
|
79
|
+
return sessionId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function copyResolution(value: unknown): EffectiveAccountGroupResolution {
|
|
83
|
+
if (!isRecord(value)) {
|
|
84
|
+
throw new SessionAccountGroupStoreError("cached effective resolution is malformed.");
|
|
85
|
+
}
|
|
86
|
+
const source = value["source"];
|
|
87
|
+
const groupId = value["groupId"];
|
|
88
|
+
const unknownKeys = Object.keys(value).filter(
|
|
89
|
+
(key) => key !== "source" && key !== "groupId",
|
|
90
|
+
);
|
|
91
|
+
if (unknownKeys.length > 0) {
|
|
92
|
+
throw new SessionAccountGroupStoreError("cached effective resolution is malformed.");
|
|
93
|
+
}
|
|
94
|
+
if (source === "unrestricted" && groupId === undefined) {
|
|
95
|
+
return Object.freeze({ source: "unrestricted" });
|
|
96
|
+
}
|
|
97
|
+
if (
|
|
98
|
+
(source === "session-override" ||
|
|
99
|
+
source === "cwd-default" ||
|
|
100
|
+
source === "global-default") &&
|
|
101
|
+
isAccountGroupId(groupId)
|
|
102
|
+
) {
|
|
103
|
+
return Object.freeze({ groupId, source });
|
|
104
|
+
}
|
|
105
|
+
throw new SessionAccountGroupStoreError("cached effective resolution is malformed.");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function emptyState(): SessionGroupState {
|
|
109
|
+
return {
|
|
110
|
+
version: STORE_VERSION,
|
|
111
|
+
sessions: Object.create(null) as Record<string, StoredSessionGroupRecord>,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function readState(storePath: string): SessionGroupState {
|
|
116
|
+
let raw: string;
|
|
117
|
+
try {
|
|
118
|
+
raw = readFileSync(storePath, "utf8");
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return emptyState();
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let parsed: unknown;
|
|
125
|
+
try {
|
|
126
|
+
parsed = JSON.parse(raw);
|
|
127
|
+
} catch {
|
|
128
|
+
throw new SessionAccountGroupStoreError("store is not valid JSON.");
|
|
129
|
+
}
|
|
130
|
+
if (!isRecord(parsed) || parsed["version"] !== STORE_VERSION || !isRecord(parsed["sessions"])) {
|
|
131
|
+
throw new SessionAccountGroupStoreError("store schema is malformed.");
|
|
132
|
+
}
|
|
133
|
+
const unknownRootKeys = Object.keys(parsed).filter(
|
|
134
|
+
(key) => key !== "version" && key !== "sessions",
|
|
135
|
+
);
|
|
136
|
+
if (unknownRootKeys.length > 0) {
|
|
137
|
+
throw new SessionAccountGroupStoreError("store schema is malformed.");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const state = emptyState();
|
|
141
|
+
for (const [sessionId, candidate] of Object.entries(parsed["sessions"])) {
|
|
142
|
+
assertSessionId(sessionId);
|
|
143
|
+
if (!isRecord(candidate)) {
|
|
144
|
+
throw new SessionAccountGroupStoreError("session record is malformed.");
|
|
145
|
+
}
|
|
146
|
+
const unknownKeys = Object.keys(candidate).filter(
|
|
147
|
+
(key) => key !== "manualOverrideGroupId" && key !== "effective",
|
|
148
|
+
);
|
|
149
|
+
if (unknownKeys.length > 0) {
|
|
150
|
+
throw new SessionAccountGroupStoreError("session record is malformed.");
|
|
151
|
+
}
|
|
152
|
+
const manualOverrideGroupId = candidate["manualOverrideGroupId"];
|
|
153
|
+
if (
|
|
154
|
+
manualOverrideGroupId !== undefined &&
|
|
155
|
+
!isAccountGroupId(manualOverrideGroupId)
|
|
156
|
+
) {
|
|
157
|
+
throw new SessionAccountGroupStoreError("session override group id is malformed.");
|
|
158
|
+
}
|
|
159
|
+
const record: StoredSessionGroupRecord = {};
|
|
160
|
+
if (manualOverrideGroupId !== undefined) {
|
|
161
|
+
record.manualOverrideGroupId = manualOverrideGroupId;
|
|
162
|
+
}
|
|
163
|
+
if (candidate["effective"] !== undefined) {
|
|
164
|
+
record.effective = copyResolution(candidate["effective"]);
|
|
165
|
+
}
|
|
166
|
+
state.sessions[sessionId] = record;
|
|
167
|
+
}
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function fsyncDirectory(directory: string): void {
|
|
172
|
+
const descriptor = openSync(directory, "r");
|
|
173
|
+
try {
|
|
174
|
+
fsyncSync(descriptor);
|
|
175
|
+
} finally {
|
|
176
|
+
closeSync(descriptor);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function writeState(storePath: string, state: SessionGroupState): void {
|
|
181
|
+
const directory = dirname(storePath);
|
|
182
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
183
|
+
chmodSync(directory, 0o700);
|
|
184
|
+
const temporaryPath = `${storePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
185
|
+
let descriptor: number | undefined;
|
|
186
|
+
try {
|
|
187
|
+
descriptor = openSync(temporaryPath, "wx", 0o600);
|
|
188
|
+
writeFileSync(descriptor, `${JSON.stringify(state, null, 2)}\n`, {
|
|
189
|
+
encoding: "utf8",
|
|
190
|
+
});
|
|
191
|
+
fsyncSync(descriptor);
|
|
192
|
+
closeSync(descriptor);
|
|
193
|
+
descriptor = undefined;
|
|
194
|
+
chmodSync(temporaryPath, 0o600);
|
|
195
|
+
renameSync(temporaryPath, storePath);
|
|
196
|
+
chmodSync(storePath, 0o600);
|
|
197
|
+
fsyncDirectory(directory);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
200
|
+
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export class SessionAccountGroupStore {
|
|
206
|
+
readonly #storePath: string;
|
|
207
|
+
readonly #lockPath: string;
|
|
208
|
+
|
|
209
|
+
constructor(options: SessionAccountGroupStoreOptions) {
|
|
210
|
+
this.#storePath = options.storePath;
|
|
211
|
+
this.#lockPath = options.lockPath ?? `${options.storePath}.lock`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
readOverride(sessionId: string): string | undefined {
|
|
215
|
+
assertSessionId(sessionId);
|
|
216
|
+
return readState(this.#storePath).sessions[sessionId]?.manualOverrideGroupId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
readCachedResolution(sessionId: string): EffectiveAccountGroupResolution | undefined {
|
|
220
|
+
assertSessionId(sessionId);
|
|
221
|
+
const resolution = readState(this.#storePath).sessions[sessionId]?.effective;
|
|
222
|
+
return resolution === undefined ? undefined : copyResolution(resolution);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
setOverride(sessionManager: SessionIdSource, groupId: string): void {
|
|
226
|
+
const sessionId = sessionIdFrom(sessionManager);
|
|
227
|
+
if (!isAccountGroupId(groupId)) {
|
|
228
|
+
throw new SessionAccountGroupStoreError("group id is malformed.");
|
|
229
|
+
}
|
|
230
|
+
this.#update((state) => {
|
|
231
|
+
state.sessions[sessionId] = { manualOverrideGroupId: groupId };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
clearOverride(sessionManager: SessionIdSource): void {
|
|
236
|
+
const sessionId = sessionIdFrom(sessionManager);
|
|
237
|
+
this.#update((state) => {
|
|
238
|
+
delete state.sessions[sessionId];
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
resolveAndCache(
|
|
243
|
+
input: ResolveAndCacheAccountGroupInput,
|
|
244
|
+
): EffectiveAccountGroupResolution {
|
|
245
|
+
const sessionId = sessionIdFrom(input.sessionManager);
|
|
246
|
+
let resolved: EffectiveAccountGroupResolution | undefined;
|
|
247
|
+
this.#update((state) => {
|
|
248
|
+
const manualOverrideGroupId =
|
|
249
|
+
state.sessions[sessionId]?.manualOverrideGroupId;
|
|
250
|
+
resolved = resolveEffectiveAccountGroup(
|
|
251
|
+
input.config,
|
|
252
|
+
input.cwd,
|
|
253
|
+
manualOverrideGroupId,
|
|
254
|
+
);
|
|
255
|
+
const record: StoredSessionGroupRecord = { effective: resolved };
|
|
256
|
+
if (manualOverrideGroupId !== undefined) {
|
|
257
|
+
record.manualOverrideGroupId = manualOverrideGroupId;
|
|
258
|
+
}
|
|
259
|
+
state.sessions[sessionId] = record;
|
|
260
|
+
});
|
|
261
|
+
if (resolved === undefined) {
|
|
262
|
+
throw new SessionAccountGroupStoreError("effective resolution was not written.");
|
|
263
|
+
}
|
|
264
|
+
return copyResolution(resolved);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#update(mutator: (state: SessionGroupState) => void): void {
|
|
268
|
+
const lease = acquireMachineLease({
|
|
269
|
+
lockPath: this.#lockPath,
|
|
270
|
+
ttlMs: STORE_LEASE_TTL_MS,
|
|
271
|
+
reclaimMalformed: true,
|
|
272
|
+
});
|
|
273
|
+
if (lease === undefined) {
|
|
274
|
+
throw new SessionAccountGroupStoreError("store is busy.");
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const state = readState(this.#storePath);
|
|
278
|
+
mutator(state);
|
|
279
|
+
writeState(this.#storePath, state);
|
|
280
|
+
} finally {
|
|
281
|
+
lease.release();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
/** Session fields used to recover an explicit model selection. */
|
|
4
|
+
interface ModelChangeEntryLike {
|
|
5
|
+
readonly type: string;
|
|
6
|
+
readonly provider?: unknown;
|
|
7
|
+
readonly modelId?: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Session fields used to recover the model that completed an assistant turn. */
|
|
11
|
+
interface MessageEntryLike {
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly message?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AssistantMessageLike {
|
|
17
|
+
readonly role?: unknown;
|
|
18
|
+
readonly provider?: unknown;
|
|
19
|
+
readonly model?: unknown;
|
|
20
|
+
readonly stopReason?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface IndexedSelection {
|
|
24
|
+
readonly index: number;
|
|
25
|
+
readonly selection: PersistedModelSelection;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SessionRestoreEvidence {
|
|
29
|
+
/** Newest valid model change, retained for the existing restore precedence. */
|
|
30
|
+
readonly modelChange?: IndexedSelection;
|
|
31
|
+
/** The newest model_change entry only when that exact entry is valid evidence. */
|
|
32
|
+
readonly latestModelChange?: IndexedSelection;
|
|
33
|
+
/** Index of the newest model_change even when its identity fields are malformed. */
|
|
34
|
+
readonly latestModelChangeIndex?: number;
|
|
35
|
+
readonly assistant?: IndexedSelection;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The read-only session surface this module consumes. */
|
|
39
|
+
export interface SessionBranchReader {
|
|
40
|
+
getBranch: () => readonly unknown[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A persisted model selection recovered from session history. */
|
|
44
|
+
export interface PersistedModelSelection {
|
|
45
|
+
readonly provider: string;
|
|
46
|
+
readonly modelId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Why a managed-alias restore did not (or did not need to) happen. Every value
|
|
51
|
+
* is a bounded enum member so diagnostics never carry session text.
|
|
52
|
+
*/
|
|
53
|
+
export type RestoreOutcome =
|
|
54
|
+
| "restored"
|
|
55
|
+
| "no-persisted-selection"
|
|
56
|
+
| "unmanaged-provider"
|
|
57
|
+
| "already-active"
|
|
58
|
+
| "model-unavailable"
|
|
59
|
+
| "model-lookup-failed"
|
|
60
|
+
| "set-model-rejected"
|
|
61
|
+
| "set-model-failed";
|
|
62
|
+
|
|
63
|
+
function boundedSelection(
|
|
64
|
+
provider: unknown,
|
|
65
|
+
modelId: unknown,
|
|
66
|
+
): PersistedModelSelection | undefined {
|
|
67
|
+
if (typeof provider !== "string" || provider.length === 0 || provider.length > 256) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
if (typeof modelId !== "string" || modelId.length === 0 || modelId.length > 256) {
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
return { provider, modelId };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isCompletedAssistantStopReason(value: unknown): boolean {
|
|
77
|
+
return value === "stop" || value === "length" || value === "toolUse";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Read only the identity fields needed for restore, and fail soft on hostile history. */
|
|
81
|
+
function sessionRestoreEvidence(
|
|
82
|
+
session: SessionBranchReader,
|
|
83
|
+
): SessionRestoreEvidence | undefined {
|
|
84
|
+
try {
|
|
85
|
+
const branch = session.getBranch();
|
|
86
|
+
if (!Array.isArray(branch)) return undefined;
|
|
87
|
+
let modelChange: IndexedSelection | undefined;
|
|
88
|
+
let latestModelChange: IndexedSelection | undefined;
|
|
89
|
+
let latestModelChangeIndex: number | undefined;
|
|
90
|
+
let assistant: IndexedSelection | undefined;
|
|
91
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
92
|
+
const entry = branch[index] as
|
|
93
|
+
| ModelChangeEntryLike
|
|
94
|
+
| MessageEntryLike
|
|
95
|
+
| null
|
|
96
|
+
| undefined;
|
|
97
|
+
if (!entry || typeof entry !== "object") continue;
|
|
98
|
+
if (entry.type === "model_change") {
|
|
99
|
+
const candidate = entry as ModelChangeEntryLike;
|
|
100
|
+
const selection = boundedSelection(candidate.provider, candidate.modelId);
|
|
101
|
+
if (latestModelChangeIndex === undefined) {
|
|
102
|
+
latestModelChangeIndex = index;
|
|
103
|
+
if (selection !== undefined) latestModelChange = { index, selection };
|
|
104
|
+
}
|
|
105
|
+
if (modelChange === undefined && selection !== undefined) {
|
|
106
|
+
modelChange = { index, selection };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (assistant === undefined && entry.type === "message") {
|
|
110
|
+
const message = (entry as MessageEntryLike).message as
|
|
111
|
+
| AssistantMessageLike
|
|
112
|
+
| null
|
|
113
|
+
| undefined;
|
|
114
|
+
if (
|
|
115
|
+
message &&
|
|
116
|
+
typeof message === "object" &&
|
|
117
|
+
message.role === "assistant" &&
|
|
118
|
+
isCompletedAssistantStopReason(message.stopReason)
|
|
119
|
+
) {
|
|
120
|
+
const selection = boundedSelection(message.provider, message.model);
|
|
121
|
+
if (selection !== undefined) assistant = { index, selection };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (modelChange !== undefined && assistant !== undefined) break;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...(modelChange === undefined ? {} : { modelChange }),
|
|
128
|
+
...(latestModelChange === undefined ? {} : { latestModelChange }),
|
|
129
|
+
...(latestModelChangeIndex === undefined
|
|
130
|
+
? {}
|
|
131
|
+
: { latestModelChangeIndex }),
|
|
132
|
+
...(assistant === undefined ? {} : { assistant }),
|
|
133
|
+
};
|
|
134
|
+
} catch {
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Extracts the selection Pi should restore from a restored session branch.
|
|
141
|
+
*
|
|
142
|
+
* An explicit `model_change` is authoritative even when a later assistant
|
|
143
|
+
* message still belongs to the previously active model. When no valid explicit
|
|
144
|
+
* selection exists, the newest completed assistant identifies the provider and
|
|
145
|
+
* model that actually served the session. Failed, aborted, pending, and
|
|
146
|
+
* deferred assistants are never restore evidence.
|
|
147
|
+
*
|
|
148
|
+
* Pi's own restore runs inside `createAgentSession`, strictly BEFORE extensions
|
|
149
|
+
* load, so a managed alias provider does not exist yet at that point and the
|
|
150
|
+
* host silently falls back to a base provider. Reading the branch at
|
|
151
|
+
* `session_start` recovers the selection once the alias has been registered.
|
|
152
|
+
*
|
|
153
|
+
* Only provider, model, and stop-reason metadata is inspected. Message content,
|
|
154
|
+
* tool output, and every other session field are ignored.
|
|
155
|
+
*/
|
|
156
|
+
export function lastPersistedModelSelection(
|
|
157
|
+
session: SessionBranchReader,
|
|
158
|
+
): PersistedModelSelection | undefined {
|
|
159
|
+
const evidence = sessionRestoreEvidence(session);
|
|
160
|
+
return evidence?.modelChange?.selection ?? evidence?.assistant?.selection;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function exactModels(
|
|
164
|
+
models: readonly Model<Api>[],
|
|
165
|
+
selection: PersistedModelSelection,
|
|
166
|
+
): readonly Model<Api>[] {
|
|
167
|
+
return models.filter(
|
|
168
|
+
(model) =>
|
|
169
|
+
model.provider === selection.provider && model.id === selection.modelId,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Map one legacy physical terminal back to the logical provider only when every
|
|
175
|
+
* D6 predicate proves that the mapping is current and unique.
|
|
176
|
+
*/
|
|
177
|
+
export function resolveLegacyUnifiedSelection(input: {
|
|
178
|
+
readonly origin: PersistedModelSelection | undefined;
|
|
179
|
+
readonly physical: PersistedModelSelection;
|
|
180
|
+
readonly logicalProviderId: string;
|
|
181
|
+
readonly isManagedPhysicalSelection: (
|
|
182
|
+
selection: PersistedModelSelection,
|
|
183
|
+
) => boolean;
|
|
184
|
+
readonly findModels: (
|
|
185
|
+
selection: PersistedModelSelection,
|
|
186
|
+
) => readonly Model<Api>[];
|
|
187
|
+
}): PersistedModelSelection {
|
|
188
|
+
const physical = {
|
|
189
|
+
provider: input.physical.provider,
|
|
190
|
+
modelId: input.physical.modelId,
|
|
191
|
+
};
|
|
192
|
+
if (input.origin?.provider !== input.logicalProviderId) return physical;
|
|
193
|
+
if (physical.provider === input.logicalProviderId) return physical;
|
|
194
|
+
if (!input.isManagedPhysicalSelection(physical)) return physical;
|
|
195
|
+
const logical = {
|
|
196
|
+
provider: input.logicalProviderId,
|
|
197
|
+
modelId: physical.modelId,
|
|
198
|
+
};
|
|
199
|
+
const physicalMatches = exactModels(input.findModels(physical), physical);
|
|
200
|
+
if (physicalMatches.length !== 1) return physical;
|
|
201
|
+
const logicalMatches = exactModels(input.findModels(logical), logical);
|
|
202
|
+
return logicalMatches.length === 1 ? logical : physical;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Reinstates the operator's persisted managed-alias model after the extension
|
|
207
|
+
* has registered its alias providers.
|
|
208
|
+
*
|
|
209
|
+
* This is deliberately narrow: it acts only when the persisted provider is one
|
|
210
|
+
* this extension manages AND the host did not already restore it. A session
|
|
211
|
+
* whose last selection was an unmanaged provider is left untouched, so this
|
|
212
|
+
* never overrides the host's own restore or a base-provider choice.
|
|
213
|
+
*/
|
|
214
|
+
export async function restoreManagedAliasModel(input: {
|
|
215
|
+
readonly session: SessionBranchReader;
|
|
216
|
+
readonly currentProviderId: string | undefined;
|
|
217
|
+
readonly isManagedProvider: (providerId: string) => boolean;
|
|
218
|
+
readonly findModel: (
|
|
219
|
+
selection: PersistedModelSelection,
|
|
220
|
+
) => Model<Api> | undefined;
|
|
221
|
+
readonly setModel: (model: Model<Api>) => Promise<boolean>;
|
|
222
|
+
readonly logicalProviderId?: string;
|
|
223
|
+
readonly isManagedPhysicalSelection?: (
|
|
224
|
+
selection: PersistedModelSelection,
|
|
225
|
+
) => boolean;
|
|
226
|
+
readonly findModels?: (
|
|
227
|
+
selection: PersistedModelSelection,
|
|
228
|
+
) => readonly Model<Api>[];
|
|
229
|
+
}): Promise<RestoreOutcome> {
|
|
230
|
+
const evidence = sessionRestoreEvidence(input.session);
|
|
231
|
+
const persisted = evidence?.modelChange?.selection ?? evidence?.assistant?.selection;
|
|
232
|
+
if (!persisted) return "no-persisted-selection";
|
|
233
|
+
|
|
234
|
+
let selection = persisted;
|
|
235
|
+
const legacyPhysical = evidence?.assistant;
|
|
236
|
+
const unifiedOrigin = evidence?.latestModelChange;
|
|
237
|
+
const latestModelChangeIndex = evidence?.latestModelChangeIndex;
|
|
238
|
+
if (
|
|
239
|
+
legacyPhysical !== undefined &&
|
|
240
|
+
latestModelChangeIndex !== undefined &&
|
|
241
|
+
legacyPhysical.index > latestModelChangeIndex &&
|
|
242
|
+
input.logicalProviderId !== undefined &&
|
|
243
|
+
legacyPhysical.selection.provider !== input.logicalProviderId &&
|
|
244
|
+
input.isManagedPhysicalSelection !== undefined &&
|
|
245
|
+
input.findModels !== undefined
|
|
246
|
+
) {
|
|
247
|
+
try {
|
|
248
|
+
selection = resolveLegacyUnifiedSelection({
|
|
249
|
+
origin: unifiedOrigin?.selection,
|
|
250
|
+
physical: legacyPhysical.selection,
|
|
251
|
+
logicalProviderId: input.logicalProviderId,
|
|
252
|
+
isManagedPhysicalSelection: input.isManagedPhysicalSelection,
|
|
253
|
+
findModels: input.findModels,
|
|
254
|
+
});
|
|
255
|
+
} catch {
|
|
256
|
+
return "model-lookup-failed";
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let managed: boolean;
|
|
261
|
+
try {
|
|
262
|
+
managed = input.isManagedProvider(selection.provider);
|
|
263
|
+
} catch {
|
|
264
|
+
return "model-lookup-failed";
|
|
265
|
+
}
|
|
266
|
+
if (!managed) return "unmanaged-provider";
|
|
267
|
+
if (selection.provider === input.currentProviderId) return "already-active";
|
|
268
|
+
|
|
269
|
+
let model: Model<Api> | undefined;
|
|
270
|
+
try {
|
|
271
|
+
model = input.findModel(selection);
|
|
272
|
+
} catch {
|
|
273
|
+
return "model-lookup-failed";
|
|
274
|
+
}
|
|
275
|
+
if (
|
|
276
|
+
model === undefined ||
|
|
277
|
+
model.provider !== selection.provider ||
|
|
278
|
+
model.id !== selection.modelId
|
|
279
|
+
) {
|
|
280
|
+
return "model-unavailable";
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
return (await input.setModel(model)) ? "restored" : "set-model-rejected";
|
|
284
|
+
} catch {
|
|
285
|
+
return "set-model-failed";
|
|
286
|
+
}
|
|
287
|
+
}
|