@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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,254 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ Container,
5
+ getKeybindings,
6
+ Input,
7
+ matchesKey,
8
+ fuzzyMatch,
9
+ type Component,
10
+ type Focusable,
11
+ type KeybindingsManager,
12
+ type TUI,
13
+ Text,
14
+ } from "@earendil-works/pi-tui";
15
+ import type {
16
+ LogicalModelCandidate,
17
+ LogicalModelSelector,
18
+ } from "./logical-model-switcher.js";
19
+ import { LOGICAL_PROVIDER_ID } from "./models-declaration.js";
20
+
21
+ function sameModel(left: Model<Api> | undefined, right: Model<Api>): boolean {
22
+ return left?.provider === right.provider && left.id === right.id;
23
+ }
24
+
25
+ /**
26
+ * Public-TUI searchable component for the logical model command. It receives a
27
+ * pre-filtered candidate set and never resolves models or reads the registry.
28
+ */
29
+ export class LogicalModelSelectorComponent
30
+ extends Container
31
+ implements Focusable
32
+ {
33
+ private readonly tui: TUI;
34
+ private readonly candidates: readonly LogicalModelCandidate[];
35
+ private readonly searchInput: Input;
36
+ private readonly list: Container;
37
+ private readonly keybindings: KeybindingsManager;
38
+ private visibleCandidates: readonly LogicalModelCandidate[];
39
+ private selectedIndex: number;
40
+ private closed = false;
41
+ private focusedState = false;
42
+ private readonly onSelect: (candidate: LogicalModelCandidate) => void;
43
+ private readonly onCancel: () => void;
44
+
45
+ constructor(
46
+ tui: TUI,
47
+ candidates: readonly LogicalModelCandidate[],
48
+ currentModel: Model<Api> | undefined,
49
+ onSelect: (candidate: LogicalModelCandidate) => void,
50
+ onCancel: () => void,
51
+ keybindings: KeybindingsManager = getKeybindings(),
52
+ ) {
53
+ super();
54
+ this.tui = tui;
55
+ this.candidates = candidates;
56
+ this.visibleCandidates = candidates;
57
+ this.keybindings = keybindings;
58
+ this.onSelect = onSelect;
59
+ this.onCancel = onCancel;
60
+ const currentIndex = candidates.findIndex(({ model }) =>
61
+ sameModel(currentModel, model),
62
+ );
63
+ this.selectedIndex = currentIndex >= 0 ? currentIndex : 0;
64
+
65
+ this.addChild(new Text("Select a unified model", 0, 0));
66
+ this.searchInput = new Input();
67
+ this.addChild(this.searchInput);
68
+ this.list = new Container();
69
+ this.addChild(this.list);
70
+ this.updateList();
71
+ this.tui.requestRender();
72
+ }
73
+
74
+ get focused(): boolean {
75
+ return this.focusedState;
76
+ }
77
+
78
+ set focused(value: boolean) {
79
+ this.focusedState = value;
80
+ this.searchInput.focused = value;
81
+ }
82
+
83
+ getVisibleCandidates(): readonly LogicalModelCandidate[] {
84
+ return this.visibleCandidates;
85
+ }
86
+
87
+ getSelectedIndex(): number {
88
+ return this.selectedIndex;
89
+ }
90
+
91
+ getSearchInput(): Input {
92
+ return this.searchInput;
93
+ }
94
+
95
+ override invalidate(): void {
96
+ super.invalidate();
97
+ this.searchInput.invalidate();
98
+ }
99
+
100
+ handleInput(data: string): void {
101
+ if (this.closed) return;
102
+ if (this.keyMatches(data, "tui.select.up")) {
103
+ this.move(-1);
104
+ return;
105
+ }
106
+ if (this.keyMatches(data, "tui.select.down")) {
107
+ this.move(1);
108
+ return;
109
+ }
110
+ if (this.keyMatches(data, "tui.select.confirm")) {
111
+ this.confirm();
112
+ return;
113
+ }
114
+ if (this.keyMatches(data, "tui.select.cancel")) {
115
+ this.cancel();
116
+ return;
117
+ }
118
+
119
+ this.searchInput.handleInput(data);
120
+ this.filter(this.searchInput.getValue());
121
+ this.tui.requestRender();
122
+ }
123
+
124
+ private keyMatches(
125
+ data: string,
126
+ keybinding: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel",
127
+ ): boolean {
128
+ return this.keybindings.matches(data, keybinding) &&
129
+ this.keybindings.getKeys(keybinding).some((key) => matchesKey(data, key));
130
+ }
131
+
132
+ private move(delta: number): void {
133
+ if (this.visibleCandidates.length === 0) return;
134
+ const length = this.visibleCandidates.length;
135
+ this.selectedIndex = (this.selectedIndex + delta + length) % length;
136
+ this.updateList();
137
+ this.tui.requestRender();
138
+ }
139
+
140
+ private filter(query: string): void {
141
+ if (query.length === 0) {
142
+ this.visibleCandidates = this.candidates;
143
+ this.selectedIndex = Math.min(
144
+ this.selectedIndex,
145
+ Math.max(0, this.visibleCandidates.length - 1),
146
+ );
147
+ } else {
148
+ this.visibleCandidates = this.candidates.filter((candidate) =>
149
+ fuzzyMatch(
150
+ query,
151
+ `unified pi-multi-account ${LOGICAL_PROVIDER_ID}/${candidate.id} ${candidate.id} ${candidate.name}`,
152
+ ).matches,
153
+ );
154
+ this.selectedIndex = 0;
155
+ }
156
+ this.updateList();
157
+ }
158
+
159
+ private updateList(): void {
160
+ this.list.clear();
161
+ if (this.visibleCandidates.length === 0) {
162
+ this.list.addChild(new Text("No matching unified models", 0, 0));
163
+ return;
164
+ }
165
+ const maxVisible = 10;
166
+ const startIndex = Math.max(
167
+ 0,
168
+ Math.min(
169
+ this.selectedIndex - Math.floor(maxVisible / 2),
170
+ this.visibleCandidates.length - maxVisible,
171
+ ),
172
+ );
173
+ const endIndex = Math.min(
174
+ startIndex + maxVisible,
175
+ this.visibleCandidates.length,
176
+ );
177
+
178
+ for (let index = startIndex; index < endIndex; index += 1) {
179
+ const candidate = this.visibleCandidates[index];
180
+ if (candidate === undefined) continue;
181
+ const marker = index === this.selectedIndex ? "→ " : " ";
182
+ this.list.addChild(
183
+ new Text(`${marker}${candidate.id} [unified]`, 0, 0),
184
+ );
185
+ }
186
+
187
+ if (startIndex > 0 || endIndex < this.visibleCandidates.length) {
188
+ this.list.addChild(
189
+ new Text(
190
+ ` (${this.selectedIndex + 1}/${this.visibleCandidates.length})`,
191
+ 0,
192
+ 0,
193
+ ),
194
+ );
195
+ }
196
+ }
197
+
198
+ private confirm(): void {
199
+ const candidate = this.visibleCandidates[this.selectedIndex];
200
+ if (candidate === undefined) return;
201
+ this.closed = true;
202
+ this.onSelect(candidate);
203
+ }
204
+
205
+ public cancel(): void {
206
+ if (this.closed) return;
207
+ this.closed = true;
208
+ this.onCancel();
209
+ }
210
+ }
211
+
212
+ /** Adapter that exposes the component through the public extension UI seam. */
213
+ export class LogicalModelSelectorAdapter implements LogicalModelSelector {
214
+ async select(input: {
215
+ candidates: readonly LogicalModelCandidate[];
216
+ currentModel: Model<Api> | undefined;
217
+ ui: ExtensionUIContext;
218
+ signal?: AbortSignal;
219
+ }): Promise<LogicalModelCandidate | undefined> {
220
+ let removeAbortListener = (): void => {};
221
+ const finish = (done: (candidate: LogicalModelCandidate | undefined) => void) =>
222
+ (candidate: LogicalModelCandidate | undefined): void => {
223
+ removeAbortListener();
224
+ done(candidate);
225
+ };
226
+ return input.ui.custom<LogicalModelCandidate | undefined>(
227
+ (tui, _theme, keybindings, done): Component => {
228
+ const complete = finish(done);
229
+ const component = new LogicalModelSelectorComponent(
230
+ tui,
231
+ input.candidates,
232
+ input.currentModel,
233
+ complete,
234
+ () => complete(undefined),
235
+ keybindings,
236
+ );
237
+ if (input.signal !== undefined) {
238
+ const onAbort = (): void => component.cancel();
239
+ input.signal.addEventListener("abort", onAbort, { once: true });
240
+ removeAbortListener = () => {
241
+ input.signal?.removeEventListener("abort", onAbort);
242
+ removeAbortListener = () => {};
243
+ };
244
+ if (input.signal.aborted) onAbort();
245
+ }
246
+ return component;
247
+ },
248
+ );
249
+ }
250
+ }
251
+
252
+ export function createLogicalModelSelector(): LogicalModelSelector {
253
+ return new LogicalModelSelectorAdapter();
254
+ }
@@ -0,0 +1,430 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
3
+ import { LOGICAL_PROVIDER_ID } from "./models-declaration.js";
4
+
5
+ export type LogicalModelCandidate = Readonly<{
6
+ id: string;
7
+ name: string;
8
+ model: Model<Api>;
9
+ }>;
10
+
11
+ export type LogicalModelInventory =
12
+ | {
13
+ status: "ready";
14
+ source: "session-scope" | "available-catalog";
15
+ candidates: readonly LogicalModelCandidate[];
16
+ }
17
+ | { status: "absent" }
18
+ | {
19
+ status: "unusable";
20
+ reason:
21
+ | "provider-not-registered"
22
+ | "provider-api-mismatch"
23
+ | "model-api-mismatch"
24
+ | "duplicate-model-id";
25
+ }
26
+ | { status: "out-of-scope" }
27
+ | { status: "unavailable" };
28
+
29
+ export type LogicalModelReference =
30
+ | { status: "resolved"; candidate: LogicalModelCandidate }
31
+ | {
32
+ status: "invalid-input";
33
+ reason: "empty" | "control-character" | "too-long";
34
+ }
35
+ | { status: "foreign-provider" }
36
+ | { status: "ambiguous" }
37
+ | { status: "unknown-model" }
38
+ | { status: "out-of-scope"; modelId: string }
39
+ | { status: "unavailable"; modelId: string };
40
+
41
+ export interface LogicalModelSelector {
42
+ select(input: {
43
+ candidates: readonly LogicalModelCandidate[];
44
+ currentModel: Model<Api> | undefined;
45
+ ui: ExtensionUIContext;
46
+ signal?: AbortSignal;
47
+ }): Promise<LogicalModelCandidate | undefined>;
48
+ }
49
+
50
+ export type LogicalModelRejectionReason =
51
+ | Exclude<LogicalModelInventory["status"], "ready">
52
+ | Exclude<LogicalModelReference["status"], "resolved">
53
+ | "tui-required"
54
+ | "host-refused";
55
+
56
+ export type LogicalModelSwitchResult =
57
+ | { status: "selected"; modelId: string }
58
+ | { status: "cancelled" }
59
+ | {
60
+ status: "rejected";
61
+ reason: LogicalModelRejectionReason;
62
+ modelId?: string;
63
+ };
64
+
65
+ export interface LogicalModelRegistry {
66
+ getAll(): readonly Model<Api>[];
67
+ getAvailable(): readonly Model<Api>[];
68
+ getRegisteredProviderConfig(providerId: string): { api?: string } | undefined;
69
+ }
70
+
71
+ export interface LogicalModelScopeEntry {
72
+ readonly model: Model<Api>;
73
+ }
74
+
75
+ interface InventoryMetadata {
76
+ readonly allCandidates: readonly LogicalModelCandidate[];
77
+ readonly availableCandidates: readonly LogicalModelCandidate[];
78
+ readonly providerIds: ReadonlySet<string>;
79
+ readonly scopeIds: ReadonlySet<string> | undefined;
80
+ }
81
+
82
+ const inventoryMetadata = new WeakMap<object, InventoryMetadata>();
83
+ const LOGICAL_PREFIX = `${LOGICAL_PROVIDER_ID}/`;
84
+ const MAX_REFERENCE_LENGTH = 1024;
85
+
86
+ function candidateForModel(model: Model<Api>): LogicalModelCandidate {
87
+ return { id: model.id, name: model.name, model };
88
+ }
89
+
90
+ type LogicalModelInventoryReason = Extract<
91
+ LogicalModelInventory,
92
+ { status: "unusable" }
93
+ >["reason"];
94
+
95
+ function unusable(reason: LogicalModelInventoryReason): LogicalModelInventory {
96
+ return { status: "unusable", reason };
97
+ }
98
+
99
+ /**
100
+ * Builds one ordered, credential-free logical model snapshot. The registry is
101
+ * read afresh for each invocation; scope only removes rows from the available
102
+ * catalog and never adds one.
103
+ */
104
+ export function buildLogicalModelInventory(
105
+ modelRegistry: LogicalModelRegistry,
106
+ scopedModels: readonly LogicalModelScopeEntry[] = [],
107
+ ): LogicalModelInventory {
108
+ let allModels: readonly Model<Api>[];
109
+ try {
110
+ allModels = modelRegistry.getAll();
111
+ } catch {
112
+ return unusable("provider-not-registered");
113
+ }
114
+
115
+ const allLogicalModels = allModels.filter(
116
+ (model) => model.provider === LOGICAL_PROVIDER_ID,
117
+ );
118
+ if (allLogicalModels.length === 0) return { status: "absent" };
119
+
120
+ let registered: { api?: string } | undefined;
121
+ try {
122
+ registered = modelRegistry.getRegisteredProviderConfig(LOGICAL_PROVIDER_ID);
123
+ } catch {
124
+ return unusable("provider-not-registered");
125
+ }
126
+ if (registered === undefined) return unusable("provider-not-registered");
127
+ if (registered.api !== LOGICAL_PROVIDER_ID) {
128
+ return unusable("provider-api-mismatch");
129
+ }
130
+ if (allLogicalModels.some((model) => model.api !== LOGICAL_PROVIDER_ID)) {
131
+ return unusable("model-api-mismatch");
132
+ }
133
+
134
+ const ids = new Set<string>();
135
+ for (const model of allLogicalModels) {
136
+ if (ids.has(model.id)) return unusable("duplicate-model-id");
137
+ ids.add(model.id);
138
+ }
139
+
140
+ let availableModels: readonly Model<Api>[];
141
+ try {
142
+ availableModels = modelRegistry.getAvailable();
143
+ } catch {
144
+ availableModels = [];
145
+ }
146
+ const availableLogicalModels = availableModels.filter(
147
+ (model) =>
148
+ model.provider === LOGICAL_PROVIDER_ID &&
149
+ model.api === LOGICAL_PROVIDER_ID,
150
+ );
151
+ const allCandidates = allLogicalModels.map(candidateForModel);
152
+ const availableCandidates = availableLogicalModels.map(candidateForModel);
153
+ const providerIds = new Set(allModels.map((model) => model.provider));
154
+ const scopeIds =
155
+ scopedModels.length === 0
156
+ ? undefined
157
+ : new Set(
158
+ scopedModels
159
+ .filter(
160
+ ({ model }) =>
161
+ model.provider === LOGICAL_PROVIDER_ID &&
162
+ model.api === LOGICAL_PROVIDER_ID,
163
+ )
164
+ .map(({ model }) => model.id),
165
+ );
166
+ const metadata = { allCandidates, availableCandidates, providerIds, scopeIds };
167
+
168
+ if (scopeIds !== undefined) {
169
+ const candidates = availableCandidates.filter((candidate) =>
170
+ scopeIds.has(candidate.id),
171
+ );
172
+ if (candidates.length === 0) {
173
+ const result: LogicalModelInventory = { status: "out-of-scope" };
174
+ inventoryMetadata.set(result, metadata);
175
+ return result;
176
+ }
177
+ const result: LogicalModelInventory = {
178
+ status: "ready",
179
+ source: "session-scope",
180
+ candidates,
181
+ };
182
+ inventoryMetadata.set(result, metadata);
183
+ return result;
184
+ }
185
+
186
+ if (availableCandidates.length === 0) {
187
+ const result: LogicalModelInventory = { status: "unavailable" };
188
+ inventoryMetadata.set(result, metadata);
189
+ return result;
190
+ }
191
+
192
+ const result: LogicalModelInventory = {
193
+ status: "ready",
194
+ source: "available-catalog",
195
+ candidates: availableCandidates,
196
+ };
197
+ inventoryMetadata.set(result, metadata);
198
+ return result;
199
+ }
200
+
201
+ function invalidReference(input: string): LogicalModelReference | undefined {
202
+ const value = input.trim();
203
+ if (value.length === 0) return { status: "invalid-input", reason: "empty" };
204
+ if (value.length > MAX_REFERENCE_LENGTH) {
205
+ return { status: "invalid-input", reason: "too-long" };
206
+ }
207
+ if ([...value].some((character) => {
208
+ const code = character.charCodeAt(0);
209
+ return code < 0x20 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
210
+ })) {
211
+ return { status: "invalid-input", reason: "control-character" };
212
+ }
213
+ return undefined;
214
+ }
215
+
216
+ /** Resolve only a bare exact id or one exact logical-provider prefix. */
217
+ export function resolveLogicalModelReference(
218
+ input: string,
219
+ inventory: LogicalModelInventory,
220
+ ): LogicalModelReference {
221
+ const invalid = invalidReference(input);
222
+ if (invalid) return invalid;
223
+
224
+ const normalized = input.trim();
225
+ const metadata = inventoryMetadata.get(inventory);
226
+ const allCandidates =
227
+ metadata?.allCandidates ??
228
+ (inventory.status === "ready" ? inventory.candidates : []);
229
+ const availableCandidates =
230
+ metadata?.availableCandidates ??
231
+ (inventory.status === "ready" ? inventory.candidates : []);
232
+ const scopeIds = metadata?.scopeIds;
233
+ const providerIds = metadata?.providerIds ?? new Set([
234
+ LOGICAL_PROVIDER_ID,
235
+ "anthropic",
236
+ "openai-codex",
237
+ "google-antigravity",
238
+ "openrouter",
239
+ ]);
240
+ const find = (id: string): LogicalModelCandidate | undefined =>
241
+ allCandidates.find((candidate) => candidate.id === id);
242
+ const bare = find(normalized);
243
+ const prefixed = normalized.startsWith(LOGICAL_PREFIX)
244
+ ? find(normalized.slice(LOGICAL_PREFIX.length))
245
+ : undefined;
246
+
247
+ if (bare && prefixed && bare.id !== prefixed.id) {
248
+ return { status: "ambiguous" };
249
+ }
250
+ const found = bare ?? prefixed;
251
+ if (found) {
252
+ const available = availableCandidates.find(
253
+ (candidate) => candidate.id === found.id,
254
+ );
255
+ const inScope =
256
+ scopeIds === undefined || scopeIds.has(found.id);
257
+ if (!inScope) return { status: "out-of-scope", modelId: found.id };
258
+ if (!available) return { status: "unavailable", modelId: found.id };
259
+ return { status: "resolved", candidate: available };
260
+ }
261
+
262
+ const firstSegment = normalized.split("/", 1)[0];
263
+ if (
264
+ normalized.includes("/") &&
265
+ firstSegment !== undefined &&
266
+ providerIds.has(firstSegment) &&
267
+ firstSegment !== LOGICAL_PROVIDER_ID
268
+ ) {
269
+ return { status: "foreign-provider" };
270
+ }
271
+ return { status: "unknown-model" };
272
+ }
273
+
274
+ function rejected(
275
+ reason: LogicalModelRejectionReason,
276
+ modelId?: string,
277
+ ): LogicalModelSwitchResult {
278
+ return modelId === undefined
279
+ ? { status: "rejected", reason }
280
+ : { status: "rejected", reason, modelId };
281
+ }
282
+
283
+ function inventoryRejection(
284
+ inventory: LogicalModelInventory,
285
+ ): LogicalModelSwitchResult | undefined {
286
+ if (inventory.status === "ready") return undefined;
287
+ return inventory.status === "unusable"
288
+ ? rejected("unusable")
289
+ : rejected(inventory.status);
290
+ }
291
+
292
+ export interface LogicalModelSwitchDependencies {
293
+ readonly mode: ExtensionContext["mode"];
294
+ readonly ui: ExtensionUIContext;
295
+ readonly modelRegistry: LogicalModelRegistry;
296
+ readonly scopedModels: readonly LogicalModelScopeEntry[];
297
+ readonly currentModel: Model<Api> | undefined;
298
+ readonly selector: LogicalModelSelector;
299
+ readonly setModel: (model: Model<Api>) => Promise<boolean>;
300
+ readonly signal?: AbortSignal;
301
+ }
302
+
303
+ /** Execute the operator-only command, with direct mode kept free of terminal UI. */
304
+ export async function executeLogicalModelSwitch(
305
+ argument: string,
306
+ dependencies: LogicalModelSwitchDependencies,
307
+ ): Promise<LogicalModelSwitchResult> {
308
+ const normalized = argument.trim();
309
+ if (normalized.length === 0 && dependencies.mode !== "tui") {
310
+ return rejected("tui-required");
311
+ }
312
+
313
+ const inventory = buildLogicalModelInventory(
314
+ dependencies.modelRegistry,
315
+ dependencies.scopedModels,
316
+ );
317
+ const apply = async (
318
+ candidate: LogicalModelCandidate,
319
+ ): Promise<LogicalModelSwitchResult> => {
320
+ try {
321
+ if ((await dependencies.setModel(candidate.model)) !== true) {
322
+ return rejected("host-refused", candidate.id);
323
+ }
324
+ } catch {
325
+ return rejected("host-refused", candidate.id);
326
+ }
327
+ return { status: "selected", modelId: candidate.id };
328
+ };
329
+ if (inventory.status !== "ready") {
330
+ if (
331
+ normalized.length > 0 &&
332
+ (inventory.status === "unavailable" || inventory.status === "out-of-scope")
333
+ ) {
334
+ const reference = resolveLogicalModelReference(normalized, inventory);
335
+ if (reference.status === "resolved") {
336
+ return apply(reference.candidate);
337
+ }
338
+ if (reference.status === "out-of-scope" || reference.status === "unavailable") {
339
+ return rejected(reference.status, reference.modelId);
340
+ }
341
+ if (reference.status === "invalid-input") {
342
+ return rejected("invalid-input");
343
+ }
344
+ return rejected(reference.status);
345
+ }
346
+ return inventoryRejection(inventory)!;
347
+ }
348
+
349
+ let candidate: LogicalModelCandidate | undefined;
350
+ if (normalized.length === 0) {
351
+ candidate = await dependencies.selector.select({
352
+ candidates: inventory.candidates,
353
+ currentModel: dependencies.currentModel,
354
+ ui: dependencies.ui,
355
+ ...(dependencies.signal === undefined
356
+ ? {}
357
+ : { signal: dependencies.signal }),
358
+ });
359
+ if (candidate === undefined) return { status: "cancelled" };
360
+ if (!inventory.candidates.some((entry) => entry === candidate)) {
361
+ return rejected("unknown-model");
362
+ }
363
+ } else {
364
+ const reference = resolveLogicalModelReference(normalized, inventory);
365
+ if (reference.status !== "resolved") {
366
+ return reference.status === "invalid-input"
367
+ ? rejected("invalid-input")
368
+ : reference.status === "out-of-scope" ||
369
+ reference.status === "unavailable"
370
+ ? rejected(reference.status, reference.modelId)
371
+ : rejected(reference.status);
372
+ }
373
+ candidate = reference.candidate;
374
+ }
375
+
376
+ return apply(candidate);
377
+ }
378
+
379
+ function validatedModelId(modelId: string): string | undefined {
380
+ if (modelId.trim() !== modelId || invalidReference(modelId) !== undefined) {
381
+ return undefined;
382
+ }
383
+ return modelId;
384
+ }
385
+
386
+ /** Convert a result to fixed, non-sensitive operator output. */
387
+ export function renderLogicalModelSwitchResult(
388
+ result: LogicalModelSwitchResult,
389
+ ): string {
390
+ if (result.status === "selected") {
391
+ const modelId = validatedModelId(result.modelId);
392
+ return modelId === undefined
393
+ ? "Selected an exact unified model."
394
+ : `Selected unified model ${modelId}.`;
395
+ }
396
+ if (result.status === "cancelled") {
397
+ return "Model selection cancelled; the current model is unchanged.";
398
+ }
399
+ switch (result.reason) {
400
+ case "absent":
401
+ return "No usable unified models are declared. Run /multi-account models install, then reload or restart Pi.";
402
+ case "unusable":
403
+ return "The unified declaration is unusable. Run /multi-account models update, then reload or restart Pi.";
404
+ case "out-of-scope":
405
+ return "That exact logical model is outside this session's scope. Adjust enabledModels or launch with a matching --models value.";
406
+ case "unavailable":
407
+ return "That exact logical model is currently unavailable. Choose another logical row or reconcile changed catalogs.";
408
+ case "foreign-provider":
409
+ return "Exact logical identity is required; a foreign provider was not selected.";
410
+ case "unknown-model":
411
+ return "Exact logical identity is required; no model was selected.";
412
+ case "ambiguous":
413
+ return "That reference is ambiguous; exact logical identity is required.";
414
+ case "invalid-input":
415
+ return "The model reference is invalid; enter one exact logical model id.";
416
+ case "tui-required":
417
+ return "The picker requires terminal UI. Use /multi-account model <exact-model-id>.";
418
+ case "host-refused": {
419
+ const modelId =
420
+ result.modelId === undefined ? undefined : validatedModelId(result.modelId);
421
+ return modelId === undefined
422
+ ? "Pi refused the exact model; the current model is unchanged."
423
+ : `Pi refused the exact model ${modelId}; the current model is unchanged.`;
424
+ }
425
+ default: {
426
+ const exhaustive: never = result.reason;
427
+ return exhaustive;
428
+ }
429
+ }
430
+ }