@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
package/src/commands.ts
ADDED
|
@@ -0,0 +1,2000 @@
|
|
|
1
|
+
import {
|
|
2
|
+
chmodSync,
|
|
3
|
+
closeSync,
|
|
4
|
+
copyFileSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
openSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
renameSync,
|
|
11
|
+
unlinkSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from "node:fs";
|
|
14
|
+
import { basename, dirname, join } from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
17
|
+
import { acquireMachineLease } from "./machine-lease.js";
|
|
18
|
+
import {
|
|
19
|
+
DECLARATION_BASE_URL,
|
|
20
|
+
DECLARATION_PLACEHOLDER_KEY,
|
|
21
|
+
LOGICAL_PROVIDER_DISPLAY_NAME,
|
|
22
|
+
LOGICAL_PROVIDER_ID,
|
|
23
|
+
assertProjectedManagedModel,
|
|
24
|
+
assertProjectedManagedModels,
|
|
25
|
+
} from "./models-declaration.js";
|
|
26
|
+
import type { EffectiveAccountGroupResolution } from "./group-policy.js";
|
|
27
|
+
import type {
|
|
28
|
+
AllowedFamily,
|
|
29
|
+
ManagedFamily,
|
|
30
|
+
CrossFamilyChain,
|
|
31
|
+
MultiAccountConfig,
|
|
32
|
+
RoutingProjection,
|
|
33
|
+
} from "./config.js";
|
|
34
|
+
import type {
|
|
35
|
+
InstalledDeclarationStatus,
|
|
36
|
+
ModelDeclarationCatalogs,
|
|
37
|
+
ModelDeclarationRow,
|
|
38
|
+
} from "./models-declaration.js";
|
|
39
|
+
import {
|
|
40
|
+
ALLOWED_CROSS_FAMILY_PAIRS,
|
|
41
|
+
ALLOWED_FAMILIES,
|
|
42
|
+
isAllowedFamily,
|
|
43
|
+
isManagedFamily,
|
|
44
|
+
normalizeCrossFamilyChains,
|
|
45
|
+
routingProjection,
|
|
46
|
+
routingProjectionsEqual,
|
|
47
|
+
validateCrossChain,
|
|
48
|
+
} from "./config.js";
|
|
49
|
+
import {
|
|
50
|
+
buildModelDeclarationWithCodexDefaults,
|
|
51
|
+
type CodexModelDefaultChange,
|
|
52
|
+
planCodexModelDefaults,
|
|
53
|
+
resolveCodexLongContextDefaults,
|
|
54
|
+
} from "./codex-model-defaults.js";
|
|
55
|
+
import {
|
|
56
|
+
COMMIT_WARNING_GUIDANCE,
|
|
57
|
+
NON_ROUTING_DRIFT_GUIDANCE,
|
|
58
|
+
type RoutingConfigCommitResult,
|
|
59
|
+
} from "./routing-config-transaction.js";
|
|
60
|
+
import type { ContinuationController } from "./continuation.js";
|
|
61
|
+
import type { CostReport } from "./cost-report.js";
|
|
62
|
+
import { renderCostReport } from "./cost-report-render.js";
|
|
63
|
+
import { type DiagnosticLog, sanitizedJson } from "./diagnostics.js";
|
|
64
|
+
import {
|
|
65
|
+
accountHealth,
|
|
66
|
+
renderStatus,
|
|
67
|
+
type StatusViewInput,
|
|
68
|
+
} from "./status-view.js";
|
|
69
|
+
import type { CredentialType } from "./discovery.js";
|
|
70
|
+
import { providerTypeFor, type ProviderType } from "./vendor.js";
|
|
71
|
+
import type { UnsupportedModelPair } from "./model-support.js";
|
|
72
|
+
import type { UsageFetchStatus } from "./usage-fetch.js";
|
|
73
|
+
import { PERIOD_TYPES, type PeriodType } from "./period-boundaries.js";
|
|
74
|
+
import {
|
|
75
|
+
isCanonicalManagedProviderId,
|
|
76
|
+
type RuntimeState,
|
|
77
|
+
} from "./runtime-state.js";
|
|
78
|
+
import type { UsageLedger } from "./usage.js";
|
|
79
|
+
import { formatUsageSnapshot } from "./usage.js";
|
|
80
|
+
import type { ContinuationWatchdog } from "./watchdog.js";
|
|
81
|
+
|
|
82
|
+
export const MULTI_ACCOUNT_SUBCOMMANDS = Object.freeze([
|
|
83
|
+
"status",
|
|
84
|
+
"limits",
|
|
85
|
+
"models",
|
|
86
|
+
"model",
|
|
87
|
+
"cost",
|
|
88
|
+
"log",
|
|
89
|
+
"rediscover",
|
|
90
|
+
"add",
|
|
91
|
+
"remove",
|
|
92
|
+
"clear",
|
|
93
|
+
"next",
|
|
94
|
+
"switch",
|
|
95
|
+
"stop",
|
|
96
|
+
"reset",
|
|
97
|
+
"reload",
|
|
98
|
+
"configure",
|
|
99
|
+
"enable",
|
|
100
|
+
"disable",
|
|
101
|
+
"group",
|
|
102
|
+
] as const);
|
|
103
|
+
|
|
104
|
+
export type MultiAccountSubcommand = (typeof MULTI_ACCOUNT_SUBCOMMANDS)[number];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Every slash subcommand's supported grammar, exactly as shown in an arity
|
|
108
|
+
* error. Exported read-only so a help-surface test can assert on the real
|
|
109
|
+
* production text directly instead of re-deriving it by triggering an error.
|
|
110
|
+
*/
|
|
111
|
+
export const COMMAND_USAGE: Readonly<Record<MultiAccountSubcommand, string>> =
|
|
112
|
+
Object.freeze({
|
|
113
|
+
status: "status [account-id|--json]",
|
|
114
|
+
limits: "limits",
|
|
115
|
+
models: "models [account-id|install|update]",
|
|
116
|
+
model: "model [id]",
|
|
117
|
+
cost: "cost [day|week|month|quarter|half-year|year]",
|
|
118
|
+
log: "log [lines]",
|
|
119
|
+
rediscover: "rediscover",
|
|
120
|
+
add: "add <anthropic|openai-codex|google-antigravity|openai> [slot-number]",
|
|
121
|
+
remove: "remove <account-id>",
|
|
122
|
+
clear: "clear <account-id>",
|
|
123
|
+
next: "next",
|
|
124
|
+
switch: "switch <account-id>",
|
|
125
|
+
stop: "stop",
|
|
126
|
+
reset: "reset",
|
|
127
|
+
reload: "reload",
|
|
128
|
+
configure: "configure",
|
|
129
|
+
enable: "enable",
|
|
130
|
+
disable: "disable <anthropic|openai-codex|google-antigravity|openai>",
|
|
131
|
+
group: "group <use <id>|reset|status>",
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const COMMAND_ARITY: Readonly<
|
|
135
|
+
Record<MultiAccountSubcommand, readonly [minimum: number, maximum: number]>
|
|
136
|
+
> = Object.freeze({
|
|
137
|
+
status: [0, 1],
|
|
138
|
+
limits: [0, 0],
|
|
139
|
+
models: [0, 1],
|
|
140
|
+
model: [0, 1],
|
|
141
|
+
cost: [0, 1],
|
|
142
|
+
log: [0, 1],
|
|
143
|
+
rediscover: [0, 0],
|
|
144
|
+
add: [1, 2],
|
|
145
|
+
remove: [1, 1],
|
|
146
|
+
clear: [1, 1],
|
|
147
|
+
next: [0, 0],
|
|
148
|
+
switch: [1, 1],
|
|
149
|
+
stop: [0, 0],
|
|
150
|
+
reset: [0, 0],
|
|
151
|
+
reload: [0, 0],
|
|
152
|
+
configure: [0, 0],
|
|
153
|
+
enable: [0, 0],
|
|
154
|
+
disable: [1, 1],
|
|
155
|
+
group: [1, 2],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
function assertCommandArity(
|
|
159
|
+
command: MultiAccountSubcommand,
|
|
160
|
+
argumentCount: number,
|
|
161
|
+
): void {
|
|
162
|
+
const [minimum, maximum] = COMMAND_ARITY[command];
|
|
163
|
+
if (argumentCount < minimum || argumentCount > maximum) {
|
|
164
|
+
throw new TypeError(`Usage: /multi-account ${COMMAND_USAGE[command]}`);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function assertGroupCommand(
|
|
169
|
+
action: string | undefined,
|
|
170
|
+
groupId: string | undefined,
|
|
171
|
+
): asserts action is "use" | "reset" | "status" {
|
|
172
|
+
const valid =
|
|
173
|
+
(action === "use" && groupId !== undefined) ||
|
|
174
|
+
((action === "reset" || action === "status") && groupId === undefined);
|
|
175
|
+
if (!valid) {
|
|
176
|
+
throw new TypeError(`Usage: /multi-account ${COMMAND_USAGE.group}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface OperatorAccount {
|
|
181
|
+
readonly providerId: string;
|
|
182
|
+
readonly family: ManagedFamily;
|
|
183
|
+
readonly model: Model<Api>;
|
|
184
|
+
readonly modelIds: readonly string[];
|
|
185
|
+
readonly displayName?: string;
|
|
186
|
+
/**
|
|
187
|
+
* Bounded credential-presence category for this slot, when discovery reported
|
|
188
|
+
* one. Never a credential value. Drives the derived, live `providerType`
|
|
189
|
+
* shown on the operator surface (an Anthropic `api_key` account reads as the
|
|
190
|
+
* owning-vendor-API tier, an Anthropic OAuth account as a subscription). When
|
|
191
|
+
* absent it defaults to `"unknown"`, which derives the subscription type.
|
|
192
|
+
*/
|
|
193
|
+
readonly credentialType?: CredentialType;
|
|
194
|
+
/**
|
|
195
|
+
* Derived, non-reversible account identity. Already attached upstream at the
|
|
196
|
+
* credential boundary; carried here only so the status surface can report two
|
|
197
|
+
* slots resolving to one account. Never a credential value.
|
|
198
|
+
*/
|
|
199
|
+
readonly accountFingerprint?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The minimum Pi UI subset this controller needs, declared structurally so the
|
|
204
|
+
* command layer does not depend on the host package. Pi's `ExtensionUIContext`
|
|
205
|
+
* satisfies it.
|
|
206
|
+
*/
|
|
207
|
+
export interface CommandDialogOptions {
|
|
208
|
+
readonly signal?: AbortSignal;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface CommandUI {
|
|
212
|
+
select(
|
|
213
|
+
title: string,
|
|
214
|
+
options: string[],
|
|
215
|
+
opts?: CommandDialogOptions,
|
|
216
|
+
): Promise<string | undefined>;
|
|
217
|
+
input(
|
|
218
|
+
title: string,
|
|
219
|
+
placeholder?: string,
|
|
220
|
+
opts?: CommandDialogOptions,
|
|
221
|
+
): Promise<string | undefined>;
|
|
222
|
+
confirm(
|
|
223
|
+
title: string,
|
|
224
|
+
message: string,
|
|
225
|
+
opts?: CommandDialogOptions,
|
|
226
|
+
): Promise<boolean>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Mirrors Pi's `ExtensionMode`. */
|
|
230
|
+
export type CommandMode = "tui" | "rpc" | "json" | "print";
|
|
231
|
+
|
|
232
|
+
export interface CommandSession {
|
|
233
|
+
readonly mode: CommandMode;
|
|
234
|
+
readonly hasUI: boolean;
|
|
235
|
+
readonly ui: CommandUI;
|
|
236
|
+
/** Runs the operator-only logical model selector against the live command context. */
|
|
237
|
+
readonly switchLogicalModel?: (argument: string) => Promise<string>;
|
|
238
|
+
/** Shutdown-linked signal; passed through to every dialog. */
|
|
239
|
+
readonly signal?: AbortSignal;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The configure command's only route to persisted configuration.
|
|
244
|
+
*
|
|
245
|
+
* The controller owns dialogs and validation; it never reads or writes the
|
|
246
|
+
* config file itself, and it never publishes to the running process.
|
|
247
|
+
*/
|
|
248
|
+
export interface RoutingConfigSurface {
|
|
249
|
+
/** Fresh normalized disk read; authoritative for prompts and confirmation. */
|
|
250
|
+
readonly readPersistedRouting: () => RoutingProjection;
|
|
251
|
+
/** This process's live routing policy, for viewing and divergence only. */
|
|
252
|
+
readonly processEffectiveRouting: () => RoutingProjection;
|
|
253
|
+
readonly commit: (input: {
|
|
254
|
+
readonly promptSnapshot: RoutingProjection;
|
|
255
|
+
readonly candidate: RoutingProjection;
|
|
256
|
+
readonly signal?: AbortSignal;
|
|
257
|
+
}) => Promise<RoutingConfigCommitResult>;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface MeteredFallbackStatus {
|
|
261
|
+
readonly providerId: "openrouter";
|
|
262
|
+
readonly enabled: boolean;
|
|
263
|
+
readonly reason: string;
|
|
264
|
+
readonly active: boolean;
|
|
265
|
+
readonly sessionDisabled: boolean;
|
|
266
|
+
readonly conversationEgressConsented: boolean;
|
|
267
|
+
readonly delegatesAllowed: false;
|
|
268
|
+
readonly configuredModel?: string;
|
|
269
|
+
readonly dailyLimitUsd?: number;
|
|
270
|
+
readonly reservedTodayUsd?: number;
|
|
271
|
+
readonly remainingTodayUsd?: number;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export type DeclarationNotice = NonNullable<StatusViewInput["declarationNotice"]>;
|
|
275
|
+
|
|
276
|
+
export function declarationNoticeForStatus(
|
|
277
|
+
status: InstalledDeclarationStatus | undefined,
|
|
278
|
+
): DeclarationNotice | undefined {
|
|
279
|
+
if (status !== "mismatched" && status !== "unreadable") return undefined;
|
|
280
|
+
return {
|
|
281
|
+
condition: "stale",
|
|
282
|
+
remedy: "/multi-account models update",
|
|
283
|
+
status,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function declarationNoticeMessage(notice: DeclarationNotice): string {
|
|
288
|
+
return notice.status === "mismatched"
|
|
289
|
+
? `Managed model declaration is stale; logical routing is using the live catalog. Run ${notice.remedy} to re-sync it.`
|
|
290
|
+
: `LOGICAL ROUTING OFF: The managed model declaration is unreadable. Run ${notice.remedy}.`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export interface AccountGroupCommandMemberStatus {
|
|
294
|
+
readonly providerId: string;
|
|
295
|
+
readonly eligible: boolean;
|
|
296
|
+
readonly reason: string;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface AccountGroupCommandStatus {
|
|
300
|
+
readonly resolution: EffectiveAccountGroupResolution;
|
|
301
|
+
readonly members?: readonly AccountGroupCommandMemberStatus[];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface AccountGroupCommandSurface {
|
|
305
|
+
readonly use: (groupId: string) => AccountGroupCommandStatus;
|
|
306
|
+
readonly reset: () => AccountGroupCommandStatus;
|
|
307
|
+
readonly status: () => AccountGroupCommandStatus;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface CommandDependencies {
|
|
311
|
+
readonly state: RuntimeState;
|
|
312
|
+
readonly usage: UsageLedger;
|
|
313
|
+
readonly diagnostics: DiagnosticLog;
|
|
314
|
+
readonly continuation: Pick<ContinuationController, "cancelAll">;
|
|
315
|
+
readonly watchdog: Pick<ContinuationWatchdog, "cancelAll">;
|
|
316
|
+
/** Session-owned cancellation of watchdog, continuation, and logical attribution. */
|
|
317
|
+
readonly cancelPendingActivity: () => void;
|
|
318
|
+
readonly accounts: () => readonly OperatorAccount[];
|
|
319
|
+
/** Captured once at session start; status must never recompute declaration state. */
|
|
320
|
+
readonly logicalRoutingState?: () =>
|
|
321
|
+
| { readonly status: InstalledDeclarationStatus }
|
|
322
|
+
| undefined;
|
|
323
|
+
/** Shared process-local policy state used by automatic routing and commands. */
|
|
324
|
+
readonly disabledProviders?: Set<string>;
|
|
325
|
+
/** Full routing eligibility, including credentials and retained usage. */
|
|
326
|
+
readonly isAccountEligible?: (providerId: string, nowMs: number) => boolean;
|
|
327
|
+
readonly currentProviderId: () => string | undefined;
|
|
328
|
+
/** Physical account that served the latest logical-provider turn, when selected. */
|
|
329
|
+
readonly activeAccountProviderId?: () => string | undefined;
|
|
330
|
+
/** Model id actually in use, for display on the active account. */
|
|
331
|
+
readonly activeModelId?: () => string | undefined;
|
|
332
|
+
/** Operator-facing label for a managed account, when one resolves. */
|
|
333
|
+
readonly accountLabel?: (providerId: string) => string | undefined;
|
|
334
|
+
/** Bounded credential expiry for a managed account, when known. */
|
|
335
|
+
readonly credentialExpiry?: (providerId: string) => number | undefined;
|
|
336
|
+
/** Session-local provider/model divergence observations for operator status. */
|
|
337
|
+
readonly unsupportedModels?: () => readonly UnsupportedModelPair[];
|
|
338
|
+
/** Detached authoritative usage fetch state for operator status. */
|
|
339
|
+
readonly usageFetchStatus?: (
|
|
340
|
+
providerId: string,
|
|
341
|
+
) => UsageFetchStatus | undefined;
|
|
342
|
+
/** Read-only project/account/model cost intelligence for one calendar series. */
|
|
343
|
+
readonly costReport?: (periodType: PeriodType) => Promise<CostReport>;
|
|
344
|
+
/** Explicit metered last-resort policy; never includes credential material. */
|
|
345
|
+
readonly meteredFallbackStatus?: () => MeteredFallbackStatus;
|
|
346
|
+
readonly setModel: (model: Model<Api>) => Promise<boolean>;
|
|
347
|
+
readonly rediscover: () => Promise<void>;
|
|
348
|
+
readonly addSlot: (
|
|
349
|
+
family: ManagedFamily,
|
|
350
|
+
slotNumber?: number,
|
|
351
|
+
) => Promise<string>;
|
|
352
|
+
readonly publicRemove?: (providerId: string) => Promise<boolean>;
|
|
353
|
+
readonly reloadGlobalConfig: () => Promise<MultiAccountConfig>;
|
|
354
|
+
readonly onConfigReload?: (
|
|
355
|
+
config: MultiAccountConfig,
|
|
356
|
+
) => void | Promise<void>;
|
|
357
|
+
/** Absent in builds or harnesses with no persisted configuration surface. */
|
|
358
|
+
readonly routingConfig?: RoutingConfigSurface;
|
|
359
|
+
readonly now?: () => number;
|
|
360
|
+
/**
|
|
361
|
+
* Live inputs for the managed declaration transaction.
|
|
362
|
+
*
|
|
363
|
+
* Absent in harnesses with no models file. When absent, `models install`
|
|
364
|
+
* and `models update` refuse rather than guessing a target path, because a
|
|
365
|
+
* wrong guess would write a provider declaration into someone else's file.
|
|
366
|
+
*/
|
|
367
|
+
readonly modelsDeclaration?: {
|
|
368
|
+
readonly targetPath: string;
|
|
369
|
+
readonly readCatalogs: () => ModelsCatalogs | Promise<ModelsCatalogs>;
|
|
370
|
+
};
|
|
371
|
+
/** Operator-only session group mutation and status surface. */
|
|
372
|
+
readonly accountGroups?: AccountGroupCommandSurface;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export const CONFIGURE_VIEW_OPTION = "View cross-family routing policy";
|
|
376
|
+
|
|
377
|
+
export function crossFamilyDirectionOption(
|
|
378
|
+
from: AllowedFamily,
|
|
379
|
+
to: AllowedFamily,
|
|
380
|
+
): string {
|
|
381
|
+
return `${from} \u2192 ${to}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const CONFIGURE_TUI_ONLY =
|
|
385
|
+
"/multi-account configure needs an interactive Pi TUI session with dialogs. " +
|
|
386
|
+
"Edit crossFamilyChains and preferredModels in the machine-global config, then run /multi-account reload.";
|
|
387
|
+
|
|
388
|
+
const CONFIGURE_IN_PROGRESS =
|
|
389
|
+
"Configuration is already in progress. Finish or cancel the open configure dialog before starting another.";
|
|
390
|
+
|
|
391
|
+
const CONFIGURE_CANCELLED =
|
|
392
|
+
"Configure cancelled; no configuration or routing state changed.";
|
|
393
|
+
|
|
394
|
+
const ROUTING_DIVERGENCE_NOTE =
|
|
395
|
+
"This process is following a different routing policy than the persisted configuration; run /multi-account reload or restart Pi before it follows persisted policy.";
|
|
396
|
+
|
|
397
|
+
const MAX_MODEL_INPUT_LENGTH = 1_024;
|
|
398
|
+
const MAX_MODEL_ENTRIES = 32;
|
|
399
|
+
// Control characters can only reach here from a paste; a model id never has one.
|
|
400
|
+
const CONTROL_CHARACTER = /[\u0000-\u001f\u007f]/;
|
|
401
|
+
|
|
402
|
+
function rediscoverGuidance(family: string): string {
|
|
403
|
+
return (
|
|
404
|
+
`Cross-family routing needs a represented ${family} account in this process. ` +
|
|
405
|
+
"Run /multi-account rediscover after adding/authenticating that family, then rerun configure."
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function coverageRejection(providerIds: readonly string[]): string {
|
|
410
|
+
return (
|
|
411
|
+
`${providerIds.join(", ")} would have no preferred model in that list, ` +
|
|
412
|
+
"so an authorized account would fall through to an unchosen catalog head. Nothing was changed."
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
type ModelListResult =
|
|
417
|
+
| { readonly status: "ok"; readonly models: readonly string[] }
|
|
418
|
+
| { readonly status: "cancelled" }
|
|
419
|
+
| { readonly status: "rejected"; readonly reason: string };
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Parses one bounded comma-separated best-first model list.
|
|
423
|
+
*
|
|
424
|
+
* `undefined` is Esc, which is cancellation rather than rejection. Everything
|
|
425
|
+
* else is validated against the live destination catalog BEFORE any mutation,
|
|
426
|
+
* because a typo'd id would otherwise be persisted as policy and then silently
|
|
427
|
+
* fall through to a catalog head.
|
|
428
|
+
*/
|
|
429
|
+
function parseModelList(
|
|
430
|
+
raw: string | undefined,
|
|
431
|
+
available: readonly string[],
|
|
432
|
+
): ModelListResult {
|
|
433
|
+
if (raw === undefined) return { status: "cancelled" };
|
|
434
|
+
if (raw.length > MAX_MODEL_INPUT_LENGTH) {
|
|
435
|
+
return {
|
|
436
|
+
status: "rejected",
|
|
437
|
+
reason: "the model list must be at most 1,024 characters.",
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const entries = raw.split(",").map((entry) => entry.trim());
|
|
441
|
+
if (entries.length > MAX_MODEL_ENTRIES) {
|
|
442
|
+
return {
|
|
443
|
+
status: "rejected",
|
|
444
|
+
reason: "enter at most 32 comma-separated model IDs.",
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
const models: string[] = [];
|
|
448
|
+
for (const entry of entries) {
|
|
449
|
+
if (entry.length === 0) {
|
|
450
|
+
return {
|
|
451
|
+
status: "rejected",
|
|
452
|
+
reason: "enter at least one destination model ID.",
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
if (/\s/.test(entry)) {
|
|
456
|
+
return {
|
|
457
|
+
status: "rejected",
|
|
458
|
+
reason: "model IDs must not contain whitespace.",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (CONTROL_CHARACTER.test(entry)) {
|
|
462
|
+
return {
|
|
463
|
+
status: "rejected",
|
|
464
|
+
reason: "model IDs must not contain a control character.",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (models.includes(entry)) {
|
|
468
|
+
return {
|
|
469
|
+
status: "rejected",
|
|
470
|
+
reason: `remove the duplicate model ID ${entry}.`,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
if (!available.includes(entry)) {
|
|
474
|
+
return {
|
|
475
|
+
status: "rejected",
|
|
476
|
+
reason: `${entry} is not offered by any live managed account in that family.`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
models.push(entry);
|
|
480
|
+
}
|
|
481
|
+
if (models.length === 0) {
|
|
482
|
+
return {
|
|
483
|
+
status: "rejected",
|
|
484
|
+
reason: "enter at least one destination model ID.",
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
return { status: "ok", models };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function liveModelIds(
|
|
491
|
+
accounts: readonly OperatorAccount[],
|
|
492
|
+
family: AllowedFamily,
|
|
493
|
+
): readonly string[] {
|
|
494
|
+
const seen = new Set<string>();
|
|
495
|
+
const ids: string[] = [];
|
|
496
|
+
for (const account of accounts) {
|
|
497
|
+
if (account.family !== family) continue;
|
|
498
|
+
for (const modelId of account.modelIds) {
|
|
499
|
+
if (seen.has(modelId)) continue;
|
|
500
|
+
seen.add(modelId);
|
|
501
|
+
ids.push(modelId);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return ids;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Per-ACCOUNT coverage, not union membership.
|
|
509
|
+
*
|
|
510
|
+
* A list that names one live model of the family still leaves any account that
|
|
511
|
+
* cannot serve it falling through to whatever its catalog happens to list
|
|
512
|
+
* first, which is exactly the defect `preferredModels` exists to prevent.
|
|
513
|
+
*/
|
|
514
|
+
function accountsMissingPreference(
|
|
515
|
+
accounts: readonly OperatorAccount[],
|
|
516
|
+
family: AllowedFamily,
|
|
517
|
+
models: readonly string[],
|
|
518
|
+
): readonly string[] {
|
|
519
|
+
return accounts
|
|
520
|
+
.filter(
|
|
521
|
+
(account) =>
|
|
522
|
+
account.family === family &&
|
|
523
|
+
!account.modelIds.some((modelId) => models.includes(modelId)),
|
|
524
|
+
)
|
|
525
|
+
.map((account) => account.providerId);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function describeTierModelMap(projection: RoutingProjection): string {
|
|
529
|
+
const summary = Object.entries(projection.tierModelMap)
|
|
530
|
+
.map(([destination, mappings]) => {
|
|
531
|
+
const count = Object.keys(mappings).length;
|
|
532
|
+
return `${destination}: ${count} ${count === 1 ? "entry" : "entries"}`;
|
|
533
|
+
})
|
|
534
|
+
.join("; ");
|
|
535
|
+
return summary.length === 0 ? "none" : summary;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function describeRoutingPolicy(projection: RoutingProjection): string {
|
|
539
|
+
const edges =
|
|
540
|
+
projection.crossFamilyChains.length === 0
|
|
541
|
+
? "no directional edges"
|
|
542
|
+
: projection.crossFamilyChains
|
|
543
|
+
.map((edge) => crossFamilyDirectionOption(edge.from, edge.to))
|
|
544
|
+
.join("; ");
|
|
545
|
+
const preferred = Object.entries(projection.preferredModels)
|
|
546
|
+
.map(([family, models]) => `${family}: ${models.join(", ")}`)
|
|
547
|
+
.join("; ");
|
|
548
|
+
return (
|
|
549
|
+
`${projection.crossFamilyChainEnabled ? "enabled" : "disabled"}; ` +
|
|
550
|
+
`${edges}; preferred models: ${preferred.length === 0 ? "none" : preferred}; ` +
|
|
551
|
+
`tier model map: ${describeTierModelMap(projection)}`
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function renderRoutingView(
|
|
556
|
+
persisted: RoutingProjection,
|
|
557
|
+
effective: RoutingProjection,
|
|
558
|
+
): string {
|
|
559
|
+
const lines = [
|
|
560
|
+
`Persisted cross-family routing: ${describeRoutingPolicy(persisted)}`,
|
|
561
|
+
`Process-effective cross-family routing: ${describeRoutingPolicy(effective)}`,
|
|
562
|
+
];
|
|
563
|
+
if (!routingProjectionsEqual(persisted, effective)) {
|
|
564
|
+
lines.push(ROUTING_DIVERGENCE_NOTE);
|
|
565
|
+
}
|
|
566
|
+
return lines.join("\n");
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function renderCommitResult(
|
|
570
|
+
result: RoutingConfigCommitResult,
|
|
571
|
+
candidate: RoutingProjection,
|
|
572
|
+
destinationFamily: AllowedFamily,
|
|
573
|
+
): string {
|
|
574
|
+
switch (result.status) {
|
|
575
|
+
case "applied":
|
|
576
|
+
case "applied-warning": {
|
|
577
|
+
const edges = candidate.crossFamilyChains
|
|
578
|
+
.map((edge) => crossFamilyDirectionOption(edge.from, edge.to))
|
|
579
|
+
.join("; ");
|
|
580
|
+
const models = (candidate.preferredModels[destinationFamily] ?? []).join(
|
|
581
|
+
", ",
|
|
582
|
+
);
|
|
583
|
+
const lines = [
|
|
584
|
+
`Cross-family routing is enabled with ${edges}; preferred ${destinationFamily} models: ${models}.`,
|
|
585
|
+
];
|
|
586
|
+
if (result.status === "applied-warning") {
|
|
587
|
+
lines.push(COMMIT_WARNING_GUIDANCE);
|
|
588
|
+
}
|
|
589
|
+
if (result.nonRoutingDrift) lines.push(NON_ROUTING_DRIFT_GUIDANCE);
|
|
590
|
+
return lines.join("\n");
|
|
591
|
+
}
|
|
592
|
+
case "unchanged":
|
|
593
|
+
return "Cross-family routing is already configured exactly that way, on disk and in this process; nothing was written.";
|
|
594
|
+
case "busy":
|
|
595
|
+
return "Another process is committing multi-account configuration. Nothing was changed; rerun /multi-account configure in a moment.";
|
|
596
|
+
case "changed":
|
|
597
|
+
return "The persisted configuration changed while this dialog was open, so nothing was written. Rerun /multi-account configure from the current policy.";
|
|
598
|
+
case "invalid":
|
|
599
|
+
return "The machine-global configuration is malformed, so nothing was written. Repair the file, then rerun /multi-account configure.";
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const MANUAL_REMOVE_INSTRUCTIONS =
|
|
604
|
+
"Credentials remain in AuthStorage. Run Pi's /logout command and select this provider; this command does not edit auth.json directly.";
|
|
605
|
+
|
|
606
|
+
function parseLineCount(value: string | undefined): number {
|
|
607
|
+
if (value === undefined) return 20;
|
|
608
|
+
if (!/^\d+$/.test(value))
|
|
609
|
+
throw new TypeError("log lines must be an integer from 1 through 100.");
|
|
610
|
+
const count = Number(value);
|
|
611
|
+
if (!Number.isSafeInteger(count) || count < 1 || count > 100) {
|
|
612
|
+
throw new RangeError("log lines must be an integer from 1 through 100.");
|
|
613
|
+
}
|
|
614
|
+
return count;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function parseSlotNumber(value: string | undefined): number | undefined {
|
|
618
|
+
if (value === undefined) return undefined;
|
|
619
|
+
if (!/^\d+$/.test(value))
|
|
620
|
+
throw new TypeError("slot number must be a positive safe integer.");
|
|
621
|
+
const slot = Number(value);
|
|
622
|
+
if (!Number.isSafeInteger(slot) || slot < 2) {
|
|
623
|
+
throw new RangeError("slot number must be a safe integer of at least 2.");
|
|
624
|
+
}
|
|
625
|
+
return slot;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
export class MultiAccountCommandController {
|
|
629
|
+
readonly #dependencies: CommandDependencies;
|
|
630
|
+
readonly #disabledProviders: Set<string>;
|
|
631
|
+
/** One configure flow per controller: dialogs are modal to the operator. */
|
|
632
|
+
#configureInProgress = false;
|
|
633
|
+
|
|
634
|
+
constructor(dependencies: CommandDependencies) {
|
|
635
|
+
this.#dependencies = dependencies;
|
|
636
|
+
this.#disabledProviders =
|
|
637
|
+
dependencies.disabledProviders ?? new Set<string>();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async execute(
|
|
641
|
+
rawArguments: string,
|
|
642
|
+
session?: CommandSession,
|
|
643
|
+
): Promise<string> {
|
|
644
|
+
try {
|
|
645
|
+
if (rawArguments.length > 1_024)
|
|
646
|
+
throw new RangeError("command arguments are too long.");
|
|
647
|
+
const parts = rawArguments.trim()
|
|
648
|
+
? rawArguments.trim().split(/\s+/)
|
|
649
|
+
: ["status"];
|
|
650
|
+
if (parts.length > 3) throw new TypeError("too many command arguments.");
|
|
651
|
+
const [rawCommand, first, second] = parts;
|
|
652
|
+
if (
|
|
653
|
+
!rawCommand ||
|
|
654
|
+
!MULTI_ACCOUNT_SUBCOMMANDS.includes(
|
|
655
|
+
rawCommand as MultiAccountSubcommand,
|
|
656
|
+
)
|
|
657
|
+
) {
|
|
658
|
+
return this.#output(
|
|
659
|
+
`Unknown subcommand. Use: ${MULTI_ACCOUNT_SUBCOMMANDS.join(", ")}.`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
const command = rawCommand as MultiAccountSubcommand;
|
|
663
|
+
assertCommandArity(command, parts.length - 1);
|
|
664
|
+
if (command === "group") assertGroupCommand(first, second);
|
|
665
|
+
let result: string;
|
|
666
|
+
switch (command) {
|
|
667
|
+
case "status":
|
|
668
|
+
result = this.#status(first);
|
|
669
|
+
break;
|
|
670
|
+
case "limits":
|
|
671
|
+
result = this.#limits();
|
|
672
|
+
break;
|
|
673
|
+
case "models":
|
|
674
|
+
// `models` shares its first word with two different commands: the
|
|
675
|
+
// per-account lister, and the declaration transaction. Only the two
|
|
676
|
+
// exact action words route to the transaction; every other argument
|
|
677
|
+
// keeps the pre-existing listing behaviour unchanged.
|
|
678
|
+
result =
|
|
679
|
+
first === "install" || first === "update"
|
|
680
|
+
? await this.#modelsTransaction(first, session)
|
|
681
|
+
: this.#models(first);
|
|
682
|
+
break;
|
|
683
|
+
case "model":
|
|
684
|
+
if (session?.switchLogicalModel === undefined) {
|
|
685
|
+
throw new Error("Logical model selection requires a live command session.");
|
|
686
|
+
}
|
|
687
|
+
result = await session.switchLogicalModel(first ?? "");
|
|
688
|
+
break;
|
|
689
|
+
case "cost":
|
|
690
|
+
result = await this.#cost(first);
|
|
691
|
+
break;
|
|
692
|
+
case "log":
|
|
693
|
+
result = this.#dependencies.diagnostics.formatRecent(
|
|
694
|
+
parseLineCount(first),
|
|
695
|
+
);
|
|
696
|
+
break;
|
|
697
|
+
case "rediscover":
|
|
698
|
+
result = await this.#rediscover();
|
|
699
|
+
break;
|
|
700
|
+
case "add":
|
|
701
|
+
result = await this.#add(first, second);
|
|
702
|
+
break;
|
|
703
|
+
case "remove":
|
|
704
|
+
result = await this.#remove(first);
|
|
705
|
+
break;
|
|
706
|
+
case "clear":
|
|
707
|
+
result = this.#clear(first);
|
|
708
|
+
break;
|
|
709
|
+
case "next":
|
|
710
|
+
result = this.#next();
|
|
711
|
+
break;
|
|
712
|
+
case "switch":
|
|
713
|
+
result = await this.#switch(first);
|
|
714
|
+
break;
|
|
715
|
+
case "stop":
|
|
716
|
+
result = this.#stop();
|
|
717
|
+
break;
|
|
718
|
+
case "reset":
|
|
719
|
+
result = this.#reset();
|
|
720
|
+
break;
|
|
721
|
+
case "reload":
|
|
722
|
+
result = await this.#reload();
|
|
723
|
+
break;
|
|
724
|
+
case "configure":
|
|
725
|
+
result = await this.#configure(session);
|
|
726
|
+
break;
|
|
727
|
+
case "enable":
|
|
728
|
+
result = this.#enable();
|
|
729
|
+
break;
|
|
730
|
+
case "disable":
|
|
731
|
+
result = this.#disable(first);
|
|
732
|
+
break;
|
|
733
|
+
case "group":
|
|
734
|
+
result = this.#group(first, second);
|
|
735
|
+
break;
|
|
736
|
+
default:
|
|
737
|
+
throw new TypeError(`Unsupported subcommand: ${String(command)}`);
|
|
738
|
+
}
|
|
739
|
+
return this.#output(result);
|
|
740
|
+
} catch (error) {
|
|
741
|
+
this.#dependencies.diagnostics.recordError(
|
|
742
|
+
"command.multi-account",
|
|
743
|
+
error,
|
|
744
|
+
);
|
|
745
|
+
const detail = error instanceof Error ? error.message : error;
|
|
746
|
+
return this.#output(
|
|
747
|
+
`Command failed: ${String(detail)} Review /multi-account status and retry.`,
|
|
748
|
+
);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
#accounts(): readonly OperatorAccount[] {
|
|
753
|
+
return this.#dependencies
|
|
754
|
+
.accounts()
|
|
755
|
+
.filter(
|
|
756
|
+
(account) =>
|
|
757
|
+
isCanonicalManagedProviderId(account.providerId, account.family) &&
|
|
758
|
+
account.model.provider === account.providerId,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
#account(providerId: string | undefined): OperatorAccount {
|
|
763
|
+
if (!providerId) throw new TypeError("an account ID is required.");
|
|
764
|
+
const account = this.#accounts().find(
|
|
765
|
+
(candidate) => candidate.providerId === providerId,
|
|
766
|
+
);
|
|
767
|
+
if (!account)
|
|
768
|
+
throw new TypeError(
|
|
769
|
+
`Unknown managed account ${providerId}. Run rediscover.`,
|
|
770
|
+
);
|
|
771
|
+
return account;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Renders account status. Human-readable by default; `status --json` keeps the
|
|
776
|
+
* original machine-readable shape for scripted consumers.
|
|
777
|
+
*/
|
|
778
|
+
#status(argument: string | undefined): string {
|
|
779
|
+
const now = this.#now();
|
|
780
|
+
const unsupportedModels = this.#dependencies.unsupportedModels?.() ?? [];
|
|
781
|
+
const asJson = argument === "--json";
|
|
782
|
+
const declarationNotice = declarationNoticeForStatus(
|
|
783
|
+
this.#dependencies.logicalRoutingState?.()?.status,
|
|
784
|
+
);
|
|
785
|
+
const declarationNoticeJson =
|
|
786
|
+
declarationNotice === undefined
|
|
787
|
+
? undefined
|
|
788
|
+
: {
|
|
789
|
+
condition: declarationNotice.condition,
|
|
790
|
+
remedy: declarationNotice.remedy,
|
|
791
|
+
status: declarationNotice.status,
|
|
792
|
+
};
|
|
793
|
+
const providerId = asJson ? undefined : argument;
|
|
794
|
+
const allAccounts = this.#accounts();
|
|
795
|
+
const accounts = providerId ? [this.#account(providerId)] : allAccounts;
|
|
796
|
+
if (accounts.length === 0) {
|
|
797
|
+
const message =
|
|
798
|
+
"No managed accounts are available. Run /multi-account rediscover.";
|
|
799
|
+
if (asJson) {
|
|
800
|
+
return sanitizedJson({
|
|
801
|
+
message,
|
|
802
|
+
...(declarationNoticeJson === undefined
|
|
803
|
+
? {}
|
|
804
|
+
: { declarationNotice: declarationNoticeJson }),
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
return renderStatus({
|
|
808
|
+
nowMs: now,
|
|
809
|
+
accounts: [],
|
|
810
|
+
...(declarationNotice === undefined ? {} : { declarationNotice }),
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
const currentProviderId = this.#dependencies.currentProviderId();
|
|
814
|
+
const activeAccountProviderId =
|
|
815
|
+
this.#dependencies.activeAccountProviderId?.() ?? currentProviderId;
|
|
816
|
+
// Label and expiry are resolved HERE, not per-branch, so the JSON and
|
|
817
|
+
// human-readable views cannot drift: a scripted consumer sees the same
|
|
818
|
+
// credential freshness the operator does.
|
|
819
|
+
const projected = accounts.map((account) => {
|
|
820
|
+
const disabled = this.#disabledProviders.has(account.providerId);
|
|
821
|
+
const coolingUntilMs = this.#dependencies.state.getCooldown(
|
|
822
|
+
account.providerId,
|
|
823
|
+
now,
|
|
824
|
+
)?.untilMs;
|
|
825
|
+
const unavailable =
|
|
826
|
+
this.#dependencies.state.getInvalidation(account.providerId) !==
|
|
827
|
+
undefined;
|
|
828
|
+
const usageUntrusted =
|
|
829
|
+
this.#dependencies.state.isUsageSnapshotUntrusted(
|
|
830
|
+
account.providerId,
|
|
831
|
+
now,
|
|
832
|
+
);
|
|
833
|
+
const usage = this.#dependencies.usage.get(account.providerId);
|
|
834
|
+
const view = {
|
|
835
|
+
providerId: account.providerId,
|
|
836
|
+
family: account.family,
|
|
837
|
+
// Derived live from the discovered credential type; additive field,
|
|
838
|
+
// no existing field changes. An absent credentialType defaults to
|
|
839
|
+
// the subscription type.
|
|
840
|
+
providerType: providerTypeFor(
|
|
841
|
+
account.family,
|
|
842
|
+
account.credentialType ?? "unknown",
|
|
843
|
+
),
|
|
844
|
+
active: account.providerId === activeAccountProviderId,
|
|
845
|
+
disabled,
|
|
846
|
+
unavailable,
|
|
847
|
+
...(coolingUntilMs === undefined ? {} : { coolingUntilMs }),
|
|
848
|
+
...(usageUntrusted ? { usageUntrusted: true } : {}),
|
|
849
|
+
...(usage === undefined ? {} : { usage }),
|
|
850
|
+
};
|
|
851
|
+
const health = accountHealth(view, now);
|
|
852
|
+
return {
|
|
853
|
+
...view,
|
|
854
|
+
api: account.model.api,
|
|
855
|
+
healthy: health === "ready" || health === "low-headroom",
|
|
856
|
+
...(account.accountFingerprint === undefined
|
|
857
|
+
? {}
|
|
858
|
+
: { accountFingerprint: account.accountFingerprint }),
|
|
859
|
+
usageFetch: this.#dependencies.usageFetchStatus?.(account.providerId),
|
|
860
|
+
allModelsUnsupported:
|
|
861
|
+
account.modelIds.length > 0 &&
|
|
862
|
+
account.modelIds.every((modelId) =>
|
|
863
|
+
unsupportedModels.some(
|
|
864
|
+
(pair) =>
|
|
865
|
+
pair.providerId === account.providerId &&
|
|
866
|
+
pair.modelId === modelId,
|
|
867
|
+
),
|
|
868
|
+
),
|
|
869
|
+
...this.#optionalField(
|
|
870
|
+
"label",
|
|
871
|
+
this.#dependencies.accountLabel?.(account.providerId),
|
|
872
|
+
),
|
|
873
|
+
...this.#optionalField(
|
|
874
|
+
"expiresAtMs",
|
|
875
|
+
this.#dependencies.credentialExpiry?.(account.providerId),
|
|
876
|
+
),
|
|
877
|
+
};
|
|
878
|
+
});
|
|
879
|
+
const meteredFallback = this.#dependencies.meteredFallbackStatus?.();
|
|
880
|
+
if (asJson) {
|
|
881
|
+
return sanitizedJson({
|
|
882
|
+
currentProviderId,
|
|
883
|
+
healthyAccountCount: projected.filter((account) => account.healthy)
|
|
884
|
+
.length,
|
|
885
|
+
accounts: projected,
|
|
886
|
+
unsupportedModels: this.#dependencies.unsupportedModels?.(),
|
|
887
|
+
...(declarationNoticeJson === undefined
|
|
888
|
+
? {}
|
|
889
|
+
: { declarationNotice: declarationNoticeJson }),
|
|
890
|
+
...(meteredFallback === undefined ? {} : { meteredFallback }),
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
const rendered = renderStatus({
|
|
894
|
+
nowMs: now,
|
|
895
|
+
scoped: providerId !== undefined,
|
|
896
|
+
accounts: projected.map((account) => ({
|
|
897
|
+
providerId: account.providerId,
|
|
898
|
+
family: account.family,
|
|
899
|
+
providerType: account.providerType,
|
|
900
|
+
active: account.active,
|
|
901
|
+
disabled: account.disabled,
|
|
902
|
+
unavailable: account.unavailable,
|
|
903
|
+
...(account.usageUntrusted ? { usageUntrusted: true } : {}),
|
|
904
|
+
...(account.coolingUntilMs === undefined
|
|
905
|
+
? {}
|
|
906
|
+
: { coolingUntilMs: account.coolingUntilMs }),
|
|
907
|
+
...(account.usage === undefined ? {} : { usage: account.usage }),
|
|
908
|
+
...(account.usageFetch === undefined
|
|
909
|
+
? {}
|
|
910
|
+
: { usageFetch: account.usageFetch }),
|
|
911
|
+
...(account.allModelsUnsupported ? { allModelsUnsupported: true } : {}),
|
|
912
|
+
...("label" in account ? { label: account.label } : {}),
|
|
913
|
+
...("expiresAtMs" in account
|
|
914
|
+
? { expiresAtMs: account.expiresAtMs }
|
|
915
|
+
: {}),
|
|
916
|
+
...("accountFingerprint" in account
|
|
917
|
+
? { accountFingerprint: account.accountFingerprint }
|
|
918
|
+
: {}),
|
|
919
|
+
...this.#activeModelIdField(account.active),
|
|
920
|
+
})),
|
|
921
|
+
...(unsupportedModels.length === 0 ? {} : { unsupportedModels }),
|
|
922
|
+
...(declarationNotice === undefined ? {} : { declarationNotice }),
|
|
923
|
+
});
|
|
924
|
+
if (meteredFallback === undefined) return rendered;
|
|
925
|
+
const budget =
|
|
926
|
+
meteredFallback.reservedTodayUsd === undefined ||
|
|
927
|
+
meteredFallback.dailyLimitUsd === undefined
|
|
928
|
+
? "daily reservation state unavailable"
|
|
929
|
+
: `$${meteredFallback.reservedTodayUsd.toFixed(2)} reserved of $${meteredFallback.dailyLimitUsd.toFixed(2)} today`;
|
|
930
|
+
if (!meteredFallback.enabled) {
|
|
931
|
+
const configured =
|
|
932
|
+
meteredFallback.configuredModel === undefined
|
|
933
|
+
? ""
|
|
934
|
+
: `; configured for ${meteredFallback.configuredModel}; ${budget}`;
|
|
935
|
+
return `${rendered}\n\nOpenRouter last resort: disabled (${meteredFallback.reason})${configured}.`;
|
|
936
|
+
}
|
|
937
|
+
return (
|
|
938
|
+
`${rendered}\n\nOpenRouter last resort: enabled for ${meteredFallback.configuredModel ?? "an unavailable model"}; ` +
|
|
939
|
+
`${budget}; active ${meteredFallback.active ? "yes" : "no"}; delegate use blocked.`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
#limits(): string {
|
|
944
|
+
const snapshots = this.#dependencies.usage.snapshots();
|
|
945
|
+
if (snapshots.length === 0)
|
|
946
|
+
return "No usage observations are available yet.";
|
|
947
|
+
return snapshots
|
|
948
|
+
.map((snapshot) => formatUsageSnapshot(snapshot, this.#now()))
|
|
949
|
+
.join("\n");
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
async #cost(periodValue: string | undefined): Promise<string> {
|
|
953
|
+
const periodType = periodValue ?? "month";
|
|
954
|
+
if (!PERIOD_TYPES.includes(periodType as PeriodType)) {
|
|
955
|
+
throw new TypeError(
|
|
956
|
+
"cost period must be day, week, month, quarter, half-year, or year.",
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
if (this.#dependencies.costReport === undefined) {
|
|
960
|
+
return "Cost intelligence is unavailable in this Pi build.";
|
|
961
|
+
}
|
|
962
|
+
return renderCostReport(
|
|
963
|
+
await this.#dependencies.costReport(periodType as PeriodType),
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Run the managed declaration transaction for `install` or `update`.
|
|
969
|
+
*
|
|
970
|
+
* This fails closed in two ways rather than writing something the operator
|
|
971
|
+
* did not see. Without declaration inputs there is no target path to write.
|
|
972
|
+
* Without an interactive session there is no way to show the diff, and
|
|
973
|
+
* `executeModelsCommand` requires a confirmation answer before it commits;
|
|
974
|
+
* defaulting that answer to yes would write an unreviewed provider entry.
|
|
975
|
+
*/
|
|
976
|
+
async #modelsTransaction(
|
|
977
|
+
action: "install" | "update",
|
|
978
|
+
session: CommandSession | undefined,
|
|
979
|
+
): Promise<string> {
|
|
980
|
+
const declaration = this.#dependencies.modelsDeclaration;
|
|
981
|
+
if (declaration === undefined) {
|
|
982
|
+
throw new Error(
|
|
983
|
+
"Managed model declarations are unavailable in this session.",
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
if (session === undefined || !session.hasUI) {
|
|
987
|
+
throw new Error(
|
|
988
|
+
`/multi-account models ${action} needs an interactive session to confirm the change.`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
const { ui, signal } = session;
|
|
992
|
+
const result = await executeModelsCommand(`models ${action}`, {
|
|
993
|
+
targetPath: declaration.targetPath,
|
|
994
|
+
readCatalogs: declaration.readCatalogs,
|
|
995
|
+
// `confirm` is an adapter, not a handoff: the transaction supplies one
|
|
996
|
+
// rendered diff, while the dialog surface takes a title and a body.
|
|
997
|
+
confirm: (diff: string) =>
|
|
998
|
+
ui.confirm(
|
|
999
|
+
`Apply managed model declaration (${action})`,
|
|
1000
|
+
diff,
|
|
1001
|
+
signal ? { signal } : undefined,
|
|
1002
|
+
),
|
|
1003
|
+
});
|
|
1004
|
+
if (result.outcome === "cancelled") {
|
|
1005
|
+
return `No change was written. ${result.action} cancelled.`;
|
|
1006
|
+
}
|
|
1007
|
+
const lines = [
|
|
1008
|
+
`Managed declaration ${result.action === "install" ? "installed" : "updated"} with ${result.modelIds.length} models.`,
|
|
1009
|
+
...result.diagnostics,
|
|
1010
|
+
];
|
|
1011
|
+
return lines.join("\n");
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
#models(providerId: string | undefined): string {
|
|
1015
|
+
const accounts = providerId
|
|
1016
|
+
? [this.#account(providerId)]
|
|
1017
|
+
: this.#accounts();
|
|
1018
|
+
if (accounts.length === 0)
|
|
1019
|
+
return "No models are available. Add or rediscover an account.";
|
|
1020
|
+
const unsupportedModels = this.#dependencies.unsupportedModels?.() ?? [];
|
|
1021
|
+
return sanitizedJson(
|
|
1022
|
+
accounts.map((account) => {
|
|
1023
|
+
const unsupported = account.modelIds.filter((modelId) =>
|
|
1024
|
+
unsupportedModels.some(
|
|
1025
|
+
(pair) =>
|
|
1026
|
+
pair.providerId === account.providerId &&
|
|
1027
|
+
pair.modelId === modelId,
|
|
1028
|
+
),
|
|
1029
|
+
);
|
|
1030
|
+
return {
|
|
1031
|
+
providerId: account.providerId,
|
|
1032
|
+
displayName: account.displayName ?? account.providerId,
|
|
1033
|
+
api: account.model.api,
|
|
1034
|
+
models: account.modelIds,
|
|
1035
|
+
...(unsupported.length === 0
|
|
1036
|
+
? {}
|
|
1037
|
+
: { unsupportedModels: unsupported }),
|
|
1038
|
+
...(unsupported.length === account.modelIds.length &&
|
|
1039
|
+
account.modelIds.length > 0
|
|
1040
|
+
? { allModelsUnsupported: true }
|
|
1041
|
+
: {}),
|
|
1042
|
+
};
|
|
1043
|
+
}),
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async #rediscover(): Promise<string> {
|
|
1048
|
+
await this.#dependencies.rediscover();
|
|
1049
|
+
return "Account metadata rediscovered and provider slots refreshed.";
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
async #add(
|
|
1053
|
+
familyValue: string | undefined,
|
|
1054
|
+
slotValue: string | undefined,
|
|
1055
|
+
): Promise<string> {
|
|
1056
|
+
if (!familyValue || !isManagedFamily(familyValue)) {
|
|
1057
|
+
throw new TypeError(
|
|
1058
|
+
"add requires family anthropic, openai-codex, google-antigravity, or openai.",
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
const providerId = await this.#dependencies.addSlot(
|
|
1062
|
+
familyValue,
|
|
1063
|
+
parseSlotNumber(slotValue),
|
|
1064
|
+
);
|
|
1065
|
+
// The owning-vendor-api openai family authenticates from an API key, not an
|
|
1066
|
+
// OAuth login, so its guidance must not tell the operator to run a login.
|
|
1067
|
+
// Captured as a plain string so this branch does not depend on how the
|
|
1068
|
+
// family-acceptance guard above narrows `familyValue`.
|
|
1069
|
+
const requestedFamily: string = familyValue;
|
|
1070
|
+
return requestedFamily === "openai"
|
|
1071
|
+
? `Registered API-key slot ${providerId}. Set OPENAI_API_KEY so it can authenticate.`
|
|
1072
|
+
: `Registered OAuth login slot ${providerId}. Use Pi's public login command to authenticate it.`;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async #remove(providerId: string | undefined): Promise<string> {
|
|
1076
|
+
const account = this.#account(providerId);
|
|
1077
|
+
if (!this.#dependencies.publicRemove) return MANUAL_REMOVE_INSTRUCTIONS;
|
|
1078
|
+
const removed = await this.#dependencies.publicRemove(account.providerId);
|
|
1079
|
+
return removed
|
|
1080
|
+
? `Removed credentials for ${account.providerId} through Pi's public API.`
|
|
1081
|
+
: MANUAL_REMOVE_INSTRUCTIONS;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
#clear(providerId: string | undefined): string {
|
|
1085
|
+
const account = this.#account(providerId);
|
|
1086
|
+
this.#disabledProviders.add(account.providerId);
|
|
1087
|
+
this.#dependencies.state.resetAccount(account.providerId);
|
|
1088
|
+
this.#dependencies.usage.clear(account.providerId);
|
|
1089
|
+
return `Cleared process-local state and disabled ${account.providerId}; credentials were preserved.`;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
#next(): string {
|
|
1093
|
+
const now = this.#now();
|
|
1094
|
+
const current = this.#dependencies.currentProviderId();
|
|
1095
|
+
const next = this.#accounts().find((account) => {
|
|
1096
|
+
if (
|
|
1097
|
+
account.providerId === current ||
|
|
1098
|
+
this.#disabledProviders.has(account.providerId)
|
|
1099
|
+
) {
|
|
1100
|
+
return false;
|
|
1101
|
+
}
|
|
1102
|
+
if (this.#dependencies.isAccountEligible !== undefined) {
|
|
1103
|
+
return this.#dependencies.isAccountEligible(account.providerId, now);
|
|
1104
|
+
}
|
|
1105
|
+
return (
|
|
1106
|
+
this.#dependencies.state.getCooldown(account.providerId, now) ===
|
|
1107
|
+
undefined &&
|
|
1108
|
+
this.#dependencies.state.getInvalidation(account.providerId) ===
|
|
1109
|
+
undefined
|
|
1110
|
+
);
|
|
1111
|
+
});
|
|
1112
|
+
return next
|
|
1113
|
+
? `Next healthy account: ${next.providerId}.`
|
|
1114
|
+
: "No healthy alternative is available; add or rediscover an account.";
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async #switch(providerId: string | undefined): Promise<string> {
|
|
1118
|
+
const account = this.#account(providerId);
|
|
1119
|
+
if (this.#disabledProviders.has(account.providerId)) {
|
|
1120
|
+
return `${account.providerId} is disabled in this process. Run enable or reset first.`;
|
|
1121
|
+
}
|
|
1122
|
+
const cooldown = this.#dependencies.state.getCooldown(
|
|
1123
|
+
account.providerId,
|
|
1124
|
+
this.#now(),
|
|
1125
|
+
);
|
|
1126
|
+
if (cooldown) {
|
|
1127
|
+
return `${account.providerId} is cooling until ${cooldown.untilMs}. Run /multi-account next or wait for recovery.`;
|
|
1128
|
+
}
|
|
1129
|
+
if (this.#dependencies.state.getInvalidation(account.providerId)) {
|
|
1130
|
+
return `${account.providerId} is unavailable after an authentication failure. Run rediscover or reset.`;
|
|
1131
|
+
}
|
|
1132
|
+
const switched =
|
|
1133
|
+
(await this.#dependencies.setModel(account.model)) === true;
|
|
1134
|
+
return switched
|
|
1135
|
+
? `Switched explicitly to ${account.providerId}.`
|
|
1136
|
+
: `Pi could not select ${account.providerId}; authenticate it or choose another healthy account.`;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
#stop(): string {
|
|
1140
|
+
this.#dependencies.cancelPendingActivity();
|
|
1141
|
+
return "Pending automatic continuation and queued input were cancelled.";
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
#reset(): string {
|
|
1145
|
+
this.#dependencies.cancelPendingActivity();
|
|
1146
|
+
this.#dependencies.state.clearAll();
|
|
1147
|
+
this.#dependencies.usage.clear();
|
|
1148
|
+
this.#disabledProviders.clear();
|
|
1149
|
+
return "Reset process-local cooldown, continuation, watchdog, usage, and disabled-account state.";
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async #reload(): Promise<string> {
|
|
1153
|
+
const config = await this.#dependencies.reloadGlobalConfig();
|
|
1154
|
+
await this.#dependencies.onConfigReload?.(config);
|
|
1155
|
+
return "Reloaded the machine-global multi-account configuration.";
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Interactive cross-family routing configuration.
|
|
1160
|
+
*
|
|
1161
|
+
* TUI-only by explicit `mode` check, not by `hasUI`: Pi reports `hasUI` true
|
|
1162
|
+
* in RPC as well, and an RPC caller opening a modal selector would hang.
|
|
1163
|
+
*/
|
|
1164
|
+
async #configure(session: CommandSession | undefined): Promise<string> {
|
|
1165
|
+
const routingConfig = this.#dependencies.routingConfig;
|
|
1166
|
+
if (routingConfig === undefined) {
|
|
1167
|
+
return "Cross-family routing configuration is unavailable in this build.";
|
|
1168
|
+
}
|
|
1169
|
+
if (
|
|
1170
|
+
session === undefined ||
|
|
1171
|
+
session.mode !== "tui" ||
|
|
1172
|
+
session.hasUI !== true
|
|
1173
|
+
) {
|
|
1174
|
+
return CONFIGURE_TUI_ONLY;
|
|
1175
|
+
}
|
|
1176
|
+
if (this.#configureInProgress) return CONFIGURE_IN_PROGRESS;
|
|
1177
|
+
this.#configureInProgress = true;
|
|
1178
|
+
try {
|
|
1179
|
+
return await this.#runConfigure(session, routingConfig);
|
|
1180
|
+
} finally {
|
|
1181
|
+
// Cleared on success, Esc, rejection, every commit outcome, abort, and
|
|
1182
|
+
// exceptions, so one bad flow cannot block the command permanently.
|
|
1183
|
+
this.#configureInProgress = false;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async #runConfigure(
|
|
1188
|
+
session: CommandSession,
|
|
1189
|
+
routingConfig: RoutingConfigSurface,
|
|
1190
|
+
): Promise<string> {
|
|
1191
|
+
const { ui } = session;
|
|
1192
|
+
const signal = session.signal;
|
|
1193
|
+
const dialogOptions: CommandDialogOptions =
|
|
1194
|
+
signal === undefined ? {} : { signal };
|
|
1195
|
+
const aborted = (): boolean => signal?.aborted === true;
|
|
1196
|
+
|
|
1197
|
+
const preAccounts = this.#accounts();
|
|
1198
|
+
const represented = new Set(preAccounts.map((account) => account.family));
|
|
1199
|
+
// Authoritative for replacement detection, confirmation, and the commit-time
|
|
1200
|
+
// drift comparison. The process-effective read is for display only.
|
|
1201
|
+
const persisted = routingProjection(routingConfig.readPersistedRouting());
|
|
1202
|
+
const effective = routingProjection(
|
|
1203
|
+
routingConfig.processEffectiveRouting(),
|
|
1204
|
+
);
|
|
1205
|
+
|
|
1206
|
+
const directions = ALLOWED_CROSS_FAMILY_PAIRS.filter(
|
|
1207
|
+
([from, to]) => represented.has(from) && represented.has(to),
|
|
1208
|
+
);
|
|
1209
|
+
if (directions.length === 0) {
|
|
1210
|
+
const missing = ALLOWED_FAMILIES.filter(
|
|
1211
|
+
(family) => !represented.has(family),
|
|
1212
|
+
);
|
|
1213
|
+
return rediscoverGuidance(missing.join(" and "));
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
const choice = await ui.select(
|
|
1217
|
+
"Cross-family routing",
|
|
1218
|
+
[
|
|
1219
|
+
CONFIGURE_VIEW_OPTION,
|
|
1220
|
+
...directions.map(([from, to]) =>
|
|
1221
|
+
crossFamilyDirectionOption(from, to),
|
|
1222
|
+
),
|
|
1223
|
+
],
|
|
1224
|
+
dialogOptions,
|
|
1225
|
+
);
|
|
1226
|
+
if (aborted() || choice === undefined) return CONFIGURE_CANCELLED;
|
|
1227
|
+
if (choice === CONFIGURE_VIEW_OPTION) {
|
|
1228
|
+
return renderRoutingView(persisted, effective);
|
|
1229
|
+
}
|
|
1230
|
+
const direction = directions.find(
|
|
1231
|
+
([from, to]) => crossFamilyDirectionOption(from, to) === choice,
|
|
1232
|
+
);
|
|
1233
|
+
if (direction === undefined) return CONFIGURE_CANCELLED;
|
|
1234
|
+
const [sourceFamily, destinationFamily] = direction;
|
|
1235
|
+
|
|
1236
|
+
const available = liveModelIds(preAccounts, destinationFamily);
|
|
1237
|
+
const selected = parseModelList(
|
|
1238
|
+
await ui.input(
|
|
1239
|
+
`Preferred ${destinationFamily} models, best first (comma-separated)`,
|
|
1240
|
+
`Available: ${available.join(", ")}`,
|
|
1241
|
+
dialogOptions,
|
|
1242
|
+
),
|
|
1243
|
+
available,
|
|
1244
|
+
);
|
|
1245
|
+
if (aborted() || selected.status === "cancelled") return CONFIGURE_CANCELLED;
|
|
1246
|
+
if (selected.status === "rejected") {
|
|
1247
|
+
return `Configure rejected that entry: ${selected.reason} Nothing was changed.`;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// Re-read accounts AFTER the dialog: an account can disappear while the
|
|
1251
|
+
// operator is typing, and every representation and coverage decision below
|
|
1252
|
+
// must use the later snapshot.
|
|
1253
|
+
const postAccounts = this.#accounts();
|
|
1254
|
+
const postRepresented = new Set(
|
|
1255
|
+
postAccounts.map((account) => account.family),
|
|
1256
|
+
);
|
|
1257
|
+
if (!postRepresented.has(destinationFamily)) {
|
|
1258
|
+
return rediscoverGuidance(destinationFamily);
|
|
1259
|
+
}
|
|
1260
|
+
const uncovered = accountsMissingPreference(
|
|
1261
|
+
postAccounts,
|
|
1262
|
+
destinationFamily,
|
|
1263
|
+
selected.models,
|
|
1264
|
+
);
|
|
1265
|
+
if (uncovered.length > 0) return coverageRejection(uncovered);
|
|
1266
|
+
|
|
1267
|
+
const selectedEdge: CrossFamilyChain = {
|
|
1268
|
+
from: sourceFamily,
|
|
1269
|
+
to: destinationFamily,
|
|
1270
|
+
};
|
|
1271
|
+
const preserved = normalizeCrossFamilyChains(persisted.crossFamilyChains);
|
|
1272
|
+
// Selecting an existing edge is a replacement, never an added duplicate.
|
|
1273
|
+
const edges = preserved.some(
|
|
1274
|
+
(edge) =>
|
|
1275
|
+
edge.from === selectedEdge.from && edge.to === selectedEdge.to,
|
|
1276
|
+
)
|
|
1277
|
+
? preserved
|
|
1278
|
+
: [...preserved, selectedEdge];
|
|
1279
|
+
const preferredModels: Record<string, readonly string[]> = {
|
|
1280
|
+
...persisted.preferredModels,
|
|
1281
|
+
[destinationFamily]: selected.models,
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
// Enabling the global flag activates every staged edge at once, so a second
|
|
1285
|
+
// destination family whose preference lacks coverage is repaired in the SAME
|
|
1286
|
+
// invocation rather than being activated uncovered.
|
|
1287
|
+
const collected = new Set<AllowedFamily>([destinationFamily]);
|
|
1288
|
+
for (const edge of edges) {
|
|
1289
|
+
for (const family of [edge.from, edge.to]) {
|
|
1290
|
+
if (!postRepresented.has(family)) return rediscoverGuidance(family);
|
|
1291
|
+
}
|
|
1292
|
+
if (collected.has(edge.to)) continue;
|
|
1293
|
+
collected.add(edge.to);
|
|
1294
|
+
if (
|
|
1295
|
+
accountsMissingPreference(
|
|
1296
|
+
postAccounts,
|
|
1297
|
+
edge.to,
|
|
1298
|
+
preferredModels[edge.to] ?? [],
|
|
1299
|
+
).length === 0
|
|
1300
|
+
) {
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
const repairAvailable = liveModelIds(postAccounts, edge.to);
|
|
1304
|
+
const repaired = parseModelList(
|
|
1305
|
+
await ui.input(
|
|
1306
|
+
`Preferred ${edge.to} models, best first (comma-separated)`,
|
|
1307
|
+
`Available: ${repairAvailable.join(", ")}`,
|
|
1308
|
+
dialogOptions,
|
|
1309
|
+
),
|
|
1310
|
+
repairAvailable,
|
|
1311
|
+
);
|
|
1312
|
+
if (aborted() || repaired.status === "cancelled") {
|
|
1313
|
+
return CONFIGURE_CANCELLED;
|
|
1314
|
+
}
|
|
1315
|
+
if (repaired.status === "rejected") {
|
|
1316
|
+
return `Configure rejected that entry: ${repaired.reason} Nothing was changed.`;
|
|
1317
|
+
}
|
|
1318
|
+
const stillUncovered = accountsMissingPreference(
|
|
1319
|
+
postAccounts,
|
|
1320
|
+
edge.to,
|
|
1321
|
+
repaired.models,
|
|
1322
|
+
);
|
|
1323
|
+
if (stillUncovered.length > 0) return coverageRejection(stillUncovered);
|
|
1324
|
+
preferredModels[edge.to] = repaired.models;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
// Defense in depth over the COMPLETE enabled candidate. The per-step guards
|
|
1328
|
+
// above are the intended owners; this pass exists so a hand-edited invalid
|
|
1329
|
+
// edge cannot be activated because one of them was bypassed.
|
|
1330
|
+
for (const edge of edges) {
|
|
1331
|
+
try {
|
|
1332
|
+
validateCrossChain(edge);
|
|
1333
|
+
} catch (error) {
|
|
1334
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1335
|
+
return `Configure refused the resulting policy: ${detail} Repair the machine-global config, then rerun configure.`;
|
|
1336
|
+
}
|
|
1337
|
+
if (!postRepresented.has(edge.from)) return rediscoverGuidance(edge.from);
|
|
1338
|
+
if (!postRepresented.has(edge.to)) return rediscoverGuidance(edge.to);
|
|
1339
|
+
const missing = accountsMissingPreference(
|
|
1340
|
+
postAccounts,
|
|
1341
|
+
edge.to,
|
|
1342
|
+
preferredModels[edge.to] ?? [],
|
|
1343
|
+
);
|
|
1344
|
+
if (missing.length > 0) return coverageRejection(missing);
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
const candidate: RoutingProjection = {
|
|
1348
|
+
crossFamilyChainEnabled: true,
|
|
1349
|
+
crossFamilyChains: edges,
|
|
1350
|
+
preferredModels,
|
|
1351
|
+
tierModelMap: persisted.tierModelMap,
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
const lines = [
|
|
1355
|
+
`Persisted starting policy: ${describeRoutingPolicy(persisted)}`,
|
|
1356
|
+
`Process-effective starting policy: ${describeRoutingPolicy(effective)}`,
|
|
1357
|
+
];
|
|
1358
|
+
if (!routingProjectionsEqual(persisted, effective)) {
|
|
1359
|
+
lines.push(ROUTING_DIVERGENCE_NOTE);
|
|
1360
|
+
}
|
|
1361
|
+
lines.push(`Persisted candidate: ${describeRoutingPolicy(candidate)}`);
|
|
1362
|
+
if (!persisted.crossFamilyChainEnabled) {
|
|
1363
|
+
lines.push("Enabling cross-family routing activates every staged edge:");
|
|
1364
|
+
for (const edge of edges) {
|
|
1365
|
+
lines.push(` - ${crossFamilyDirectionOption(edge.from, edge.to)}`);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
lines.push(
|
|
1369
|
+
`preferredModels is family-scoped: this ${destinationFamily} list also affects same-family fallback for that destination family.`,
|
|
1370
|
+
);
|
|
1371
|
+
const approved = await ui.confirm(
|
|
1372
|
+
"Apply cross-family routing configuration",
|
|
1373
|
+
lines.join("\n"),
|
|
1374
|
+
dialogOptions,
|
|
1375
|
+
);
|
|
1376
|
+
if (aborted() || !approved) return CONFIGURE_CANCELLED;
|
|
1377
|
+
|
|
1378
|
+
return renderCommitResult(
|
|
1379
|
+
await routingConfig.commit({
|
|
1380
|
+
promptSnapshot: persisted,
|
|
1381
|
+
candidate,
|
|
1382
|
+
...(signal === undefined ? {} : { signal }),
|
|
1383
|
+
}),
|
|
1384
|
+
candidate,
|
|
1385
|
+
destinationFamily,
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
#group(action: string | undefined, groupId: string | undefined): string {
|
|
1390
|
+
assertGroupCommand(action, groupId);
|
|
1391
|
+
const surface = this.#dependencies.accountGroups;
|
|
1392
|
+
if (surface === undefined) {
|
|
1393
|
+
throw new Error("Session account groups are unavailable in this build.");
|
|
1394
|
+
}
|
|
1395
|
+
if (action === "use") {
|
|
1396
|
+
if (groupId === undefined) {
|
|
1397
|
+
throw new TypeError(`Usage: /multi-account ${COMMAND_USAGE.group}`);
|
|
1398
|
+
}
|
|
1399
|
+
const status = surface.use(groupId);
|
|
1400
|
+
return `Session account group set to ${groupId}.\n${this.#renderAccountGroupStatus(status)}`;
|
|
1401
|
+
}
|
|
1402
|
+
if (action === "reset") {
|
|
1403
|
+
return `Session account group override reset.\n${this.#renderAccountGroupStatus(surface.reset())}`;
|
|
1404
|
+
}
|
|
1405
|
+
return this.#renderAccountGroupStatus(surface.status());
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
#renderAccountGroupStatus(status: AccountGroupCommandStatus): string {
|
|
1409
|
+
const { resolution } = status;
|
|
1410
|
+
const source = resolution.source.replaceAll("-", " ");
|
|
1411
|
+
const groupId =
|
|
1412
|
+
resolution.source === "unrestricted" ? "unrestricted" : resolution.groupId;
|
|
1413
|
+
const lines = [`Effective account group: ${groupId} (${source}).`];
|
|
1414
|
+
for (const member of status.members ?? []) {
|
|
1415
|
+
lines.push(
|
|
1416
|
+
`- ${member.providerId}: ${member.eligible ? "eligible" : "blocked"} (${member.reason})`,
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
return lines.join("\n");
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
#enable(): string {
|
|
1423
|
+
this.#reset();
|
|
1424
|
+
return "Re-enabled all managed accounts and reset process-local routing, continuation, watchdog, and usage state.";
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
#disable(familyValue: string | undefined): string {
|
|
1428
|
+
if (!familyValue || !isManagedFamily(familyValue)) {
|
|
1429
|
+
throw new TypeError(
|
|
1430
|
+
"disable requires family anthropic, openai-codex, google-antigravity, or openai.",
|
|
1431
|
+
);
|
|
1432
|
+
}
|
|
1433
|
+
for (const account of this.#accounts()) {
|
|
1434
|
+
if (account.family === familyValue)
|
|
1435
|
+
this.#disabledProviders.add(account.providerId);
|
|
1436
|
+
}
|
|
1437
|
+
return `Disabled ${familyValue} accounts in this process only.`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Yields `{ activeModelId }` only when the account is active AND the host
|
|
1442
|
+
* actually reported a model id, so no `undefined` value is ever constructed
|
|
1443
|
+
* under exactOptionalPropertyTypes.
|
|
1444
|
+
*/
|
|
1445
|
+
/**
|
|
1446
|
+
* Yields `{ [key]: value }` only when the value is present, so no `undefined`
|
|
1447
|
+
* member is ever constructed under exactOptionalPropertyTypes.
|
|
1448
|
+
*/
|
|
1449
|
+
#optionalField<K extends string, V>(
|
|
1450
|
+
key: K,
|
|
1451
|
+
value: V | undefined,
|
|
1452
|
+
): Record<K, V> | Record<string, never> {
|
|
1453
|
+
return value === undefined ? {} : ({ [key]: value } as Record<K, V>);
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
#activeModelIdField(isActive: boolean): { activeModelId?: string } {
|
|
1457
|
+
if (!isActive) return {};
|
|
1458
|
+
const modelId = this.#dependencies.activeModelId?.();
|
|
1459
|
+
return modelId === undefined ? {} : { activeModelId: modelId };
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
/**
|
|
1463
|
+
* Machine-readable account status for the agent-invocable tool.
|
|
1464
|
+
*
|
|
1465
|
+
* REQ-STATUS-TOOL. This delegates to the SAME `#status("--json")` path the
|
|
1466
|
+
* operator's `/multi-account status --json` renders, rather than assembling a
|
|
1467
|
+
* second view. Duplicating it would let the operator surface and the agent
|
|
1468
|
+
* surface drift, and the label rule below is exactly the kind of detail that
|
|
1469
|
+
* drifts first.
|
|
1470
|
+
*
|
|
1471
|
+
* Human-readable labels are decorated HERE, at render time, from live config
|
|
1472
|
+
* via `dependencies.accountLabel`. They are never read from the usage store:
|
|
1473
|
+
* the store holds observations keyed by canonical provider id, and a label
|
|
1474
|
+
* persisted alongside them would go stale the moment config changed, then be
|
|
1475
|
+
* reported as current.
|
|
1476
|
+
*/
|
|
1477
|
+
statusJson(): string {
|
|
1478
|
+
return this.#status("--json");
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
isDisabled(providerId: string): boolean {
|
|
1482
|
+
return this.#disabledProviders.has(providerId);
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
shutdown(): void {
|
|
1486
|
+
this.#dependencies.watchdog.cancelAll();
|
|
1487
|
+
this.#dependencies.continuation.cancelAll();
|
|
1488
|
+
this.#dependencies.usage.clear();
|
|
1489
|
+
this.#disabledProviders.clear();
|
|
1490
|
+
}
|
|
1491
|
+
|
|
1492
|
+
#now(): number {
|
|
1493
|
+
return (this.#dependencies.now ?? Date.now)();
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
#output(value: unknown): string {
|
|
1497
|
+
return this.#dependencies.diagnostics.sanitizeOutput(value);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
export { MANUAL_REMOVE_INSTRUCTIONS };
|
|
1502
|
+
|
|
1503
|
+
/*
|
|
1504
|
+
* ---------------------------------------------------------------------------
|
|
1505
|
+
* `/multi-account models install` and `/multi-account models update`
|
|
1506
|
+
* ---------------------------------------------------------------------------
|
|
1507
|
+
*
|
|
1508
|
+
* Writes the logical provider's declaration into the host's `models.json`.
|
|
1509
|
+
*
|
|
1510
|
+
* The file belongs to the operator, not to this extension. It may hold provider
|
|
1511
|
+
* entries this extension knows nothing about. The transaction owns the logical
|
|
1512
|
+
* provider declaration and `contextWindow` on offline-approved or independently
|
|
1513
|
+
* verified Codex overrides; it copies every other field through untouched.
|
|
1514
|
+
*/
|
|
1515
|
+
|
|
1516
|
+
/** Live physical catalogs, keyed by managed family. */
|
|
1517
|
+
export type ModelsCatalogs = ModelDeclarationCatalogs;
|
|
1518
|
+
|
|
1519
|
+
/** Reported just before the atomic replacement, while the target is still old. */
|
|
1520
|
+
export type ModelsAtomicRenameDetails = {
|
|
1521
|
+
readonly temporaryPath: string;
|
|
1522
|
+
/** The prior bytes, kept aside. Absent only when there was no target. */
|
|
1523
|
+
readonly rollbackPath?: string | undefined;
|
|
1524
|
+
};
|
|
1525
|
+
|
|
1526
|
+
export type ModelsLeaseHandle = { readonly release: () => void | Promise<void> };
|
|
1527
|
+
|
|
1528
|
+
export type ModelsCommandOptions = {
|
|
1529
|
+
readonly targetPath: string;
|
|
1530
|
+
readonly readCatalogs: () => ModelsCatalogs | Promise<ModelsCatalogs>;
|
|
1531
|
+
/** Synthetic evidence seam for tests; production reads exact official pages. */
|
|
1532
|
+
readonly fetchCodexModelDocumentation?: typeof fetch;
|
|
1533
|
+
readonly validateCandidate?: (candidate: unknown) => void | Promise<void>;
|
|
1534
|
+
readonly confirm: (diff: string) => boolean | Promise<boolean>;
|
|
1535
|
+
readonly acquireLease?: (
|
|
1536
|
+
targetPath: string,
|
|
1537
|
+
) => ModelsLeaseHandle | undefined | Promise<ModelsLeaseHandle | undefined>;
|
|
1538
|
+
readonly beforeAtomicRename?: (
|
|
1539
|
+
details: ModelsAtomicRenameDetails,
|
|
1540
|
+
) => void | Promise<void>;
|
|
1541
|
+
readonly afterAtomicRename?: (
|
|
1542
|
+
details: ModelsAtomicRenameDetails,
|
|
1543
|
+
) => void | Promise<void>;
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
export type ModelsFileShape = {
|
|
1547
|
+
providers?: Record<string, Record<string, unknown>>;
|
|
1548
|
+
};
|
|
1549
|
+
|
|
1550
|
+
/** What the operator sees when a transaction finishes or declines to act. */
|
|
1551
|
+
export type ModelsCommandResult = {
|
|
1552
|
+
readonly action: "install" | "update";
|
|
1553
|
+
readonly outcome: "committed" | "cancelled";
|
|
1554
|
+
readonly modelIds: readonly string[];
|
|
1555
|
+
readonly diagnostics: readonly string[];
|
|
1556
|
+
};
|
|
1557
|
+
|
|
1558
|
+
const MODELS_COMMAND_USAGE =
|
|
1559
|
+
"Usage: /multi-account models install|update";
|
|
1560
|
+
/** Previous provider identity, retained only for the models.json migration. */
|
|
1561
|
+
const LEGACY_LOGICAL_PROVIDER_ID = "pi-multi-account";
|
|
1562
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* Read the target, treating an absent file as an empty one.
|
|
1565
|
+
*
|
|
1566
|
+
* Unparseable bytes fail closed rather than being replaced. Overwriting a file
|
|
1567
|
+
* this extension cannot read would destroy provider entries belonging to
|
|
1568
|
+
* someone else, which is the one outcome this transaction exists to prevent.
|
|
1569
|
+
*/
|
|
1570
|
+
function readModelsTarget(targetPath: string): {
|
|
1571
|
+
parsed: ModelsFileShape;
|
|
1572
|
+
bytes: Buffer | undefined;
|
|
1573
|
+
} {
|
|
1574
|
+
if (!existsSync(targetPath)) return { parsed: {}, bytes: undefined };
|
|
1575
|
+
const bytes = readFileSync(targetPath);
|
|
1576
|
+
let parsed: unknown;
|
|
1577
|
+
try {
|
|
1578
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
1579
|
+
} catch {
|
|
1580
|
+
throw new Error(
|
|
1581
|
+
`The models file at ${targetPath} is not valid JSON, so it will not be replaced.`,
|
|
1582
|
+
);
|
|
1583
|
+
}
|
|
1584
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
1585
|
+
throw new Error(
|
|
1586
|
+
`The models file at ${targetPath} is not a JSON object, so it will not be replaced.`,
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
return { parsed: parsed as ModelsFileShape, bytes };
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* The default candidate check: structural, hermetic and always run.
|
|
1594
|
+
*
|
|
1595
|
+
* It confirms the bytes about to be written parse back to the declaration this
|
|
1596
|
+
* transaction intended — the managed entry present and correctly identified,
|
|
1597
|
+
* every row carrying an id, and no forbidden field. Driving a real host to
|
|
1598
|
+
* confirm the declaration actually loads is a live-integration concern and is
|
|
1599
|
+
* out of this node's scope; `validateCandidate` is the seam for it.
|
|
1600
|
+
*/
|
|
1601
|
+
function validateCandidateStructure(candidate: unknown): void {
|
|
1602
|
+
const providers =
|
|
1603
|
+
typeof candidate === "object" && candidate !== null
|
|
1604
|
+
? (candidate as ModelsFileShape).providers
|
|
1605
|
+
: undefined;
|
|
1606
|
+
const declaration = providers?.[LOGICAL_PROVIDER_ID];
|
|
1607
|
+
if (declaration === undefined) {
|
|
1608
|
+
throw new Error("the candidate declaration is missing the managed provider");
|
|
1609
|
+
}
|
|
1610
|
+
if (declaration.api !== LOGICAL_PROVIDER_ID) {
|
|
1611
|
+
throw new Error("the candidate declaration has the wrong api id");
|
|
1612
|
+
}
|
|
1613
|
+
// The host needs both of these to compose the provider at all, and deletes
|
|
1614
|
+
// it when composition fails. A candidate missing either is a file that
|
|
1615
|
+
// would load as nothing.
|
|
1616
|
+
if (declaration.name !== LOGICAL_PROVIDER_DISPLAY_NAME) {
|
|
1617
|
+
throw new Error("the candidate declaration has no usable provider name");
|
|
1618
|
+
}
|
|
1619
|
+
if (typeof declaration.apiKey !== "string" || declaration.apiKey === "") {
|
|
1620
|
+
throw new Error(
|
|
1621
|
+
"the candidate declaration has no authentication method, so the host would discard it",
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
if (declaration.baseUrl !== DECLARATION_BASE_URL) {
|
|
1625
|
+
throw new Error("the candidate declaration has the wrong baseUrl");
|
|
1626
|
+
}
|
|
1627
|
+
if ("oauth" in declaration) {
|
|
1628
|
+
throw new Error("the candidate declaration carries a forbidden oauth field");
|
|
1629
|
+
}
|
|
1630
|
+
const models = declaration.models;
|
|
1631
|
+
if (!Array.isArray(models)) {
|
|
1632
|
+
throw new Error("the candidate declaration has no model rows");
|
|
1633
|
+
}
|
|
1634
|
+
assertProjectedManagedModels(models);
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
const MODELS_DIFF_ID_LIST_LIMIT = 20;
|
|
1638
|
+
const MODEL_ID_SINGLE_LINE_CONTROLS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/gu;
|
|
1639
|
+
|
|
1640
|
+
function managedRowId(row: unknown): string | undefined {
|
|
1641
|
+
try {
|
|
1642
|
+
const id = (row as { readonly id?: unknown } | null | undefined)?.id;
|
|
1643
|
+
return typeof id === "string" ? id : undefined;
|
|
1644
|
+
} catch {
|
|
1645
|
+
return undefined;
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/** Canonical content used only to decide whether a row changed. */
|
|
1650
|
+
function renderManagedRow(row: unknown): string | undefined {
|
|
1651
|
+
try {
|
|
1652
|
+
return JSON.stringify(assertProjectedManagedModel(row));
|
|
1653
|
+
} catch {
|
|
1654
|
+
return undefined;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
function renderModelId(id: string): string {
|
|
1659
|
+
return id.replace(MODEL_ID_SINGLE_LINE_CONTROLS, (character) => {
|
|
1660
|
+
switch (character) {
|
|
1661
|
+
case "\b":
|
|
1662
|
+
return "\\b";
|
|
1663
|
+
case "\t":
|
|
1664
|
+
return "\\t";
|
|
1665
|
+
case "\n":
|
|
1666
|
+
return "\\n";
|
|
1667
|
+
case "\f":
|
|
1668
|
+
return "\\f";
|
|
1669
|
+
case "\r":
|
|
1670
|
+
return "\\r";
|
|
1671
|
+
default:
|
|
1672
|
+
return `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
function renderModelIds(ids: readonly string[]): string {
|
|
1678
|
+
const sorted = [...ids].sort();
|
|
1679
|
+
const visible = sorted.slice(0, MODELS_DIFF_ID_LIST_LIMIT).map(renderModelId);
|
|
1680
|
+
const overflow = sorted.length - visible.length;
|
|
1681
|
+
return `${visible.join(", ")}${overflow > 0 ? ` (+${overflow} more)` : ""}`;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function renderCodexModelDefaultChanges(
|
|
1685
|
+
changes: readonly CodexModelDefaultChange[],
|
|
1686
|
+
): string[] {
|
|
1687
|
+
if (changes.length === 0) {
|
|
1688
|
+
return [" openai-codex modelOverrides: no contextWindow changes"];
|
|
1689
|
+
}
|
|
1690
|
+
const lines = [" openai-codex modelOverrides:"];
|
|
1691
|
+
for (const change of changes) {
|
|
1692
|
+
if (change.contextWindow === undefined) {
|
|
1693
|
+
lines.push(
|
|
1694
|
+
` - ${change.modelId}.contextWindow (was ${change.previousContextWindow})`,
|
|
1695
|
+
);
|
|
1696
|
+
continue;
|
|
1697
|
+
}
|
|
1698
|
+
if (!change.hadOverride) {
|
|
1699
|
+
lines.push(
|
|
1700
|
+
` + ${change.modelId}.contextWindow = ${change.contextWindow}`,
|
|
1701
|
+
);
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
lines.push(
|
|
1705
|
+
` ~ ${change.modelId}.contextWindow: ${change.previousContextWindow ?? "<unset>"} -> ${change.contextWindow}`,
|
|
1706
|
+
);
|
|
1707
|
+
}
|
|
1708
|
+
return lines;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
function renderModelsDiff(
|
|
1712
|
+
targetPath: string,
|
|
1713
|
+
action: "install" | "update",
|
|
1714
|
+
previous: readonly unknown[] | undefined,
|
|
1715
|
+
next: readonly ModelDeclarationRow[],
|
|
1716
|
+
diagnostics: readonly string[],
|
|
1717
|
+
codexChanges: readonly CodexModelDefaultChange[],
|
|
1718
|
+
): string {
|
|
1719
|
+
type PreviousRowKey = string | symbol;
|
|
1720
|
+
interface PreviousRowEntry {
|
|
1721
|
+
readonly displayId: string;
|
|
1722
|
+
readonly row: unknown;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
const previousById = new Map<PreviousRowKey, PreviousRowEntry>();
|
|
1726
|
+
for (const row of previous ?? []) {
|
|
1727
|
+
const id = managedRowId(row);
|
|
1728
|
+
previousById.set(id ?? Symbol("invalid previous model id"), {
|
|
1729
|
+
displayId: id ?? "<unknown>",
|
|
1730
|
+
row,
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
const nextById = new Map<string, ModelDeclarationRow>();
|
|
1734
|
+
for (const row of next) nextById.set(row.id, row);
|
|
1735
|
+
|
|
1736
|
+
const added = [...nextById.keys()].filter((id) => !previousById.has(id));
|
|
1737
|
+
const removed = [...previousById]
|
|
1738
|
+
.filter(([id]) => typeof id !== "string" || !nextById.has(id))
|
|
1739
|
+
.map(([, entry]) => entry.displayId);
|
|
1740
|
+
const changed: string[] = [];
|
|
1741
|
+
const unchanged: string[] = [];
|
|
1742
|
+
for (const [id, after] of nextById) {
|
|
1743
|
+
if (!previousById.has(id)) continue;
|
|
1744
|
+
const renderedBefore = renderManagedRow(previousById.get(id)?.row);
|
|
1745
|
+
const renderedAfter = renderManagedRow(after);
|
|
1746
|
+
if (
|
|
1747
|
+
renderedBefore !== undefined &&
|
|
1748
|
+
renderedAfter !== undefined &&
|
|
1749
|
+
renderedBefore === renderedAfter
|
|
1750
|
+
) {
|
|
1751
|
+
unchanged.push(id);
|
|
1752
|
+
} else {
|
|
1753
|
+
changed.push(id);
|
|
1754
|
+
}
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const lines = [
|
|
1758
|
+
`${action} ${LOGICAL_PROVIDER_ID} in ${targetPath}`,
|
|
1759
|
+
` ${next.length} models total: ${added.length} added, ${removed.length} removed, ${changed.length} changed, ${unchanged.length} unchanged`,
|
|
1760
|
+
];
|
|
1761
|
+
if (added.length > 0) lines.push(` + added: ${renderModelIds(added)}`);
|
|
1762
|
+
if (removed.length > 0) lines.push(` - removed: ${renderModelIds(removed)}`);
|
|
1763
|
+
if (changed.length > 0) lines.push(` ~ changed: ${renderModelIds(changed)}`);
|
|
1764
|
+
lines.push(...renderCodexModelDefaultChanges(codexChanges));
|
|
1765
|
+
for (const note of diagnostics) lines.push(` note: ${note}`);
|
|
1766
|
+
return lines.join("\n");
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/**
|
|
1770
|
+
* Install or update the logical provider's declaration.
|
|
1771
|
+
*
|
|
1772
|
+
* The order of operations is the contract. Syntax and declaration state are
|
|
1773
|
+
* settled before the live catalogs are read, so a mistyped command never
|
|
1774
|
+
* reaches the network. The candidate is validated and confirmed before the
|
|
1775
|
+
* lease is taken, so a rejected write never blocks another writer. The target
|
|
1776
|
+
* is re-read after the lease is held, so a change made by someone else between
|
|
1777
|
+
* the first read and the write aborts instead of being overwritten.
|
|
1778
|
+
*/
|
|
1779
|
+
export async function executeModelsCommand(
|
|
1780
|
+
rawArguments: string,
|
|
1781
|
+
options: ModelsCommandOptions,
|
|
1782
|
+
): Promise<ModelsCommandResult> {
|
|
1783
|
+
const tokens = rawArguments.trim().split(/\s+/u).filter((t) => t.length > 0);
|
|
1784
|
+
const [head, action, ...rest] = tokens;
|
|
1785
|
+
if (head !== "models" || rest.length > 0) throw new TypeError(MODELS_COMMAND_USAGE);
|
|
1786
|
+
if (action !== "install" && action !== "update") {
|
|
1787
|
+
throw new TypeError(MODELS_COMMAND_USAGE);
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
const { targetPath } = options;
|
|
1791
|
+
const initial = readModelsTarget(targetPath);
|
|
1792
|
+
const existingDeclaration = initial.parsed.providers?.[LOGICAL_PROVIDER_ID];
|
|
1793
|
+
const legacyDeclaration =
|
|
1794
|
+
initial.parsed.providers?.[LEGACY_LOGICAL_PROVIDER_ID];
|
|
1795
|
+
const hasManagedLegacyDeclaration =
|
|
1796
|
+
legacyDeclaration?.api === LEGACY_LOGICAL_PROVIDER_ID &&
|
|
1797
|
+
legacyDeclaration.baseUrl === DECLARATION_BASE_URL;
|
|
1798
|
+
|
|
1799
|
+
// Declaration state is checked before anything is read from the providers.
|
|
1800
|
+
if (action === "install" && existingDeclaration !== undefined) {
|
|
1801
|
+
throw new Error(
|
|
1802
|
+
`A managed declaration already exists in ${targetPath}. Run /multi-account models update to refresh it.`,
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
if (
|
|
1806
|
+
action === "update" &&
|
|
1807
|
+
existingDeclaration === undefined &&
|
|
1808
|
+
!hasManagedLegacyDeclaration
|
|
1809
|
+
) {
|
|
1810
|
+
throw new Error(
|
|
1811
|
+
`No managed declaration is present in ${targetPath}. Run /multi-account models install first.`,
|
|
1812
|
+
);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
const previousDeclaration =
|
|
1816
|
+
existingDeclaration ??
|
|
1817
|
+
(action === "update" && hasManagedLegacyDeclaration
|
|
1818
|
+
? legacyDeclaration
|
|
1819
|
+
: undefined);
|
|
1820
|
+
const previousRows = Array.isArray(previousDeclaration?.models)
|
|
1821
|
+
? previousDeclaration.models
|
|
1822
|
+
: undefined;
|
|
1823
|
+
|
|
1824
|
+
// Validate operator-owned Codex overrides before consulting live providers.
|
|
1825
|
+
// A malformed override survives byte-for-byte because no catalog or official
|
|
1826
|
+
// documentation is read and no candidate is built.
|
|
1827
|
+
planCodexModelDefaults(initial.parsed.providers);
|
|
1828
|
+
|
|
1829
|
+
// Fail closed: an unreadable catalog produces no write or documentation
|
|
1830
|
+
// request. Validate a projection before deriving any network candidate IDs;
|
|
1831
|
+
// the final projection below uses the independently resolved map.
|
|
1832
|
+
const catalogs = await options.readCatalogs();
|
|
1833
|
+
buildModelDeclarationWithCodexDefaults(catalogs, new Map<string, number>());
|
|
1834
|
+
const resolvedCodexDefaults = await resolveCodexLongContextDefaults(
|
|
1835
|
+
catalogs["openai-codex"],
|
|
1836
|
+
{
|
|
1837
|
+
...(options.fetchCodexModelDocumentation === undefined
|
|
1838
|
+
? {}
|
|
1839
|
+
: { fetchDocumentation: options.fetchCodexModelDocumentation }),
|
|
1840
|
+
},
|
|
1841
|
+
);
|
|
1842
|
+
const codexDefaults = planCodexModelDefaults(
|
|
1843
|
+
initial.parsed.providers,
|
|
1844
|
+
resolvedCodexDefaults.contextWindowByModelId,
|
|
1845
|
+
resolvedCodexDefaults.liveCompleteOfflineModelIds,
|
|
1846
|
+
);
|
|
1847
|
+
const built = buildModelDeclarationWithCodexDefaults(
|
|
1848
|
+
catalogs,
|
|
1849
|
+
codexDefaults.contextWindowByModelId,
|
|
1850
|
+
);
|
|
1851
|
+
const diagnostics = [...resolvedCodexDefaults.diagnostics, ...built.diagnostics];
|
|
1852
|
+
const modelIds = built.models.map((row) => String(row.id));
|
|
1853
|
+
|
|
1854
|
+
const candidateProviders = { ...codexDefaults.providers };
|
|
1855
|
+
// Remove only the declaration shape this extension used before the ID rename.
|
|
1856
|
+
if (hasManagedLegacyDeclaration) {
|
|
1857
|
+
delete candidateProviders[LEGACY_LOGICAL_PROVIDER_ID];
|
|
1858
|
+
}
|
|
1859
|
+
const candidate: ModelsFileShape = {
|
|
1860
|
+
...initial.parsed,
|
|
1861
|
+
providers: {
|
|
1862
|
+
...candidateProviders,
|
|
1863
|
+
[LOGICAL_PROVIDER_ID]: {
|
|
1864
|
+
name: LOGICAL_PROVIDER_DISPLAY_NAME,
|
|
1865
|
+
api: LOGICAL_PROVIDER_ID,
|
|
1866
|
+
baseUrl: DECLARATION_BASE_URL,
|
|
1867
|
+
// Pi's provider composer requires an authentication method, and it
|
|
1868
|
+
// DELETES a provider whose entry fails to compose. Without this the
|
|
1869
|
+
// installed declaration would be discarded in silence and the
|
|
1870
|
+
// operator would simply never see the models they installed.
|
|
1871
|
+
//
|
|
1872
|
+
// The value is a fixed placeholder, not a credential. This provider
|
|
1873
|
+
// is never dialed: every request is dispatched through a physical
|
|
1874
|
+
// account that authenticates itself, and the baseUrl is a reserved
|
|
1875
|
+
// address that resolves nowhere.
|
|
1876
|
+
apiKey: DECLARATION_PLACEHOLDER_KEY,
|
|
1877
|
+
models: built.models,
|
|
1878
|
+
},
|
|
1879
|
+
},
|
|
1880
|
+
};
|
|
1881
|
+
|
|
1882
|
+
await (options.validateCandidate ?? validateCandidateStructure)(candidate);
|
|
1883
|
+
|
|
1884
|
+
const diff = renderModelsDiff(
|
|
1885
|
+
targetPath,
|
|
1886
|
+
action,
|
|
1887
|
+
previousRows,
|
|
1888
|
+
built.models,
|
|
1889
|
+
diagnostics,
|
|
1890
|
+
codexDefaults.changes,
|
|
1891
|
+
);
|
|
1892
|
+
if (!(await options.confirm(diff))) {
|
|
1893
|
+
return {
|
|
1894
|
+
action,
|
|
1895
|
+
outcome: "cancelled",
|
|
1896
|
+
modelIds,
|
|
1897
|
+
diagnostics,
|
|
1898
|
+
};
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// The lock sits beside the file it protects. A machine-global lock would
|
|
1902
|
+
// serialize writers of unrelated models files against each other, and would
|
|
1903
|
+
// write real machine state on behalf of a caller working in a sandbox.
|
|
1904
|
+
const acquire =
|
|
1905
|
+
options.acquireLease ??
|
|
1906
|
+
((path: string) => acquireMachineLease({ lockPath: `${path}.lock` }));
|
|
1907
|
+
const lease = await acquire(targetPath);
|
|
1908
|
+
if (lease === undefined) {
|
|
1909
|
+
throw new Error(
|
|
1910
|
+
`Another process is writing ${targetPath}. Try again in a moment.`,
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
try {
|
|
1915
|
+
const current = readModelsTarget(targetPath);
|
|
1916
|
+
const drifted =
|
|
1917
|
+
initial.bytes === undefined
|
|
1918
|
+
? current.bytes !== undefined
|
|
1919
|
+
: current.bytes === undefined || !current.bytes.equals(initial.bytes);
|
|
1920
|
+
if (drifted) {
|
|
1921
|
+
throw new Error(
|
|
1922
|
+
`${targetPath} changed while this command was preparing its write, so nothing was written.`,
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
await commitModelsCandidate(targetPath, candidate, options);
|
|
1926
|
+
return {
|
|
1927
|
+
action,
|
|
1928
|
+
outcome: "committed",
|
|
1929
|
+
modelIds,
|
|
1930
|
+
diagnostics,
|
|
1931
|
+
};
|
|
1932
|
+
} finally {
|
|
1933
|
+
await lease.release();
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Replace the target atomically, mirroring `writeConfig`.
|
|
1939
|
+
*
|
|
1940
|
+
* The candidate is written to an owner-only temporary file in the target's own
|
|
1941
|
+
* directory, so the rename is a same-filesystem atomic swap and no reader ever
|
|
1942
|
+
* observes a half-written file. When a target already exists its bytes are
|
|
1943
|
+
* copied aside first: if anything fails after that point the prior file can be
|
|
1944
|
+
* put back exactly as it was. Both scratch files are removed on every path,
|
|
1945
|
+
* because a leftover temporary is itself a partial write someone will find.
|
|
1946
|
+
*/
|
|
1947
|
+
async function commitModelsCandidate(
|
|
1948
|
+
targetPath: string,
|
|
1949
|
+
candidate: ModelsFileShape,
|
|
1950
|
+
options: ModelsCommandOptions,
|
|
1951
|
+
): Promise<void> {
|
|
1952
|
+
const directory = dirname(targetPath);
|
|
1953
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
1954
|
+
const stem = `${basename(targetPath)}.${process.pid}.${randomUUID()}`;
|
|
1955
|
+
const temporaryPath = join(directory, `.${stem}.tmp`);
|
|
1956
|
+
const rollbackPath = existsSync(targetPath)
|
|
1957
|
+
? join(directory, `.${stem}.rollback`)
|
|
1958
|
+
: undefined;
|
|
1959
|
+
|
|
1960
|
+
let descriptor: number | undefined;
|
|
1961
|
+
let replaced = false;
|
|
1962
|
+
try {
|
|
1963
|
+
descriptor = openSync(temporaryPath, "wx", 0o600);
|
|
1964
|
+
writeFileSync(descriptor, `${JSON.stringify(candidate, null, "\t")}\n`, {
|
|
1965
|
+
encoding: "utf-8",
|
|
1966
|
+
});
|
|
1967
|
+
fsyncSync(descriptor);
|
|
1968
|
+
closeSync(descriptor);
|
|
1969
|
+
descriptor = undefined;
|
|
1970
|
+
chmodSync(temporaryPath, 0o600);
|
|
1971
|
+
|
|
1972
|
+
if (rollbackPath !== undefined) {
|
|
1973
|
+
copyFileSync(targetPath, rollbackPath);
|
|
1974
|
+
chmodSync(rollbackPath, 0o600);
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
await options.beforeAtomicRename?.({ temporaryPath, rollbackPath });
|
|
1978
|
+
|
|
1979
|
+
renameSync(temporaryPath, targetPath);
|
|
1980
|
+
replaced = true;
|
|
1981
|
+
await options.afterAtomicRename?.({ temporaryPath, rollbackPath });
|
|
1982
|
+
chmodSync(targetPath, 0o600);
|
|
1983
|
+
} catch (error) {
|
|
1984
|
+
if (descriptor !== undefined) closeSync(descriptor);
|
|
1985
|
+
// The rename either happened or it did not. When it did not, the target
|
|
1986
|
+
// was never touched, so putting the rollback copy back would be a write
|
|
1987
|
+
// where none occurred. Restore only after a failure past the swap.
|
|
1988
|
+
if (replaced && rollbackPath !== undefined && existsSync(rollbackPath)) {
|
|
1989
|
+
copyFileSync(rollbackPath, targetPath);
|
|
1990
|
+
} else if (replaced && rollbackPath === undefined && existsSync(targetPath)) {
|
|
1991
|
+
unlinkSync(targetPath);
|
|
1992
|
+
}
|
|
1993
|
+
throw error;
|
|
1994
|
+
} finally {
|
|
1995
|
+
if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
|
1996
|
+
if (rollbackPath !== undefined && existsSync(rollbackPath)) {
|
|
1997
|
+
unlinkSync(rollbackPath);
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
}
|