@oh-my-pi/pi-coding-agent 15.11.6 → 15.11.8
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/CHANGELOG.md +57 -1
- package/dist/cli.js +431 -381
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/bench-cli.d.ts +78 -0
- package/dist/types/collab/crypto.d.ts +12 -0
- package/dist/types/collab/guest.d.ts +21 -0
- package/dist/types/collab/host.d.ts +13 -0
- package/dist/types/collab/protocol.d.ts +100 -0
- package/dist/types/collab/relay-client.d.ts +22 -0
- package/dist/types/commands/bench.d.ts +29 -0
- package/dist/types/commands/join.d.ts +12 -0
- package/dist/types/config/model-resolver.d.ts +3 -2
- package/dist/types/config/settings-schema.d.ts +93 -1
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/slash-commands.d.ts +1 -11
- package/dist/types/modes/components/agent-hub.d.ts +13 -0
- package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
- package/dist/types/modes/components/hook-selector.d.ts +4 -6
- package/dist/types/modes/components/oauth-selector.d.ts +10 -1
- package/dist/types/modes/components/segment-track.d.ts +11 -6
- package/dist/types/modes/components/settings-selector.d.ts +8 -1
- package/dist/types/modes/components/snapcompact-shape-preview.d.ts +31 -0
- package/dist/types/modes/components/status-line/component.d.ts +4 -1
- package/dist/types/modes/components/status-line/types.d.ts +9 -0
- package/dist/types/modes/components/tool-execution.d.ts +13 -9
- package/dist/types/modes/interactive-mode.d.ts +7 -0
- package/dist/types/modes/setup-wizard/scenes/sign-in.d.ts +3 -0
- package/dist/types/modes/setup-wizard/scenes/types.d.ts +10 -1
- package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +3 -0
- package/dist/types/modes/types.d.ts +8 -0
- package/dist/types/session/agent-session.d.ts +11 -0
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/snapcompact-inline.d.ts +8 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/eval-render.d.ts +1 -0
- package/dist/types/tools/renderers.d.ts +13 -0
- package/dist/types/tools/ssh.d.ts +1 -0
- package/package.json +14 -12
- package/scripts/bench-guard.ts +71 -0
- package/src/cli/args.ts +2 -0
- package/src/cli/bench-cli.ts +437 -0
- package/src/cli-commands.ts +2 -0
- package/src/collab/crypto.ts +57 -0
- package/src/collab/guest.ts +421 -0
- package/src/collab/host.ts +494 -0
- package/src/collab/protocol.ts +191 -0
- package/src/collab/relay-client.ts +216 -0
- package/src/commands/bench.ts +42 -0
- package/src/commands/join.ts +39 -0
- package/src/config/model-registry.ts +74 -19
- package/src/config/model-resolver.ts +36 -5
- package/src/config/settings-schema.ts +119 -1
- package/src/edit/renderer.ts +5 -0
- package/src/extensibility/slash-commands.ts +1 -97
- package/src/hindsight/client.ts +26 -1
- package/src/hindsight/state.ts +6 -2
- package/src/internal-urls/docs-index.generated.ts +4 -3
- package/src/main.ts +11 -2
- package/src/mcp/transports/stdio.ts +81 -7
- package/src/modes/components/agent-hub.ts +119 -22
- package/src/modes/components/assistant-message.ts +126 -6
- package/src/modes/components/collab-prompt-message.ts +30 -0
- package/src/modes/components/hook-selector.ts +4 -5
- package/src/modes/components/oauth-selector.ts +67 -7
- package/src/modes/components/segment-track.ts +44 -7
- package/src/modes/components/settings-selector.ts +27 -0
- package/src/modes/components/snapcompact-shape-preview-doc.md +11 -0
- package/src/modes/components/snapcompact-shape-preview.ts +192 -0
- package/src/modes/components/status-line/component.ts +21 -1
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/components/status-line/segments.ts +13 -0
- package/src/modes/components/status-line/types.ts +10 -0
- package/src/modes/components/tips.txt +2 -1
- package/src/modes/components/tool-execution.ts +18 -10
- package/src/modes/controllers/input-controller.ts +80 -12
- package/src/modes/controllers/selector-controller.ts +6 -2
- package/src/modes/controllers/streaming-reveal.ts +7 -0
- package/src/modes/interactive-mode.ts +36 -4
- package/src/modes/setup-wizard/index.ts +1 -0
- package/src/modes/setup-wizard/scenes/glyph.ts +24 -6
- package/src/modes/setup-wizard/scenes/providers.ts +36 -2
- package/src/modes/setup-wizard/scenes/sign-in.ts +10 -1
- package/src/modes/setup-wizard/scenes/theme.ts +28 -1
- package/src/modes/setup-wizard/scenes/types.ts +10 -1
- package/src/modes/setup-wizard/scenes/web-search.ts +22 -6
- package/src/modes/setup-wizard/wizard-overlay.ts +38 -1
- package/src/modes/types.ts +8 -0
- package/src/modes/utils/context-usage.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +7 -0
- package/src/prompts/bench.md +7 -0
- package/src/sdk.ts +240 -36
- package/src/session/agent-session.ts +22 -0
- package/src/session/session-manager.ts +44 -0
- package/src/session/snapcompact-inline.ts +20 -22
- package/src/slash-commands/builtin-registry.ts +210 -0
- package/src/tools/bash.ts +3 -0
- package/src/tools/eval-render.ts +4 -0
- package/src/tools/read.ts +38 -5
- package/src/tools/renderers.ts +13 -0
- package/src/tools/ssh.ts +3 -0
- package/src/tools/write.ts +13 -42
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side WebSocket wrapper for collab live-session sharing.
|
|
3
|
+
*
|
|
4
|
+
* Connects to a relay room, seals/opens AES-GCM frames, and reconnects with
|
|
5
|
+
* exponential backoff on transient drops. Fatal relay close codes (room gone,
|
|
6
|
+
* host conflict, room full) and decryption failures never reconnect.
|
|
7
|
+
*/
|
|
8
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
9
|
+
import { open, seal } from "./crypto";
|
|
10
|
+
import type { CollabFrame, RelayControlMessage } from "./protocol";
|
|
11
|
+
import { packEnvelope, unpackEnvelope } from "./protocol";
|
|
12
|
+
|
|
13
|
+
const FATAL_CLOSE_REASONS: Record<number, string> = {
|
|
14
|
+
4001: "room closed",
|
|
15
|
+
4004: "no such room",
|
|
16
|
+
4009: "a host is already connected for this room",
|
|
17
|
+
4029: "room is full",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const BACKOFF_BASE_MS = 1_000;
|
|
21
|
+
const BACKOFF_MAX_MS = 30_000;
|
|
22
|
+
/** Max enveloped frames buffered while a reconnect is pending; overflow is dropped. */
|
|
23
|
+
const MAX_PENDING_SENDS = 256;
|
|
24
|
+
|
|
25
|
+
export interface CollabSocketOptions {
|
|
26
|
+
/** wss://host[:port]/r/<roomId> — no query string. */
|
|
27
|
+
wsUrl: string;
|
|
28
|
+
role: "host" | "guest";
|
|
29
|
+
key: CryptoKey;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class CollabSocket {
|
|
33
|
+
/** Fires after every successful (re)connect. */
|
|
34
|
+
onOpen?: () => void;
|
|
35
|
+
onFrame?: (frame: CollabFrame, fromPeer: number) => void;
|
|
36
|
+
onControl?: (msg: RelayControlMessage) => void;
|
|
37
|
+
/** Fires once per terminal close (intentional, fatal code, or bad key). willReconnect=true for transient drops that will retry. */
|
|
38
|
+
onClose?: (reason: string, willReconnect: boolean) => void;
|
|
39
|
+
|
|
40
|
+
readonly #opts: CollabSocketOptions;
|
|
41
|
+
#ws: WebSocket | null = null;
|
|
42
|
+
#retryTimer: NodeJS.Timeout | undefined;
|
|
43
|
+
#attempt = 0;
|
|
44
|
+
/** Terminal state: intentional close or fatal failure. Cleared by connect(). */
|
|
45
|
+
#closed = false;
|
|
46
|
+
/** Serializes seal() so frames hit the wire in send() order. */
|
|
47
|
+
#sendChain: Promise<void> = Promise.resolve();
|
|
48
|
+
/** Serializes open() so frames are delivered in arrival order. */
|
|
49
|
+
#recvChain: Promise<void> = Promise.resolve();
|
|
50
|
+
/** Envelopes sealed while disconnected, flushed on the next open. */
|
|
51
|
+
#pendingSends: Uint8Array[] = [];
|
|
52
|
+
|
|
53
|
+
constructor(opts: CollabSocketOptions) {
|
|
54
|
+
this.#opts = opts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
get isOpen(): boolean {
|
|
58
|
+
return this.#ws?.readyState === WebSocket.OPEN;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
connect(): void {
|
|
62
|
+
if (this.#ws || this.#retryTimer) return;
|
|
63
|
+
this.#closed = false;
|
|
64
|
+
this.#attempt = 0;
|
|
65
|
+
this.#openSocket();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
send(frame: CollabFrame, targetPeer = 0): void {
|
|
69
|
+
this.#sendChain = this.#sendChain
|
|
70
|
+
.then(async () => {
|
|
71
|
+
if (this.#closed) {
|
|
72
|
+
logger.debug("collab: dropping frame, socket closed", { t: frame.t });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const sealed = await seal(this.#opts.key, frame);
|
|
76
|
+
const envelope = packEnvelope(targetPeer, sealed);
|
|
77
|
+
const ws = this.#ws;
|
|
78
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
79
|
+
ws.send(envelope);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (this.#pendingSends.length >= MAX_PENDING_SENDS) {
|
|
83
|
+
logger.debug("collab: dropping frame, reconnect buffer full", { t: frame.t });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.#pendingSends.push(envelope);
|
|
87
|
+
})
|
|
88
|
+
.catch((err: unknown) => {
|
|
89
|
+
logger.debug("collab: send failed", { error: String(err) });
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Intentional close: clears any retry timer, suppresses reconnect. A later connect() starts fresh. */
|
|
94
|
+
close(): void {
|
|
95
|
+
const hadActivity = this.#ws !== null || this.#retryTimer !== undefined;
|
|
96
|
+
this.#clearRetry();
|
|
97
|
+
const wasClosed = this.#closed;
|
|
98
|
+
this.#closed = true;
|
|
99
|
+
this.#pendingSends.length = 0;
|
|
100
|
+
const ws = this.#ws;
|
|
101
|
+
this.#ws = null;
|
|
102
|
+
if (ws) {
|
|
103
|
+
try {
|
|
104
|
+
ws.close(1000);
|
|
105
|
+
} catch {
|
|
106
|
+
// already closing/closed
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (hadActivity && !wasClosed) this.onClose?.("closed", false);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#openSocket(): void {
|
|
113
|
+
const ws = new WebSocket(`${this.#opts.wsUrl}?role=${this.#opts.role}`);
|
|
114
|
+
ws.binaryType = "arraybuffer";
|
|
115
|
+
this.#ws = ws;
|
|
116
|
+
ws.onopen = () => {
|
|
117
|
+
if (this.#ws !== ws) return;
|
|
118
|
+
this.#attempt = 0;
|
|
119
|
+
for (const envelope of this.#pendingSends) ws.send(envelope);
|
|
120
|
+
this.#pendingSends.length = 0;
|
|
121
|
+
this.onOpen?.();
|
|
122
|
+
};
|
|
123
|
+
ws.onmessage = (event: MessageEvent) => {
|
|
124
|
+
if (this.#ws !== ws) return;
|
|
125
|
+
this.#handleMessage(ws, event.data);
|
|
126
|
+
};
|
|
127
|
+
ws.onerror = () => {
|
|
128
|
+
// The paired close event carries the actionable state; nothing to do here.
|
|
129
|
+
};
|
|
130
|
+
ws.onclose = (event: CloseEvent) => {
|
|
131
|
+
if (this.#ws !== ws) return;
|
|
132
|
+
this.#ws = null;
|
|
133
|
+
this.#handleClose(event.code, event.reason);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#handleMessage(ws: WebSocket, data: unknown): void {
|
|
138
|
+
if (typeof data === "string") {
|
|
139
|
+
try {
|
|
140
|
+
this.onControl?.(JSON.parse(data) as RelayControlMessage);
|
|
141
|
+
} catch {
|
|
142
|
+
logger.debug("collab: ignoring malformed control message");
|
|
143
|
+
}
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data instanceof Uint8Array ? data : null;
|
|
147
|
+
if (!bytes) return;
|
|
148
|
+
const envelope = unpackEnvelope(bytes);
|
|
149
|
+
if (!envelope) return;
|
|
150
|
+
this.#recvChain = this.#recvChain
|
|
151
|
+
.then(async () => {
|
|
152
|
+
if (this.#ws !== ws) return;
|
|
153
|
+
let frame: CollabFrame;
|
|
154
|
+
try {
|
|
155
|
+
frame = await open(this.#opts.key, envelope.payload);
|
|
156
|
+
} catch {
|
|
157
|
+
this.#failFatal("bad key or corrupted frame");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (this.#ws !== ws) return;
|
|
161
|
+
this.onFrame?.(frame, envelope.peerId);
|
|
162
|
+
})
|
|
163
|
+
.catch((err: unknown) => {
|
|
164
|
+
logger.debug("collab: frame handler failed", { error: String(err) });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#handleClose(code: number, reason: string): void {
|
|
169
|
+
if (this.#closed) return;
|
|
170
|
+
const fatalReason = FATAL_CLOSE_REASONS[code];
|
|
171
|
+
if (fatalReason !== undefined) {
|
|
172
|
+
this.#closed = true;
|
|
173
|
+
this.#pendingSends.length = 0;
|
|
174
|
+
this.onClose?.(fatalReason, false);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
this.onClose?.(reason || `connection lost (code ${code})`, true);
|
|
178
|
+
this.#scheduleRetry();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Decryption failure: wrong key or corrupted frame. Never reconnect. */
|
|
182
|
+
#failFatal(reason: string): void {
|
|
183
|
+
if (this.#closed) return;
|
|
184
|
+
this.#closed = true;
|
|
185
|
+
this.#clearRetry();
|
|
186
|
+
this.#pendingSends.length = 0;
|
|
187
|
+
const ws = this.#ws;
|
|
188
|
+
this.#ws = null;
|
|
189
|
+
if (ws) {
|
|
190
|
+
try {
|
|
191
|
+
ws.close(1000);
|
|
192
|
+
} catch {
|
|
193
|
+
// already closing/closed
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
this.onClose?.(reason, false);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
#scheduleRetry(): void {
|
|
200
|
+
const base = Math.min(BACKOFF_BASE_MS * 2 ** this.#attempt, BACKOFF_MAX_MS);
|
|
201
|
+
this.#attempt++;
|
|
202
|
+
const delay = base * (0.75 + Math.random() * 0.5);
|
|
203
|
+
this.#retryTimer = setTimeout(() => {
|
|
204
|
+
this.#retryTimer = undefined;
|
|
205
|
+
if (this.#closed) return;
|
|
206
|
+
this.#openSocket();
|
|
207
|
+
}, delay);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
#clearRetry(): void {
|
|
211
|
+
if (this.#retryTimer !== undefined) {
|
|
212
|
+
clearTimeout(this.#retryTimer);
|
|
213
|
+
this.#retryTimer = undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
|
|
2
|
+
import { runBenchCommand } from "../cli/bench-cli";
|
|
3
|
+
|
|
4
|
+
export default class Bench extends Command {
|
|
5
|
+
static description =
|
|
6
|
+
"Benchmark models with the same prompt: time-to-first-token and generation throughput (tokens/s)";
|
|
7
|
+
|
|
8
|
+
static args = {
|
|
9
|
+
models: Args.string({
|
|
10
|
+
description: "Model selectors (provider/model or fuzzy id, e.g. opus)",
|
|
11
|
+
required: true,
|
|
12
|
+
multiple: true,
|
|
13
|
+
}),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
static flags = {
|
|
17
|
+
runs: Flags.integer({ description: "Requests per model (results are averaged)", default: 1 }),
|
|
18
|
+
"max-tokens": Flags.integer({ description: "Max output tokens per request", default: 512 }),
|
|
19
|
+
prompt: Flags.string({ description: "Custom prompt text (default: bundled bench prompt)" }),
|
|
20
|
+
json: Flags.boolean({ description: "Output JSON" }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
static examples = [
|
|
24
|
+
"# Compare two models\n omp bench anthropic/claude-opus-4-5 openai/gpt-5.2",
|
|
25
|
+
"# Fuzzy selectors work\n omp bench opus sonnet",
|
|
26
|
+
"# Average over 3 runs each\n omp bench opus gpt-5.2 --runs 3",
|
|
27
|
+
"# Machine-readable output\n omp bench opus --json",
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
async run(): Promise<void> {
|
|
31
|
+
const { args, flags } = await this.parse(Bench);
|
|
32
|
+
await runBenchCommand({
|
|
33
|
+
models: args.models ?? [],
|
|
34
|
+
flags: {
|
|
35
|
+
runs: flags.runs,
|
|
36
|
+
maxTokens: flags["max-tokens"],
|
|
37
|
+
prompt: flags.prompt,
|
|
38
|
+
json: flags.json,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join a shared collab session from the CLI: launches the interactive TUI and
|
|
3
|
+
* immediately runs `/join <link>`.
|
|
4
|
+
*/
|
|
5
|
+
import { APP_NAME } from "@oh-my-pi/pi-utils";
|
|
6
|
+
import { Args, Command } from "@oh-my-pi/pi-utils/cli";
|
|
7
|
+
import { parseArgs } from "../cli/args";
|
|
8
|
+
import { runRootCommand } from "../main";
|
|
9
|
+
|
|
10
|
+
export default class Join extends Command {
|
|
11
|
+
static description = "Join a shared collab session (same as /join)";
|
|
12
|
+
|
|
13
|
+
static args = {
|
|
14
|
+
link: Args.string({
|
|
15
|
+
description: "Collab link shared by the host (/collab)",
|
|
16
|
+
required: true,
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
static examples = [`${APP_NAME} join wss://relay.omp.sh/s/abc123#key`];
|
|
21
|
+
|
|
22
|
+
async run(): Promise<void> {
|
|
23
|
+
const { args } = await this.parse(Join);
|
|
24
|
+
const link = args.link?.trim();
|
|
25
|
+
if (!link) {
|
|
26
|
+
process.stderr.write(`Usage: ${APP_NAME} join <link>\n`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
31
|
+
process.stderr.write(`${APP_NAME} join requires an interactive terminal\n`);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const parsed = parseArgs([]);
|
|
36
|
+
parsed.join = link;
|
|
37
|
+
await runRootCommand(parsed, []);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -20,6 +20,11 @@ import {
|
|
|
20
20
|
UNK_CONTEXT_WINDOW,
|
|
21
21
|
UNK_MAX_TOKENS,
|
|
22
22
|
} from "@oh-my-pi/pi-catalog/provider-models";
|
|
23
|
+
import {
|
|
24
|
+
collapseBuiltModelVariants,
|
|
25
|
+
getVariantAliasSources,
|
|
26
|
+
resolveVariantAlias,
|
|
27
|
+
} from "@oh-my-pi/pi-catalog/variant-collapse";
|
|
23
28
|
|
|
24
29
|
// Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
|
|
25
30
|
// any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
|
|
@@ -542,7 +547,37 @@ function normalizeSuppressedSelector(selector: string): string {
|
|
|
542
547
|
if (!trimmed) return trimmed;
|
|
543
548
|
const parsed = parseModelString(trimmed);
|
|
544
549
|
if (!parsed) return trimmed;
|
|
545
|
-
|
|
550
|
+
// Retired effort-tier variant ids normalize to their collapsed logical id
|
|
551
|
+
// so persisted suppressions keyed by raw member ids still bind.
|
|
552
|
+
const aliasId = resolveVariantAlias(parsed.provider, parsed.id);
|
|
553
|
+
return `${parsed.provider}/${aliasId ?? parsed.id}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Look up a model's override, falling back to entries keyed by retired
|
|
558
|
+
* effort-tier variant ids (models.yml authored before collapsing). A raw key
|
|
559
|
+
* only re-binds when no live model holds that id.
|
|
560
|
+
*/
|
|
561
|
+
function resolveModelOverrideWithAliases(
|
|
562
|
+
overrides: Map<string, ModelOverride>,
|
|
563
|
+
model: Model<Api>,
|
|
564
|
+
hasLiveModel: (provider: string, id: string) => boolean,
|
|
565
|
+
): ModelOverride | undefined {
|
|
566
|
+
const direct = overrides.get(model.id);
|
|
567
|
+
if (direct) return direct;
|
|
568
|
+
for (const rawId of getVariantAliasSources(model.provider, model.id)) {
|
|
569
|
+
if (hasLiveModel(model.provider, rawId)) continue;
|
|
570
|
+
const remapped = overrides.get(rawId);
|
|
571
|
+
if (remapped) {
|
|
572
|
+
logger.debug("model override re-keyed through variant alias", {
|
|
573
|
+
provider: model.provider,
|
|
574
|
+
from: rawId,
|
|
575
|
+
to: model.id,
|
|
576
|
+
});
|
|
577
|
+
return remapped;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return undefined;
|
|
546
581
|
}
|
|
547
582
|
|
|
548
583
|
function getDisabledProviderIdsFromSettings(): Set<string> {
|
|
@@ -567,6 +602,7 @@ function getConfiguredProviderOrderFromSettings(): string[] {
|
|
|
567
602
|
export class ModelRegistry {
|
|
568
603
|
#models: Model<Api>[] = [];
|
|
569
604
|
#canonicalIndex: CanonicalModelIndex = { records: [], byId: new Map(), bySelector: new Map() };
|
|
605
|
+
#canonicalIndexDirty: boolean = true;
|
|
570
606
|
#customProviderApiKeys: Map<string, string> = new Map();
|
|
571
607
|
#keylessProviders: Set<string> = new Set();
|
|
572
608
|
#discoverableProviders: DiscoveryProviderConfig[] = [];
|
|
@@ -799,7 +835,9 @@ export class ModelRegistry {
|
|
|
799
835
|
const withConfigModels = this.#mergeCustomModels(resolvedDefaults, this.#customModelOverlays);
|
|
800
836
|
// Merge runtime extension models so they survive refresh() cycles
|
|
801
837
|
const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
|
|
802
|
-
|
|
838
|
+
// Custom/config providers bypass the model-manager merge point —
|
|
839
|
+
// collapse effort-tier variants here so X/X-thinking twins fold.
|
|
840
|
+
const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
|
|
803
841
|
this.#models = this.#applyRuntimeProviderOverrides(withModelOverrides);
|
|
804
842
|
this.#rebuildCanonicalIndex();
|
|
805
843
|
this.#lastStaticLoadMtime = this.#modelsConfigFile.getMtimeMs();
|
|
@@ -1152,7 +1190,7 @@ export class ModelRegistry {
|
|
|
1152
1190
|
const withConfigModels = this.#mergeCustomModels(resolved, this.#customModelOverlays);
|
|
1153
1191
|
// Merge runtime extension models so they survive online discovery completion
|
|
1154
1192
|
const combined = this.#mergeCustomModels(withConfigModels, this.#runtimeModelOverlays);
|
|
1155
|
-
const withModelOverrides = this.#applyModelOverrides(combined, this.#modelOverrides);
|
|
1193
|
+
const withModelOverrides = this.#applyModelOverrides(collapseBuiltModelVariants(combined), this.#modelOverrides);
|
|
1156
1194
|
this.#models = this.#applyRuntimeProviderOverrides(withModelOverrides);
|
|
1157
1195
|
this.#rebuildCanonicalIndex();
|
|
1158
1196
|
}
|
|
@@ -1398,8 +1436,13 @@ export class ModelRegistry {
|
|
|
1398
1436
|
#applyProviderModelOverrides(provider: string, models: Model<Api>[]): Model<Api>[] {
|
|
1399
1437
|
const overrides = this.#modelOverrides.get(provider);
|
|
1400
1438
|
if (!overrides || overrides.size === 0) return models;
|
|
1439
|
+
let liveIds: Set<string> | null = null;
|
|
1440
|
+
const hasLiveModel = (_provider: string, id: string) => {
|
|
1441
|
+
liveIds ??= new Set(models.map(m => m.id));
|
|
1442
|
+
return liveIds.has(id);
|
|
1443
|
+
};
|
|
1401
1444
|
return models.map(model => {
|
|
1402
|
-
const override = overrides
|
|
1445
|
+
const override = resolveModelOverrideWithAliases(overrides, model, hasLiveModel);
|
|
1403
1446
|
if (!override) return model;
|
|
1404
1447
|
return applyModelOverride(model, override);
|
|
1405
1448
|
});
|
|
@@ -1443,10 +1486,15 @@ export class ModelRegistry {
|
|
|
1443
1486
|
}
|
|
1444
1487
|
#applyModelOverrides(models: Model<Api>[], overrides: Map<string, Map<string, ModelOverride>>): Model<Api>[] {
|
|
1445
1488
|
if (overrides.size === 0) return models;
|
|
1489
|
+
let liveKeys: Set<string> | null = null;
|
|
1490
|
+
const hasLiveModel = (provider: string, id: string) => {
|
|
1491
|
+
liveKeys ??= new Set(models.map(m => `${m.provider}\u0000${m.id}`));
|
|
1492
|
+
return liveKeys.has(`${provider}\u0000${id}`);
|
|
1493
|
+
};
|
|
1446
1494
|
return models.map(model => {
|
|
1447
1495
|
const providerOverrides = overrides.get(model.provider);
|
|
1448
1496
|
if (!providerOverrides) return model;
|
|
1449
|
-
const override = providerOverrides
|
|
1497
|
+
const override = resolveModelOverrideWithAliases(providerOverrides, model, hasLiveModel);
|
|
1450
1498
|
if (!override) return model;
|
|
1451
1499
|
return applyModelOverride(model, override);
|
|
1452
1500
|
});
|
|
@@ -1472,14 +1520,25 @@ export class ModelRegistry {
|
|
|
1472
1520
|
this.#rebuildPending = true;
|
|
1473
1521
|
return;
|
|
1474
1522
|
}
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1523
|
+
// Defer the catalog-wide index build to first read. Boot model
|
|
1524
|
+
// resolution reads it only when enabledModels or a default-role pattern
|
|
1525
|
+
// is configured; the empty interactive launch never reads it pre-paint,
|
|
1526
|
+
// so the ~200ms build over the full catalog moves off the first-paint
|
|
1527
|
+
// critical path.
|
|
1528
|
+
this.#canonicalIndexDirty = true;
|
|
1480
1529
|
this.#rebuildPending = false;
|
|
1481
1530
|
}
|
|
1482
1531
|
|
|
1532
|
+
#ensureCanonicalIndex(): CanonicalModelIndex {
|
|
1533
|
+
if (this.#canonicalIndexDirty) {
|
|
1534
|
+
this.#canonicalIndex = logger.time("buildCanonicalModelIndex", () =>
|
|
1535
|
+
buildCanonicalModelIndex(this.#models, getBundledCanonicalReferenceData(), this.#equivalenceConfig),
|
|
1536
|
+
);
|
|
1537
|
+
this.#canonicalIndexDirty = false;
|
|
1538
|
+
}
|
|
1539
|
+
return this.#canonicalIndex;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1483
1542
|
#suspendRebuild(): void {
|
|
1484
1543
|
this.#rebuildSuspended += 1;
|
|
1485
1544
|
}
|
|
@@ -1490,11 +1549,7 @@ export class ModelRegistry {
|
|
|
1490
1549
|
}
|
|
1491
1550
|
if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
|
|
1492
1551
|
this.#rebuildPending = false;
|
|
1493
|
-
this.#
|
|
1494
|
-
this.#models,
|
|
1495
|
-
getBundledCanonicalReferenceData(),
|
|
1496
|
-
this.#equivalenceConfig,
|
|
1497
|
-
);
|
|
1552
|
+
this.#canonicalIndexDirty = true;
|
|
1498
1553
|
}
|
|
1499
1554
|
}
|
|
1500
1555
|
|
|
@@ -1603,7 +1658,7 @@ export class ModelRegistry {
|
|
|
1603
1658
|
getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
|
|
1604
1659
|
const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
|
|
1605
1660
|
const records: CanonicalModelRecord[] = [];
|
|
1606
|
-
for (const record of this.#
|
|
1661
|
+
for (const record of this.#ensureCanonicalIndex().records) {
|
|
1607
1662
|
const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
|
|
1608
1663
|
if (variants.length === 0) {
|
|
1609
1664
|
continue;
|
|
@@ -1629,7 +1684,7 @@ export class ModelRegistry {
|
|
|
1629
1684
|
const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
|
|
1630
1685
|
const preferences = this.#variantPreferences(candidates);
|
|
1631
1686
|
const selections: CanonicalModelSelection[] = [];
|
|
1632
|
-
for (const record of this.#
|
|
1687
|
+
for (const record of this.#ensureCanonicalIndex().records) {
|
|
1633
1688
|
const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
|
|
1634
1689
|
if (variants.length === 0) {
|
|
1635
1690
|
continue;
|
|
@@ -1647,7 +1702,7 @@ export class ModelRegistry {
|
|
|
1647
1702
|
}
|
|
1648
1703
|
|
|
1649
1704
|
getCanonicalVariants(canonicalId: string, options?: CanonicalModelQueryOptions): CanonicalModelVariant[] {
|
|
1650
|
-
const record = this.#
|
|
1705
|
+
const record = this.#ensureCanonicalIndex().byId.get(canonicalId.trim().toLowerCase());
|
|
1651
1706
|
if (!record) {
|
|
1652
1707
|
return [];
|
|
1653
1708
|
}
|
|
@@ -1665,7 +1720,7 @@ export class ModelRegistry {
|
|
|
1665
1720
|
}
|
|
1666
1721
|
|
|
1667
1722
|
getCanonicalId(model: Model<Api>): string | undefined {
|
|
1668
|
-
return this.#
|
|
1723
|
+
return this.#ensureCanonicalIndex().bySelector.get(formatCanonicalVariantSelector(model).toLowerCase());
|
|
1669
1724
|
}
|
|
1670
1725
|
|
|
1671
1726
|
/**
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Layering:
|
|
5
5
|
* - `matchModel` is the single matching engine. Order: exact `provider/id`
|
|
6
|
-
* reference (with OpenRouter routed/date fallbacks) →
|
|
7
|
-
* exact
|
|
6
|
+
* reference (with variant-alias and OpenRouter routed/date fallbacks) →
|
|
7
|
+
* exact canonical id → exact bare id → retired variant alias →
|
|
8
|
+
* provider-scoped fuzzy → substring with alias-vs-dated pick.
|
|
8
9
|
* - `parseModelPatternWithContext`/`parseModelPattern` layer the selector
|
|
9
10
|
* grammar on top: trailing `:level` thinking suffixes (`splitThinkingSuffix`)
|
|
10
11
|
* and `@upstream` provider routing (`splitUpstreamRouting`).
|
|
@@ -19,9 +20,11 @@ import type { Api, Effort, KnownProvider, Model, ModelSpec } from "@oh-my-pi/pi-
|
|
|
19
20
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
20
21
|
import { modelMatchesHost } from "@oh-my-pi/pi-catalog/hosts";
|
|
21
22
|
import { buildModelProviderPriorityRank } from "@oh-my-pi/pi-catalog/identity";
|
|
23
|
+
import { stripThinkingVariantToken } from "@oh-my-pi/pi-catalog/identity/family";
|
|
22
24
|
import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
23
25
|
import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
|
|
24
26
|
import { DEFAULT_MODEL_PER_PROVIDER } from "@oh-my-pi/pi-catalog/provider-models";
|
|
27
|
+
import { resolveBareVariantAlias, resolveVariantAlias } from "@oh-my-pi/pi-catalog/variant-collapse";
|
|
25
28
|
import { fuzzyMatch } from "@oh-my-pi/pi-tui";
|
|
26
29
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
27
30
|
import chalk from "chalk";
|
|
@@ -228,6 +231,18 @@ export function resolveProviderModelReference(
|
|
|
228
231
|
return exact;
|
|
229
232
|
}
|
|
230
233
|
|
|
234
|
+
// Retired effort-tier variant ids resolve to their collapsed logical
|
|
235
|
+
// model: hand-table aliases first, then the `X-thinking` → `X` grammar
|
|
236
|
+
// for auto-derived pairs. Exact lookup above always wins while raw is live.
|
|
237
|
+
const variantAliasId =
|
|
238
|
+
resolveVariantAlias(normalizedProvider, normalizedModelId) ?? stripThinkingVariantToken(normalizedModelId);
|
|
239
|
+
if (variantAliasId) {
|
|
240
|
+
const aliased = index.get(`${normalizedProvider}\u0000${variantAliasId.toLowerCase()}`);
|
|
241
|
+
if (aliased) {
|
|
242
|
+
return aliased;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
231
246
|
if (normalizedProvider !== "openrouter") {
|
|
232
247
|
return undefined;
|
|
233
248
|
}
|
|
@@ -407,11 +422,13 @@ function findExactCanonicalModelMatch(
|
|
|
407
422
|
|
|
408
423
|
/**
|
|
409
424
|
* The single model-matching engine. Tries, in order:
|
|
410
|
-
* 1. exact `provider/id` reference (OpenRouter routed/date
|
|
425
|
+
* 1. exact `provider/id` reference (variant-alias and OpenRouter routed/date
|
|
426
|
+
* fallbacks included),
|
|
411
427
|
* 2. exact canonical id (coalesces provider variants),
|
|
412
428
|
* 3. exact bare id (preference-ranked),
|
|
413
|
-
* 4.
|
|
414
|
-
* 5.
|
|
429
|
+
* 4. retired effort-tier variant alias (collapsed catalog entries),
|
|
430
|
+
* 5. provider-scoped fuzzy match,
|
|
431
|
+
* 6. substring match with the alias-vs-dated pick.
|
|
415
432
|
* Returns the matched model or undefined if no match found.
|
|
416
433
|
*/
|
|
417
434
|
function matchModel(
|
|
@@ -440,6 +457,20 @@ function matchModel(
|
|
|
440
457
|
if (exactMatches.length > 0) {
|
|
441
458
|
return pickPreferredModel(exactMatches, context);
|
|
442
459
|
}
|
|
460
|
+
|
|
461
|
+
// Retired effort-tier variant ids (bare, no provider prefix) resolve to
|
|
462
|
+
// their collapsed logical model; models from the providers whose table
|
|
463
|
+
// declared the alias win ties. Auto-derived `X-thinking` pairs resolve
|
|
464
|
+
// through the grammar fallback.
|
|
465
|
+
const bareAlias = resolveBareVariantAlias(modelPattern);
|
|
466
|
+
const bareAliasTargetId = bareAlias?.id ?? stripThinkingVariantToken(modelPattern);
|
|
467
|
+
if (bareAliasTargetId) {
|
|
468
|
+
const aliasMatches = availableModels.filter(m => m.id.toLowerCase() === bareAliasTargetId.toLowerCase());
|
|
469
|
+
if (aliasMatches.length > 0) {
|
|
470
|
+
const preferred = bareAlias ? aliasMatches.filter(m => bareAlias.providers.includes(m.provider)) : [];
|
|
471
|
+
return pickPreferredModel(preferred.length > 0 ? preferred : aliasMatches, context);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
443
474
|
// Check for provider/modelId format — fuzzy match within provider only.
|
|
444
475
|
const slashIndex = modelPattern.indexOf("/");
|
|
445
476
|
if (slashIndex !== -1) {
|