@wuyaos/pi-model-config 0.0.0-stage → 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/src/config.ts ADDED
@@ -0,0 +1,438 @@
1
+ import {
2
+ closeSync,
3
+ copyFileSync,
4
+ existsSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmSync,
11
+ statSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { mcError, t, type UiLanguage } from "./i18n.ts";
17
+ import type {
18
+ ConfigSnapshot,
19
+ ModelConfigFile,
20
+ ModelsJsonFile,
21
+ ModelsJsonProvider,
22
+ ProviderConnectionConfig,
23
+ } from "./types.ts";
24
+
25
+ export const MODELS_SCHEMA = "https://pi.dev/schemas/models.schema.json";
26
+ export const MODEL_CONFIG_SCHEMA = "https://raw.githubusercontent.com/wuyaos/pi-packages/main/pi-model-config/model-config.schema.json";
27
+
28
+ const EMPTY_MODELS: ModelsJsonFile = { $schema: MODELS_SCHEMA, providers: {} };
29
+ const EMPTY_CONFIG: ModelConfigFile = { $schema: MODEL_CONFIG_SCHEMA, version: 1, providers: {} };
30
+
31
+ function isRecord(value: unknown): value is Record<string, unknown> {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+
35
+ export function snapshot(path: string): ConfigSnapshot {
36
+ try {
37
+ return { path, exists: true, text: readFileSync(path, "utf8") };
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { path, exists: false };
40
+ throw error;
41
+ }
42
+ }
43
+
44
+ export function parseModelsJson(source: ConfigSnapshot): ModelsJsonFile {
45
+ if (!source.exists) return structuredClone(EMPTY_MODELS);
46
+ const parsed: unknown = JSON.parse(source.text!);
47
+ if (!isRecord(parsed) || !isRecord(parsed.providers)) throw mcError("MCFG_E_CONFIG_SHAPE", { file: "models.json" });
48
+ for (const [id, provider] of Object.entries(parsed.providers)) {
49
+ if (!isRecord(provider)) throw mcError("MCFG_E_PROVIDER_SHAPE", { file: "models.json", provider: id });
50
+ if (provider.models !== undefined && !Array.isArray(provider.models)) throw mcError("MCFG_E_MODELS_ARRAY", { file: "models.json", provider: id });
51
+ if (Array.isArray(provider.models) && provider.models.some((model) => !isRecord(model))) throw mcError("MCFG_E_MODELS_ARRAY", { file: "models.json", provider: id });
52
+ }
53
+ return parsed as ModelsJsonFile;
54
+ }
55
+
56
+ export function parseModelConfig(source: ConfigSnapshot): ModelConfigFile {
57
+ if (!source.exists) return structuredClone(EMPTY_CONFIG);
58
+ const parsed: unknown = JSON.parse(source.text!);
59
+ if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.providers)) {
60
+ throw mcError("MCFG_E_CONFIG_SHAPE", { file: "model-config.json" });
61
+ }
62
+ for (const [id, provider] of Object.entries(parsed.providers)) {
63
+ if (!isRecord(provider)) throw mcError("MCFG_E_PROVIDER_SHAPE", { file: "model-config.json", provider: id });
64
+ if (!new Set(["models-json", "reuse-pi", "anonymous"]).has(String(provider.authMode))) {
65
+ throw mcError("MCFG_E_AUTH_MODE", { file: "model-config.json", provider: id });
66
+ }
67
+ if (provider.enabled !== undefined && typeof provider.enabled !== "boolean") throw mcError("MCFG_E_PROVIDER_SHAPE", { file: "model-config.json", provider: id });
68
+ if (provider.discoveryAuthHeader !== undefined && !new Set(["auto", "bearer", "x-api-key", "api-key", "x-goog-api-key", "anthropic", "none"]).has(String(provider.discoveryAuthHeader))) {
69
+ throw mcError("MCFG_E_DISCOVERY_AUTH_HEADER", { file: "model-config.json", provider: id });
70
+ }
71
+ if (provider.models !== undefined && !Array.isArray(provider.models)) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
72
+ if (Array.isArray(provider.models) && provider.models.some((model) => !isRecord(model))) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
73
+ for (const model of Array.isArray(provider.models) ? provider.models : []) {
74
+ if (!isRecord(model) || (model.type !== "classifier" && model.type !== "image") || typeof model.id !== "string" || typeof model.api !== "string") {
75
+ throw mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: id });
76
+ }
77
+ }
78
+ }
79
+ return parsed as ModelConfigFile;
80
+ }
81
+
82
+ export interface ConfigFilesRead {
83
+ modelsSnapshot: ConfigSnapshot;
84
+ configSnapshot: ConfigSnapshot;
85
+ models: ModelsJsonFile;
86
+ config: ModelConfigFile;
87
+ recoveredTxId?: string;
88
+ }
89
+
90
+ interface TransactionJournalEntry {
91
+ target: string;
92
+ backup?: string;
93
+ existed: boolean;
94
+ changed: boolean;
95
+ }
96
+
97
+ interface TransactionJournal {
98
+ version: 1;
99
+ txId: string;
100
+ models: TransactionJournalEntry;
101
+ config: TransactionJournalEntry;
102
+ }
103
+
104
+ const TRANSACTION_JOURNAL = ".model-config-tx.json";
105
+
106
+ function transactionJournalPath(configPath: string): string { return join(dirname(configPath), TRANSACTION_JOURNAL); }
107
+
108
+ function isJournalEntry(value: unknown): value is TransactionJournalEntry {
109
+ return isRecord(value)
110
+ && typeof value.target === "string"
111
+ && typeof value.existed === "boolean"
112
+ && typeof value.changed === "boolean"
113
+ && (value.backup === undefined || typeof value.backup === "string");
114
+ }
115
+
116
+ function readTransactionJournal(path: string, modelsPath: string, configPath: string): TransactionJournal {
117
+ let value: unknown;
118
+ try { value = JSON.parse(readFileSync(path, "utf8")); }
119
+ catch (error) { throw mcError("MCFG_E_TX_RECOVERY_FAILED", { path, reason: "journal is unreadable" }, error); }
120
+ if (!isRecord(value) || value.version !== 1 || typeof value.txId !== "string" || !isJournalEntry(value.models) || !isJournalEntry(value.config)
121
+ || value.models.target !== modelsPath || value.config.target !== configPath) {
122
+ throw mcError("MCFG_E_TX_RECOVERY_FAILED", { path, reason: "journal does not match this agent directory" });
123
+ }
124
+ return value as unknown as TransactionJournal;
125
+ }
126
+
127
+ function writeTransactionJournal(path: string, journal: TransactionJournal): void {
128
+ const fd = openSync(path, "wx", 0o600);
129
+ try {
130
+ writeFileSync(fd, `${JSON.stringify(journal)}\n`, "utf8");
131
+ fsyncSync(fd);
132
+ } finally { closeSync(fd); }
133
+ }
134
+
135
+ function recoverJournalEntry(entry: TransactionJournalEntry): void {
136
+ if (!entry.changed) return;
137
+ if (!entry.existed) { rmSync(entry.target, { force: true }); return; }
138
+ if (!entry.backup || !existsSync(entry.backup)) throw new Error(`missing backup for ${entry.target}`);
139
+ copyFileSync(entry.backup, entry.target);
140
+ }
141
+
142
+ /** Recover a process that died after one of the two replacement renames. */
143
+ export function recoverPendingConfigTransaction(modelsPath: string, configPath: string): string | undefined {
144
+ const journalPath = transactionJournalPath(configPath);
145
+ if (!existsSync(journalPath)) return undefined;
146
+ let lockFd: number | undefined;
147
+ let ownsLock = false;
148
+ try {
149
+ try {
150
+ lockFd = acquireTransactionLock(join(dirname(configPath), "model-config.lock"));
151
+ ownsLock = true;
152
+ } catch (error) {
153
+ throw mcError("MCFG_E_TX_RECOVERY_ACTIVE", { path: journalPath }, error);
154
+ }
155
+ const journal = readTransactionJournal(journalPath, modelsPath, configPath);
156
+ try {
157
+ // Always restore both changed files to the same known old generation.
158
+ recoverJournalEntry(journal.models);
159
+ recoverJournalEntry(journal.config);
160
+ unlinkSync(journalPath);
161
+ return journal.txId;
162
+ } catch (error) {
163
+ throw mcError("MCFG_E_TX_RECOVERY_FAILED", { path: journalPath, reason: sanitizeError(error) }, error);
164
+ }
165
+ } finally {
166
+ if (lockFd !== undefined) closeSync(lockFd);
167
+ if (ownsLock) { try { unlinkSync(join(dirname(configPath), "model-config.lock")); } catch { /* best effort */ } }
168
+ }
169
+ }
170
+
171
+ export function readConfigFiles(modelsPath: string, configPath: string): ConfigFilesRead {
172
+ const recoveredTxId = recoverPendingConfigTransaction(modelsPath, configPath);
173
+ const modelsSnapshot = snapshot(modelsPath);
174
+ const configSnapshot = snapshot(configPath);
175
+ return {
176
+ modelsSnapshot,
177
+ configSnapshot,
178
+ models: parseModelsJson(modelsSnapshot),
179
+ config: parseModelConfig(configSnapshot),
180
+ ...(recoveredTxId ? { recoveredTxId } : {}),
181
+ };
182
+ }
183
+
184
+ export function validateProviderId(id: string): string {
185
+ const normalized = id.trim().toLowerCase();
186
+ if (!/^[a-z0-9][a-z0-9._-]*$/u.test(normalized)) {
187
+ throw mcError("MCFG_E_PROVIDER_ID");
188
+ }
189
+ return normalized;
190
+ }
191
+
192
+ export function validateHttpUrl(value: string, label: string): string {
193
+ let url: URL;
194
+ try {
195
+ url = new URL(value.trim());
196
+ } catch {
197
+ throw mcError("MCFG_E_URL_INVALID", { label });
198
+ }
199
+ if (url.protocol !== "http:" && url.protocol !== "https:") throw mcError("MCFG_E_URL_PROTOCOL", { label });
200
+ if (url.username || url.password) throw mcError("MCFG_E_URL_CREDENTIALS", { label });
201
+ for (const key of url.searchParams.keys()) {
202
+ if (/^(?:api[_-]?key|key|token|access[_-]?token)$/iu.test(key)) {
203
+ throw mcError("MCFG_E_URL_SECRET_QUERY", { label });
204
+ }
205
+ }
206
+ return url.toString();
207
+ }
208
+
209
+ export function redactUrl(value: string | undefined, language: UiLanguage): string {
210
+ if (!value) return t(language, "valueDefault");
211
+ try {
212
+ const url = new URL(value);
213
+ if (url.search) url.search = `?${t(language, "valueRedacted")}`;
214
+ url.hash = "";
215
+ return url.toString();
216
+ } catch {
217
+ return t(language, "valueInvalidUrl");
218
+ }
219
+ }
220
+
221
+ export function authDescription(language: UiLanguage, config: ProviderConnectionConfig | undefined, provider: ModelsJsonProvider | undefined): string {
222
+ if (config?.authMode === "anonymous") return t(language, "valueAnonymous");
223
+ if (config?.authMode === "reuse-pi") return t(language, "valueReuseAuth");
224
+ const value = provider?.apiKey;
225
+ if (!value) return t(language, "valueUnset");
226
+ if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/u.test(value)) return t(language, "valueEnv", { value });
227
+ return t(language, "valueLiteralMasked");
228
+ }
229
+
230
+ export function sanitizeError(error: unknown, secrets: Array<string | undefined> = []): string {
231
+ let message = error instanceof Error ? error.message : String(error);
232
+ for (const secret of secrets) {
233
+ if (!secret) continue;
234
+ const variants = [secret, ...(secret.startsWith("$$") || secret.startsWith("$!") ? [secret.slice(1)] : [])];
235
+ for (const variant of variants) if (variant.length >= 3) message = message.split(variant).join("[REDACTED]");
236
+ }
237
+ message = message
238
+ .replace(/(["']?authorization["']?\s*[:=]\s*["']?)(?:(?:Bearer|Basic)\s+)?[^"'\s,;}]+/giu, "$1[REDACTED]")
239
+ .replace(/(["']?(?:x-api-key|x-goog-api-key|api[-_ ]?key|token)["']?\s*[:=]\s*["']?)[^"'\s,;}]+/giu, "$1[REDACTED]")
240
+ .replace(/([?&](?:key|token|api_key)=)[^&\s]+/giu, "$1[REDACTED]")
241
+ .replace(/[\r\n\t]+/gu, " ")
242
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, "")
243
+ .trim();
244
+ return message.slice(0, 400);
245
+ }
246
+
247
+ function snapshotMatches(expected: ConfigSnapshot): boolean {
248
+ const current = snapshot(expected.path);
249
+ return current.exists === expected.exists && current.text === expected.text;
250
+ }
251
+
252
+ function writeTemp(target: string, value: unknown, tx: string): string {
253
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
254
+ const temp = join(dirname(target), `.${target.split(/[\\/]/u).pop()}.${tx}.tmp`);
255
+ const text = `${JSON.stringify(value, null, 2)}\n`;
256
+ JSON.parse(text);
257
+ const fd = openSync(temp, "wx", 0o600);
258
+ try {
259
+ writeFileSync(fd, text, "utf8");
260
+ fsyncSync(fd);
261
+ } finally {
262
+ closeSync(fd);
263
+ }
264
+ return temp;
265
+ }
266
+
267
+ function restore(target: string, before: ConfigSnapshot, rollback: string | undefined): void {
268
+ if (before.exists && rollback && existsSync(rollback)) copyFileSync(rollback, target);
269
+ else if (!before.exists) rmSync(target, { force: true });
270
+ }
271
+
272
+ export interface TransactionResult {
273
+ backups: string[];
274
+ modelsWritten: boolean;
275
+ configWritten: boolean;
276
+ }
277
+
278
+ const DEAD_LOCK_GRACE_MS = 30_000;
279
+ const UNREADABLE_LOCK_GRACE_MS = 10 * 60_000;
280
+
281
+ function processIsAlive(pid: number): boolean {
282
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
283
+ try {
284
+ process.kill(pid, 0);
285
+ return true;
286
+ } catch (error) {
287
+ const code = (error as NodeJS.ErrnoException).code;
288
+ if (code === "ESRCH") return false;
289
+ // EPERM and unknown platform errors are treated as alive: never steal a lock
290
+ // merely because this process cannot inspect its owner.
291
+ return true;
292
+ }
293
+ }
294
+
295
+ function staleLockReason(lockPath: string): string | undefined {
296
+ let ageMs: number;
297
+ try {
298
+ ageMs = Math.max(0, Date.now() - statSync(lockPath).mtimeMs);
299
+ } catch {
300
+ return undefined;
301
+ }
302
+ let pid: number | undefined;
303
+ try {
304
+ const raw = readFileSync(lockPath, "utf8").trim();
305
+ const parsed = raw.startsWith("{") ? JSON.parse(raw) as { pid?: unknown } : { pid: Number(raw.split(/\s/u)[0]) };
306
+ if (typeof parsed.pid === "number" && Number.isSafeInteger(parsed.pid) && parsed.pid > 0) pid = parsed.pid;
307
+ } catch {
308
+ // An unreadable lock needs a much longer age threshold before cleanup.
309
+ }
310
+ if (pid !== undefined) {
311
+ if (processIsAlive(pid) || ageMs < DEAD_LOCK_GRACE_MS) return undefined;
312
+ return `dead-pid:${pid};age-seconds:${Math.round(ageMs / 1000)}`;
313
+ }
314
+ if (ageMs >= UNREADABLE_LOCK_GRACE_MS) return `unreadable-lock;age-seconds:${Math.round(ageMs / 1000)}`;
315
+ return undefined;
316
+ }
317
+
318
+ function acquireTransactionLock(lockPath: string): number {
319
+ try {
320
+ return openSync(lockPath, "wx", 0o600);
321
+ } catch (error) {
322
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
323
+ const stale = staleLockReason(lockPath);
324
+ if (!stale) throw mcError("MCFG_E_LOCK_ACTIVE", { path: lockPath });
325
+
326
+ // Serialize stale-lock reclamation so two cleaners cannot delete each other's
327
+ // newly acquired transaction lock. A normal writer may still win after rename;
328
+ // in that case the exclusive retry fails and its lock remains untouched.
329
+ const recoveryPath = `${lockPath}.recovery`;
330
+ let recoveryFd: number | undefined;
331
+ const quarantinePath = `${lockPath}.stale-${process.pid}-${Date.now()}`;
332
+ try {
333
+ try {
334
+ recoveryFd = openSync(recoveryPath, "wx", 0o600);
335
+ writeFileSync(recoveryFd, `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
336
+ fsyncSync(recoveryFd);
337
+ } catch (recoveryError) {
338
+ if ((recoveryError as NodeJS.ErrnoException).code === "EEXIST") {
339
+ throw mcError("MCFG_E_LOCK_RECOVERY");
340
+ }
341
+ throw recoveryError;
342
+ }
343
+ const rechecked = staleLockReason(lockPath);
344
+ if (!rechecked) throw mcError("MCFG_E_LOCK_CHANGED");
345
+ renameSync(lockPath, quarantinePath);
346
+ try {
347
+ return openSync(lockPath, "wx", 0o600);
348
+ } catch (retryError) {
349
+ throw mcError("MCFG_E_LOCK_RACE", {}, retryError);
350
+ } finally {
351
+ rmSync(quarantinePath, { force: true });
352
+ }
353
+ } finally {
354
+ if (recoveryFd !== undefined) {
355
+ closeSync(recoveryFd);
356
+ try { unlinkSync(recoveryPath); } catch { /* best-effort cleanup of our recovery lock */ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+
362
+ /** Optimistic transaction with a crash-recovery journal, staged JSON, backups, and rollback. */
363
+ export function writeConfigTransaction(
364
+ modelsBefore: ConfigSnapshot,
365
+ configBefore: ConfigSnapshot,
366
+ modelsValue: ModelsJsonFile,
367
+ configValue: ModelConfigFile,
368
+ ): TransactionResult {
369
+ const dir = dirname(configBefore.path);
370
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
371
+ const lockPath = join(dir, "model-config.lock");
372
+ const journalPath = transactionJournalPath(configBefore.path);
373
+ let lockFd: number | undefined;
374
+ let ownsLock = false;
375
+ const tx = `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}`;
376
+ let modelsTemp: string | undefined;
377
+ let configTemp: string | undefined;
378
+ let modelsRollback: string | undefined;
379
+ let configRollback: string | undefined;
380
+ let journalCreated = false;
381
+ const backups: string[] = [];
382
+ const modelsChanged = !modelsBefore.exists || JSON.stringify(parseModelsJson(modelsBefore)) !== JSON.stringify(modelsValue);
383
+ const configChanged = !configBefore.exists || JSON.stringify(parseModelConfig(configBefore)) !== JSON.stringify(configValue);
384
+
385
+ if (!modelsChanged && !configChanged) return { backups, modelsWritten: false, configWritten: false };
386
+ try {
387
+ lockFd = acquireTransactionLock(lockPath);
388
+ ownsLock = true;
389
+ writeFileSync(lockFd, `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
390
+ fsyncSync(lockFd);
391
+ if (!snapshotMatches(modelsBefore) || !snapshotMatches(configBefore)) throw mcError("MCFG_E_CONCURRENT_CHANGE");
392
+
393
+ if (modelsChanged) modelsTemp = writeTemp(modelsBefore.path, modelsValue, tx);
394
+ if (configChanged) configTemp = writeTemp(configBefore.path, configValue, tx);
395
+ if (!snapshotMatches(modelsBefore) || !snapshotMatches(configBefore)) throw mcError("MCFG_E_STAGING_CHANGE");
396
+ if (modelsChanged && modelsBefore.exists) {
397
+ modelsRollback = `${modelsBefore.path}.bak-${tx}`;
398
+ copyFileSync(modelsBefore.path, modelsRollback);
399
+ backups.push(modelsRollback);
400
+ }
401
+ if (configChanged && configBefore.exists) {
402
+ configRollback = `${configBefore.path}.bak-${tx}`;
403
+ copyFileSync(configBefore.path, configRollback);
404
+ backups.push(configRollback);
405
+ }
406
+ writeTransactionJournal(journalPath, {
407
+ version: 1,
408
+ txId: tx,
409
+ models: { target: modelsBefore.path, backup: modelsRollback, existed: modelsBefore.exists, changed: modelsChanged },
410
+ config: { target: configBefore.path, backup: configRollback, existed: configBefore.exists, changed: configChanged },
411
+ });
412
+ journalCreated = true;
413
+
414
+ if (modelsTemp) { renameSync(modelsTemp, modelsBefore.path); modelsTemp = undefined; }
415
+ if (configTemp) { renameSync(configTemp, configBefore.path); configTemp = undefined; }
416
+ unlinkSync(journalPath);
417
+ journalCreated = false;
418
+ return { backups, modelsWritten: modelsChanged, configWritten: configChanged };
419
+ } catch (error) {
420
+ if (!journalCreated) throw error;
421
+ const rollbackErrors: string[] = [];
422
+ if (journalCreated) {
423
+ if (modelsChanged) try { restore(modelsBefore.path, modelsBefore, modelsRollback); } catch (e) { rollbackErrors.push(String(e)); }
424
+ if (configChanged) try { restore(configBefore.path, configBefore, configRollback); } catch (e) { rollbackErrors.push(String(e)); }
425
+ if (!rollbackErrors.length) {
426
+ try { unlinkSync(journalPath); journalCreated = false; } catch (e) { rollbackErrors.push(String(e)); }
427
+ }
428
+ }
429
+ if (rollbackErrors.length) throw mcError("MCFG_E_ROLLBACK_INCOMPLETE", { errors: rollbackErrors.join("; ") }, error);
430
+ if (journalCreated) throw mcError("MCFG_E_TX_RECOVERY_FAILED", { path: journalPath, reason: "journal retained after failed rollback" }, error);
431
+ throw mcError("MCFG_E_TRANSACTION_ROLLED_BACK", {}, error);
432
+ } finally {
433
+ if (modelsTemp) rmSync(modelsTemp, { force: true });
434
+ if (configTemp) rmSync(configTemp, { force: true });
435
+ if (lockFd !== undefined) closeSync(lockFd);
436
+ if (ownsLock) { try { unlinkSync(lockPath); } catch { /* best effort */ } }
437
+ }
438
+ }
@@ -0,0 +1,90 @@
1
+ import type { AnyModel } from "@earendil-works/pi-ai";
2
+ import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import { Key, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
4
+ import { MODEL_CONFIG_OVERLAY_OPTIONS, renderPanelBox, showConfirmationPanel } from "./panel.ts";
5
+ import { sanitizeError } from "./config.ts";
6
+ import { t, type UiLanguage } from "./i18n.ts";
7
+
8
+ export type ProbeStatus = "success" | "context-overflow" | "auth" | "rate-limit" | "timeout" | "unsupported" | "aborted" | "error";
9
+ export interface ProbeStep { target: number; status: ProbeStatus; latencyMs: number; actualInput?: number; estimated: boolean; error?: string; usage?: unknown }
10
+ export interface ProbeResult { lower?: number; upper?: number; estimated: boolean; steps: ProbeStep[]; verifiedAt: string }
11
+ const STEPS = [32_000, 64_000, 128_000, 256_000];
12
+
13
+ function status(error: unknown, signal: AbortSignal): ProbeStatus {
14
+ if (signal.aborted) return signal.reason?.name === "TimeoutError" ? "timeout" : "aborted";
15
+ const message = error instanceof Error ? error.message : String(error);
16
+ if (/context[_ -]?(?:length|window).*exceed|maximum context|too many tokens|prompt.*too long/iu.test(message)) return "context-overflow";
17
+ if (/\b(?:401|403)\b|auth|credential|api.?key/iu.test(message)) return "auth";
18
+ if (/\b429\b|rate.?limit/iu.test(message)) return "rate-limit";
19
+ if (/unsupported|not implemented/iu.test(message)) return "unsupported";
20
+ return "error";
21
+ }
22
+ function usageInput(usage: any): number | undefined {
23
+ for (const value of [usage?.inputTokens, usage?.input, usage?.promptTokens, usage?.totalTokens]) if (typeof value === "number" && value > 0) return Math.floor(value);
24
+ return undefined;
25
+ }
26
+
27
+ export async function runContextProbe(model: AnyModel, registry: any, ceiling: number, signal: AbortSignal, activeSecrets: Array<string | undefined> = [], options: { timeoutMs?: number; steps?: number[]; payloadBuilder?: (target: number) => string } = {}): Promise<ProbeResult> {
28
+ const result: ProbeResult = { estimated: true, steps: [], verifiedAt: new Date().toISOString() };
29
+ for (const target of (options.steps ?? STEPS).filter((step) => step <= ceiling)) {
30
+ if (signal.aborted) { result.steps.push({ target, status: "aborted", latencyMs: 0, estimated: true }); break; }
31
+ const started = performance.now();
32
+ const stepSignal = AbortSignal.any([signal, AbortSignal.timeout(options.timeoutMs ?? 30_000)]);
33
+ const text = options.payloadBuilder?.(target) ?? "x ".repeat(target);
34
+ try {
35
+ let usage: any;
36
+ if (model.type === "classifier") {
37
+ const response = await registry.classify(model, { state: { text }, questions: { ok: { type: "bool", instructions: "Return true", criteria: { true: "true", false: "false" } } } }, { signal: stepSignal });
38
+ if (response.stopReason !== "stop") throw new Error(response.errorMessage ?? response.stopReason);
39
+ usage = response.usage;
40
+ } else if (model.type === "chat") {
41
+ const stream = registry.streamSimple(model, { messages: [{ role: "user", content: [{ type: "text", text }], timestamp: Date.now() }] }, { signal: stepSignal, maxTokens: 1 });
42
+ let done = false;
43
+ for await (const event of stream) {
44
+ if (event.type === "error") throw new Error(String(event.error?.errorMessage ?? event.errorMessage ?? event.reason ?? "stream error"));
45
+ if (event.type === "done") { done = true; usage = event.message?.usage ?? event.usage; }
46
+ }
47
+ if (!done) throw new Error("stream ended without done");
48
+ } else return { ...result, steps: [{ target, status: "unsupported", latencyMs: 0, estimated: true }] };
49
+ const actual = usageInput(usage);
50
+ const lower = actual ?? target;
51
+ result.lower = lower;
52
+ result.estimated = actual === undefined;
53
+ result.steps.push({ target, status: "success", latencyMs: Math.round(performance.now() - started), actualInput: actual, estimated: actual === undefined, usage });
54
+ } catch (error) {
55
+ const failure = status(error, stepSignal);
56
+ result.steps.push({ target, status: failure, latencyMs: Math.round(performance.now() - started), estimated: true, error: sanitizeError(error, activeSecrets) });
57
+ if (failure === "context-overflow") result.upper = target;
58
+ break;
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ class ProbeRunPanel implements Component {
65
+ private controller = new AbortController();
66
+ private result?: ProbeResult;
67
+ constructor(private tui: TUI, private theme: Theme, private language: UiLanguage, model: AnyModel, registry: any, ceiling: number, activeSecrets: Array<string | undefined>, done: (result: ProbeResult) => void) {
68
+ void runContextProbe(model, registry, ceiling, this.controller.signal, activeSecrets).then((result) => { this.result = result; this.tui.requestRender(); done(result); });
69
+ }
70
+ handleInput(data: string): void { if (matchesKey(data, Key.escape)) this.controller.abort(new DOMException("Cancelled", "AbortError")); }
71
+ render(width: number): string[] { return renderPanelBox([this.result ? "done" : t(this.language, "testRunning")], width, t(this.language, "probeChooseTitle"), this.theme); }
72
+ invalidate() {}
73
+ dispose() { this.controller.abort(); }
74
+ }
75
+
76
+ export async function showContextProbeFlow(ctx: ExtensionCommandContext, language: UiLanguage, model: AnyModel, activeSecrets: Array<string | undefined> = []): Promise<ProbeResult | undefined> {
77
+ const ceilingChoice = await showConfirmationPanel(ctx, {
78
+ title: t(language, "probeChooseTitle"), summary: [t(language, "probeCostWarning")], defaultActionIndex: 0, cancelActionId: "cancel",
79
+ actions: [...STEPS.map((step) => ({ id: String(step), label: `${step / 1000}K` })), { id: "cancel", label: t(language, "probeBack") }],
80
+ });
81
+ const ceiling = Number(ceilingChoice);
82
+ if (!Number.isFinite(ceiling)) return undefined;
83
+ const confirm = await showConfirmationPanel(ctx, { title: t(language, "probeChooseTitle"), summary: [t(language, "probeCostWarning")], defaultActionIndex: 0, cancelActionId: "cancel", actions: [{ id: "cancel", label: t(language, "confirmCancel") }, { id: "run", label: t(language, "contextProbeRun"), shortcut: "ctrl+k", severity: "warning" }] });
84
+ if (confirm !== "run") return undefined;
85
+ const result = await ctx.ui.custom<ProbeResult>((tui, theme, _keys, done) => new ProbeRunPanel(tui, theme, language, model, ctx.modelRegistry, ceiling, activeSecrets, done), MODEL_CONFIG_OVERLAY_OPTIONS);
86
+ const details = result.steps.map((step) => `${step.target}: ${step.status} · ${step.latencyMs}ms${step.actualInput ? ` · input=${step.actualInput}` : " · estimated"}`);
87
+ const actions = [{ id: "keep", label: t(language, "probeKeep") }, ...(result.lower ? [{ id: "use", label: t(language, "probeUse"), shortcut: "ctrl+s" }] : []), { id: "back", label: t(language, "probeBack") }];
88
+ const choice = await showConfirmationPanel(ctx, { title: t(language, "probeChooseTitle"), summary: [result.lower ? `lower=${result.lower}${result.upper ? ` upper=${result.upper}` : ""}` : t(language, "probeNoSuccess")], details, defaultActionIndex: 0, cancelActionId: "back", actions });
89
+ return choice === "use" ? result : undefined;
90
+ }