pi-multimodal-proxy 1.17.0 → 1.18.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/CHANGELOG.md CHANGED
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.18.1] - 2026-08-30
8
+
9
+ ### Fixed (post-release review of 1.18.0)
10
+
11
+ - **The stage-2 syntax gate was a silent no-op on Windows.** `tools/check-syntax.mjs` compared `walk()` paths (backslash separators) against TypeScript-normalized source-file names (forward slashes), so the `has()` filter dropped every diagnostic — a planted `await`-in-non-async probe passed the gate. Both sides are now normalized; the planted-probe test fails the gate as intended. (Linux CI paths happened to match, so releases were still protected — local/dev Windows runs were not.)
12
+ - **Default-substitution notice no longer fires when the proxy is `off`** — model resolution is irrelevant with media analysis disabled.
13
+ - **The substitution hint is now cause-aware**: when the substituted-from model is missing from the catalog the notice suggests updating pi / pinning a model, instead of the (wrong) `pi --login` key hint that only fits the no-API-key case.
14
+ - **`writePersisted`'s fire-and-forget status refresh gained a `.catch`** — a rejected key probe can no longer surface as an unhandled promise rejection in daemon processes.
15
+ - **`withModelFallback` skips the 5 async key probes for explicit configs** (`PI_VISION_PROXY_MODEL` or persisted `modelExplicit`), where resolution is a no-op by definition.
16
+
17
+ ## [1.18.0] - 2026-08-30
18
+
19
+ ### Added
20
+
21
+ - **Key-aware default model resolution.** The implicit vision default now resolves to a model the user can actually call: when the configured default (`zai/glm-5.3-flash`) is not usable — missing from the catalog **or its provider has no API key** — `applyDefaultModelFallback` walks a preference-ordered candidate chain and picks the first entry that is both in the registry and keyed. `DEFAULT_MODEL_FALLBACKS` gained `deepseek/deepseek-v4-flash-vision-exp` (vision-capable via the built-in DeepSeek provider since Pi 0.84.4, ≈$0.22/M in) ahead of the Claude fallbacks. Safety rules: a keyed legacy default is never traded for an unkeyed model; explicit choices (`modelExplicit`, `PI_VISION_PROXY_MODEL`) are never rewritten; when nothing is keyed the catalog-only behavior applies so first use still surfaces the actionable "No API key — pi --login" notice. `withModelFallback` became async and probes keys via `ModelRegistry.getApiKeyAndHeaders` with a per-session cache; substitutions are announced once per session (`notifyDefaultSubstitution`) from `session_start` / `before_agent_start`.
22
+ - **Content-sniffed image validation** (Pi ≥ 0.84.4, graceful on older runtimes). `readImageFileWithReason` accepts an injectable sniffer backed by Pi's newly exported `detectSupportedImageMimeTypeFromFile`; new pure helper `decideImageMime` merges the extension map with the sniff: sniffed type wins (corrects mis-typed files), a sniff that finds no image rejects files whose extension is inside the sniffer's coverage (new reason `invalid-image`), PNG-signature files the sniffer rejects as animated still pass as `image/png` (APNG passthrough), formats outside coverage (tiff/ico/avif) and sniffer-unavailable runtimes keep the extension-only path, and extensionless files with recognizable contents are now accepted. The sniffer fails open: a throwing sniffer degrades to extension-only.
23
+ - **`/multimodal-proxy doctor`** — one-shot setup diagnostics covering effective model resolution (explicit / implicit / substituted), catalog presence, API keys, data-egress consent, fallback and video model, yt-dlp/ffmpeg availability (with versions), path-detection/allowlist state, and in-memory recall-store usage. Also available from the interactive menu.
24
+ - **`/multimodal-proxy test`** — end-to-end self-test: generates a tiny gradient PNG locally (ImageScript, no fixture file), runs it through the full describe pipeline (model resolution → key check → consent → retry/backoff → upload), and reports the model, latency, and description (or the failure pointer to `doctor`). Also available from the interactive menu.
25
+ - **Hardened syntax gate** (`tools/check-syntax.mjs` stage 2): beside the existing parse-only check, a `ts.createProgram` pass filtered to syntax-range diagnostic codes (< 2000) catches constructs the parser alone accepts but the runtime rejects — most notably `await` inside a non-async function (introduced and caught during this release's review; it crash-loops every pi loading the package, the exact 1.16.1 incident class). Type-level noise (TS2xxx) stays out of the gate, so the baseline is zero.
26
+
7
27
  ## [1.17.0] - 2026-08-27
8
28
 
9
29
  ### Changed
package/README.md CHANGED
@@ -8,6 +8,13 @@ When **video or audio files** are detected, they are routed to a **multimodal mo
8
8
 
9
9
  **YouTube links** are detected too: paste a URL (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`, etc.) and the video is downloaded with [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and analyzed exactly like a local file.
10
10
 
11
+ ## What's new in 1.18.0
12
+
13
+ - **Key-aware default model resolution** — the implicit vision default now lands on a model you can actually call. If the default (`zai/glm-5.3-flash`) has no API key in your setup, the proxy walks a preference-ordered candidate chain — `zai/glm-5.3-flash` → `deepseek/deepseek-v4-flash-vision-exp` (new in Pi 0.84.4, cheap, built-in provider) → `anthropic/claude-sonnet-5` — and picks the first candidate that is in the catalog **and keyed**. A keyed legacy default is never traded for an unkeyed model; explicit choices (`/multimodal-proxy model`, `pick`, `PI_VISION_PROXY_MODEL`) are still never rewritten; and when nothing is keyed you get the actionable `pi --login` hint as before. A substitution is announced once per session.
14
+ - **Content-sniffed image validation** (Pi ≥ 0.84.4, graceful on older) — image files are validated by magic bytes via Pi's exported `detectSupportedImageMimeTypeFromFile()`: a `notes.png` that is really a PDF is rejected with a clear `invalid-image` reason instead of being shipped to the vision model, mis-typed extensions are **corrected** (a JPEG named `.png` uploads as `image/jpeg`), extensionless files with recognizable contents are accepted, and formats outside the sniffer's coverage (tiff/ico/avif) keep the extension-only path. APNG still passes as `image/png`.
15
+ - **`/multimodal-proxy doctor`** — one-shot setup diagnostics: effective model (explicit / implicit / substituted), catalog presence, API keys, consent state, fallback + video model, yt-dlp & ffmpeg availability, path access, and recall-store usage.
16
+ - **`/multimodal-proxy test`** — end-to-end self-test: synthesizes a tiny gradient image locally and describes it through the full pipeline (model resolution → key → consent → retry → upload), reporting the model, latency, and result. No fixture files, no extra setup.
17
+
11
18
  ## What's new in 1.17.0
12
19
 
13
20
  - **Default vision model is now GLM 5.3 Flash** (`zai/glm-5.3-flash`) — fast, cheap image description ($0.075/M input, $0.25/M output) with a 1M-token context window. Models you never chose explicitly track the package default: configs that merely inherited the old default (`anthropic/claude-sonnet-5`) are upgraded when GLM 5.3 Flash is in the catalog, and on Pi versions without it the default falls back to `anthropic/claude-sonnet-5` (then `anthropic/claude-sonnet-4-5` on the oldest catalogs). Models chosen explicitly — via `/multimodal-proxy model`, `pick`, or `PI_VISION_PROXY_MODEL` — are never rewritten. **Heads-up:** the default provider is now Z.ai — you need a `zai` API key and will be asked for first-use data-egress consent unless you pin a different model (e.g. `/multimodal-proxy model anthropic/claude-sonnet-5`).
@@ -152,6 +159,8 @@ Settings persist across sessions in `~/.pi/agent/multimodal-proxy.json`. Environ
152
159
  /multimodal-proxy allow-home on | off → allow reading media anywhere under your home folder
153
160
  /multimodal-proxy path-detection on | off → auto-load media file paths found in prompt text (off = attachments only)
154
161
  /multimodal-proxy describe <path>... [--question "<text>"] [--crop <i>:<form>] [--model <provider/id>] [--save]
162
+ /multimodal-proxy doctor → one-shot setup diagnostics (model, keys, consent, yt-dlp/ffmpeg, paths, recall)
163
+ /multimodal-proxy test → end-to-end self-test with a locally generated image
155
164
 
156
165
  Legacy alias: /vision-proxy <args> works identically.
157
166
  ```
@@ -329,9 +338,10 @@ For the full security audit see [`SECURITY-REVIEW.md`](./SECURITY-REVIEW.md).
329
338
 
330
339
  ## Requirements
331
340
 
332
- - A vision-capable model with a valid API key (e.g. GLM 5.3 Flash, Claude, GPT-4o, Gemini, Qwen-VL)
341
+ - A vision-capable model with a valid API key (e.g. GLM 5.3 Flash, Claude, GPT-4o, Gemini, Qwen-VL) — any provider you already have a key for works; the implicit default picks a keyed model automatically (1.18.0)
333
342
  - For video/audio: a multimodal model that supports video input (e.g. Grok 4.3, Gemini 2.5 Pro)
334
343
  - The models must be registered in Pi (built-in or via `models.json`)
344
+ - Optional: Pi ≥ 0.84.4 enables content-sniffed image validation (mis-typed extensions are corrected or rejected); older versions keep the extension-only path
335
345
 
336
346
  ## License
337
347
 
@@ -1466,6 +1466,62 @@ describe("readImageFileWithReason", () => {
1466
1466
  await rm(dir, { recursive: true, force: true });
1467
1467
  }
1468
1468
  });
1469
+
1470
+ it("rejects a mis-typed image via the injected sniffer (invalid-image)", async () => {
1471
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1472
+ const file = join(dir, "fake.png");
1473
+ const contents = Buffer.from("this is actually text, not an image");
1474
+ await writeFile(file, contents);
1475
+ try {
1476
+ const r = await readImageFileWithReason(file, undefined, async () => null);
1477
+ assert.equal(r.image, null);
1478
+ assert.equal(r.reason, "invalid-image");
1479
+ assert.equal(r.bytes, contents.length);
1480
+ } finally {
1481
+ await rm(dir, { recursive: true, force: true });
1482
+ }
1483
+ });
1484
+
1485
+ it("corrects the MIME type when the sniff disagrees with the extension", async () => {
1486
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1487
+ const file = join(dir, "actually-jpeg.png");
1488
+ await writeFile(file, Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 16, 0x4a, 0x46, 0x49, 0x46]));
1489
+ try {
1490
+ const r = await readImageFileWithReason(file, undefined, async () => "image/jpeg");
1491
+ assert.ok(r.image, "image should be returned");
1492
+ assert.equal(r.image?.mimeType, "image/jpeg");
1493
+ } finally {
1494
+ await rm(dir, { recursive: true, force: true });
1495
+ }
1496
+ });
1497
+
1498
+ it("accepts an unknown extension when the sniffer recognizes the contents", async () => {
1499
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1500
+ const file = join(dir, "no-extension");
1501
+ await writeFile(file, TINY_PNG);
1502
+ try {
1503
+ const r = await readImageFileWithReason(file, undefined, async () => "image/png");
1504
+ assert.ok(r.image, "image should be returned");
1505
+ assert.equal(r.image?.mimeType, "image/png");
1506
+ } finally {
1507
+ await rm(dir, { recursive: true, force: true });
1508
+ }
1509
+ });
1510
+
1511
+ it("degrades to extension-only when the sniffer throws", async () => {
1512
+ const dir = await mkdtemp(join(os.tmpdir(), "vp-test-"));
1513
+ const file = join(dir, "ok.png");
1514
+ await writeFile(file, TINY_PNG);
1515
+ try {
1516
+ const r = await readImageFileWithReason(file, undefined, async () => {
1517
+ throw new Error("sniffer exploded");
1518
+ });
1519
+ assert.ok(r.image, "image should be returned");
1520
+ assert.equal(r.image?.mimeType, "image/png");
1521
+ } finally {
1522
+ await rm(dir, { recursive: true, force: true });
1523
+ }
1524
+ });
1469
1525
  });
1470
1526
 
1471
1527
  describe("readMediaFileWithReason", () => {
@@ -3023,7 +3079,7 @@ describe("Review fixes: storeImageMeta filename backfill", () => {
3023
3079
 
3024
3080
  // ── Default model fallback (1.8.0) ──────────────────────────────────────────
3025
3081
 
3026
- import { applyDefaultModelFallback, DEFAULT_MODEL_FALLBACKS } from "../internal.ts";
3082
+ import { applyDefaultModelFallback, DEFAULT_MODEL_FALLBACKS, decideImageMime, SNIFFABLE_IMAGE_MIMES } from "../internal.ts";
3027
3083
 
3028
3084
  describe("applyDefaultModelFallback", () => {
3029
3085
  const registryWith = (...models: string[]) => (p: string, m: string) => models.includes(`${p}/${m}`);
@@ -3100,6 +3156,153 @@ describe("applyDefaultModelFallback", () => {
3100
3156
  });
3101
3157
  });
3102
3158
 
3159
+ describe("1.18.0 key-aware default model resolution", () => {
3160
+ const registryWith = (...models: string[]) => (p: string, m: string) => models.includes(`${p}/${m}`);
3161
+ const keyWith = (...keyed: string[]) => (p: string, m: string) => keyed.includes(`${p}/${m}`);
3162
+ const builtin = { ...DEFAULT_CONFIG };
3163
+ const deepseek = DEFAULT_MODEL_FALLBACKS.find((m) => m.provider === "deepseek")!;
3164
+ const sonnet5 = { provider: "anthropic", modelId: "claude-sonnet-5" };
3165
+
3166
+ it("lists the DeepSeek vision model first among fallbacks", () => {
3167
+ assert.equal(DEFAULT_MODEL_FALLBACKS[0]!.provider, "deepseek");
3168
+ assert.equal(DEFAULT_MODEL_FALLBACKS[0]!.modelId, "deepseek-v4-flash-vision-exp");
3169
+ });
3170
+
3171
+ it("keeps the default when it is in the catalog and keyed", () => {
3172
+ const out = applyDefaultModelFallback(
3173
+ builtin,
3174
+ registryWith(`${builtin.provider}/${builtin.modelId}`),
3175
+ false,
3176
+ keyWith(`${builtin.provider}/${builtin.modelId}`),
3177
+ );
3178
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
3179
+ });
3180
+
3181
+ it("substitutes the first keyed candidate when the default has no key", () => {
3182
+ const out = applyDefaultModelFallback(
3183
+ builtin,
3184
+ registryWith(`${builtin.provider}/${builtin.modelId}`, `${deepseek.provider}/${deepseek.modelId}`, "anthropic/claude-sonnet-5"),
3185
+ false,
3186
+ keyWith(`${deepseek.provider}/${deepseek.modelId}`, "anthropic/claude-sonnet-5"),
3187
+ );
3188
+ assert.equal(out.provider, deepseek.provider);
3189
+ assert.equal(out.modelId, deepseek.modelId);
3190
+ });
3191
+
3192
+ it("skips unkeyed candidates and picks the next keyed one", () => {
3193
+ const out = applyDefaultModelFallback(
3194
+ builtin,
3195
+ registryWith(`${builtin.provider}/${builtin.modelId}`, `${deepseek.provider}/${deepseek.modelId}`, "anthropic/claude-sonnet-5"),
3196
+ false,
3197
+ keyWith("anthropic/claude-sonnet-5"),
3198
+ );
3199
+ assert.equal(out.provider, "anthropic");
3200
+ assert.equal(out.modelId, "claude-sonnet-5");
3201
+ });
3202
+
3203
+ it("keeps the in-catalog default when nothing is keyed (login-hint path)", () => {
3204
+ const out = applyDefaultModelFallback(
3205
+ builtin,
3206
+ registryWith(`${builtin.provider}/${builtin.modelId}`, "anthropic/claude-sonnet-5"),
3207
+ false,
3208
+ () => false,
3209
+ );
3210
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
3211
+ });
3212
+
3213
+ it("prefers a keyed legacy default over an unkeyed current default", () => {
3214
+ const legacy = { ...DEFAULT_CONFIG, ...sonnet5 };
3215
+ const out = applyDefaultModelFallback(
3216
+ legacy,
3217
+ registryWith(`${builtin.provider}/${builtin.modelId}`, "anthropic/claude-sonnet-5"),
3218
+ false,
3219
+ keyWith("anthropic/claude-sonnet-5"),
3220
+ );
3221
+ assert.equal(out.provider, "anthropic");
3222
+ assert.equal(out.modelId, "claude-sonnet-5");
3223
+ });
3224
+
3225
+ it("substitutes a keyed chain member for an unkeyed legacy default", () => {
3226
+ const legacy = { ...DEFAULT_CONFIG, ...sonnet5 };
3227
+ const out = applyDefaultModelFallback(
3228
+ legacy,
3229
+ registryWith("anthropic/claude-sonnet-5", `${deepseek.provider}/${deepseek.modelId}`),
3230
+ false,
3231
+ keyWith(`${deepseek.provider}/${deepseek.modelId}`),
3232
+ );
3233
+ assert.equal(out.provider, deepseek.provider);
3234
+ assert.equal(out.modelId, deepseek.modelId);
3235
+ });
3236
+
3237
+ it("never rewrites an explicit choice even with key information", () => {
3238
+ const explicit = { ...DEFAULT_CONFIG, modelExplicit: true };
3239
+ const out = applyDefaultModelFallback(
3240
+ explicit,
3241
+ registryWith(`${deepseek.provider}/${deepseek.modelId}`),
3242
+ false,
3243
+ keyWith(`${deepseek.provider}/${deepseek.modelId}`),
3244
+ );
3245
+ assert.equal(out.modelId, DEFAULT_CONFIG.modelId);
3246
+ });
3247
+
3248
+ it("does not mutate other config fields when substituting for a keyed candidate", () => {
3249
+ const cfg = { ...DEFAULT_CONFIG, mode: "always" as const, cacheSize: 9 };
3250
+ const out = applyDefaultModelFallback(
3251
+ cfg,
3252
+ registryWith(`${deepseek.provider}/${deepseek.modelId}`),
3253
+ false,
3254
+ keyWith(`${deepseek.provider}/${deepseek.modelId}`),
3255
+ );
3256
+ assert.equal(out.mode, "always");
3257
+ assert.equal(out.cacheSize, 9);
3258
+ });
3259
+ });
3260
+
3261
+ describe("decideImageMime (content sniffing)", () => {
3262
+ const pngHead = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
3263
+
3264
+ it("sniffed type wins over the extension", () => {
3265
+ const out = decideImageMime("image/png", "image/jpeg", pngHead);
3266
+ assert.deepEqual(out, { mime: "image/jpeg" });
3267
+ });
3268
+
3269
+ it("rejects an in-coverage extension whose contents are not an image", () => {
3270
+ const out = decideImageMime("image/png", null, new TextEncoder().encode("hello world"));
3271
+ assert.deepEqual(out, { reject: true });
3272
+ });
3273
+
3274
+ it("accepts a PNG-signature file the sniffer rejected (APNG passthrough)", () => {
3275
+ const out = decideImageMime("image/png", null, pngHead);
3276
+ assert.deepEqual(out, { mime: "image/png" });
3277
+ });
3278
+
3279
+ it("trusts extensions outside sniffer coverage when the sniff finds nothing", () => {
3280
+ const out = decideImageMime("image/tiff", null, new TextEncoder().encode("whatever"));
3281
+ assert.deepEqual(out, { mime: "image/tiff" });
3282
+ });
3283
+
3284
+ it("falls back to the extension map when the sniffer is unavailable", () => {
3285
+ const out = decideImageMime("image/png", undefined, new TextEncoder().encode("not really"));
3286
+ assert.deepEqual(out, { mime: "image/png" });
3287
+ });
3288
+
3289
+ it("returns no mime for an unknown extension without a sniffer", () => {
3290
+ const out = decideImageMime(undefined, undefined, pngHead);
3291
+ assert.equal(out.mime, undefined);
3292
+ assert.equal(out.reject, undefined);
3293
+ });
3294
+
3295
+ it("coverage set matches the formats Pi's sniffer validates", () => {
3296
+ assert.ok(SNIFFABLE_IMAGE_MIMES.has("image/jpeg"));
3297
+ assert.ok(SNIFFABLE_IMAGE_MIMES.has("image/png"));
3298
+ assert.ok(SNIFFABLE_IMAGE_MIMES.has("image/gif"));
3299
+ assert.ok(SNIFFABLE_IMAGE_MIMES.has("image/webp"));
3300
+ assert.ok(SNIFFABLE_IMAGE_MIMES.has("image/bmp"));
3301
+ assert.ok(!SNIFFABLE_IMAGE_MIMES.has("image/tiff"));
3302
+ assert.ok(!SNIFFABLE_IMAGE_MIMES.has("image/avif"));
3303
+ });
3304
+ });
3305
+
3103
3306
  describe("1.16.0 reliability: config defaults & sanitize", () => {
3104
3307
  it("defaults retryMax / maxUploadDim / maxUploadBytes, no fallback model", () => {
3105
3308
  const cfg = resolveConfig([], {});
@@ -1261,9 +1261,13 @@ export function resolveConfig(
1261
1261
 
1262
1262
  /**
1263
1263
  * Ordered fallbacks tried when the built-in default vision model is missing
1264
- * from the model registry — e.g. Pi catalogs that predate GLM 5.3 Flash.
1264
+ * from the model registry — e.g. Pi catalogs that predate GLM 5.3 Flash. The
1265
+ * same list (prefixed with the current default) forms the key-aware candidate
1266
+ * chain: the first candidate that is both in the registry and has an API key
1267
+ * wins when the default itself isn't usable.
1265
1268
  */
1266
1269
  export const DEFAULT_MODEL_FALLBACKS: ReadonlyArray<{ provider: string; modelId: string }> = [
1270
+ { provider: "deepseek", modelId: "deepseek-v4-flash-vision-exp" }, // Pi ≥ 0.84.4, cheap, common key
1267
1271
  { provider: "anthropic", modelId: "claude-sonnet-5" },
1268
1272
  { provider: "anthropic", modelId: "claude-sonnet-4-5" },
1269
1273
  ];
@@ -1286,36 +1290,76 @@ export const LEGACY_DEFAULT_MODELS: ReadonlyArray<{ provider: string; modelId: s
1286
1290
  * - a legacy baked-in default is upgraded to the current package default when
1287
1291
  * the registry has it (otherwise it keeps working as-is);
1288
1292
  * - the current default is substituted with the first available fallback when
1289
- * the registry doesn't know it (older Pi catalogs).
1293
+ * the registry doesn't know it (older Pi catalogs);
1294
+ * - with `hasKey` (optional, 1.18.0) resolution is key-aware: a candidate only
1295
+ * wins when it is in the registry AND its provider has an API key, so the
1296
+ * implicit default lands on a model the user can actually call. A keyed
1297
+ * legacy default is never traded for an unkeyed model, and when nothing is
1298
+ * keyed the catalog-only rules above apply unchanged (the first image then
1299
+ * fails with the actionable "No API key … pi --login" notice).
1290
1300
  *
1291
1301
  * Explicit choices are never rewritten: `userConfigured` (the caller saw
1292
1302
  * PI_VISION_PROXY_MODEL) or `config.modelExplicit` (persisted via
1293
- * /multimodal-proxy model|pick) disable both substitutions, so a missing
1303
+ * /multimodal-proxy model|pick) disable all substitutions, so a missing
1294
1304
  * explicit model still surfaces as "Model not found".
1295
1305
  */
1296
1306
  export function applyDefaultModelFallback(
1297
1307
  config: VisionConfig,
1298
1308
  hasModel: (provider: string, modelId: string) => boolean,
1299
1309
  userConfigured = false,
1310
+ hasKey?: (provider: string, modelId: string) => boolean,
1300
1311
  ): VisionConfig {
1301
1312
  if (userConfigured || config.modelExplicit === true) return config;
1302
1313
 
1303
- const isCurrentDefault =
1304
- config.provider === DEFAULT_CONFIG.provider && config.modelId === DEFAULT_CONFIG.modelId;
1305
- if (!isCurrentDefault) {
1306
- const isLegacyDefault = LEGACY_DEFAULT_MODELS.some(
1307
- (m) => m.provider === config.provider && m.modelId === config.modelId,
1308
- );
1309
- if (isLegacyDefault && hasModel(DEFAULT_CONFIG.provider, DEFAULT_CONFIG.modelId)) {
1310
- return { ...config, provider: DEFAULT_CONFIG.provider, modelId: DEFAULT_CONFIG.modelId };
1314
+ const currentDefault = { provider: DEFAULT_CONFIG.provider, modelId: DEFAULT_CONFIG.modelId };
1315
+ const isSame = (m: { provider: string; modelId: string }) =>
1316
+ m.provider === config.provider && m.modelId === config.modelId;
1317
+ const isCurrentDefault = isSame(currentDefault);
1318
+ const isLegacyDefault = LEGACY_DEFAULT_MODELS.some(isSame);
1319
+ if (!isCurrentDefault && !isLegacyDefault) return config;
1320
+
1321
+ // Candidate chain in preference order: current default first, then fallbacks.
1322
+ const chain = [currentDefault, ...DEFAULT_MODEL_FALLBACKS];
1323
+ // "Usable" = in the registry (and keyed, when key information is available).
1324
+ const usable = (m: { provider: string; modelId: string }) =>
1325
+ hasKey ? hasModel(m.provider, m.modelId) && hasKey(m.provider, m.modelId) : hasModel(m.provider, m.modelId);
1326
+
1327
+ if (isLegacyDefault) {
1328
+ // Old rule first: upgrade an implicit legacy default to the current
1329
+ // default when that one is usable.
1330
+ if (usable(currentDefault)) {
1331
+ return { ...config, provider: currentDefault.provider, modelId: currentDefault.modelId };
1311
1332
  }
1333
+ if (hasKey) {
1334
+ // A keyed legacy default keeps working — never trade it for an unkeyed model.
1335
+ if (usable(config)) return config;
1336
+ // Otherwise the first keyed candidate anywhere in the chain wins.
1337
+ for (const cand of chain) {
1338
+ if (usable(cand)) return { ...config, provider: cand.provider, modelId: cand.modelId };
1339
+ }
1340
+ }
1341
+ // No key information (older callers): exact pre-1.18 behavior — only the
1342
+ // current-default upgrade above applies.
1312
1343
  return config;
1313
1344
  }
1314
1345
 
1315
- if (hasModel(config.provider, config.modelId)) return config;
1316
- for (const fb of DEFAULT_MODEL_FALLBACKS) {
1317
- if (hasModel(fb.provider, fb.modelId)) {
1318
- return { ...config, provider: fb.provider, modelId: fb.modelId };
1346
+ // Config holds the current default pair.
1347
+ if (usable(currentDefault)) return config;
1348
+
1349
+ if (hasKey) {
1350
+ // First keyed candidate in the chain wins (current default is not usable).
1351
+ for (const cand of chain) {
1352
+ if (isSame(cand)) continue;
1353
+ if (usable(cand)) return { ...config, provider: cand.provider, modelId: cand.modelId };
1354
+ }
1355
+ // Nothing keyed anywhere: fall through to catalog-only resolution so the
1356
+ // user still gets the actionable "No API key" notice on first use.
1357
+ }
1358
+
1359
+ if (hasModel(currentDefault.provider, currentDefault.modelId)) return config;
1360
+ for (const cand of chain) {
1361
+ if (hasModel(cand.provider, cand.modelId)) {
1362
+ return { ...config, provider: cand.provider, modelId: cand.modelId };
1319
1363
  }
1320
1364
  }
1321
1365
  return config;
@@ -1556,6 +1600,64 @@ function mimeTypeForExt(filePath: string): string | undefined {
1556
1600
  return EXT_TO_MIME[extname(filePath).toLowerCase()];
1557
1601
  }
1558
1602
 
1603
+ // ── Content-sniffed image validation (Pi ≥ 0.84.4) ───────────────────────────────
1604
+
1605
+ /**
1606
+ * Image MIME types Pi's exported content sniffer can validate
1607
+ * (`detectSupportedImageMimeTypeFromFile`, public since Pi 0.84.4). Files
1608
+ * whose extension maps to one of these are rejected when the sniffer says
1609
+ * the bytes are not that kind of image; formats outside this set (tiff, ico,
1610
+ * avif) keep the extension-only behavior.
1611
+ */
1612
+ export const SNIFFABLE_IMAGE_MIMES: ReadonlySet<string> = new Set([
1613
+ "image/jpeg",
1614
+ "image/png",
1615
+ "image/gif",
1616
+ "image/webp",
1617
+ "image/bmp",
1618
+ ]);
1619
+
1620
+ const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
1621
+
1622
+ function hasPngSignature(buffer: Uint8Array): boolean {
1623
+ if (buffer.length < PNG_SIGNATURE.length) return false;
1624
+ return PNG_SIGNATURE.every((byte, i) => buffer[i] === byte);
1625
+ }
1626
+
1627
+ /** Outcome of combining the extension map with a content sniff. */
1628
+ export interface ImageMimeDecision {
1629
+ /** MIME type to use, when the file is accepted. */
1630
+ mime?: string;
1631
+ /** True when the extension claims an image but the contents are not one. */
1632
+ reject?: boolean;
1633
+ }
1634
+
1635
+ /**
1636
+ * Decide an image file's MIME type from its extension and (optionally) a
1637
+ * content sniff:
1638
+ *
1639
+ * - a sniffed type wins over the extension (corrects mis-typed files, e.g. a
1640
+ * JPEG named `.png`);
1641
+ * - a sniff that finds no image rejects files whose extension is inside the
1642
+ * sniffer's coverage — with one carve-out: a file with a PNG signature that
1643
+ * the sniffer rejects as animated (APNG) is still accepted as `image/png`,
1644
+ * matching the pre-sniff behavior for providers that decode the first frame;
1645
+ * - formats outside sniffer coverage, or a missing/unavailable sniffer
1646
+ * (`sniffed === undefined`), keep the extension-only behavior.
1647
+ */
1648
+ export function decideImageMime(
1649
+ extMime: string | undefined,
1650
+ sniffed: string | null | undefined,
1651
+ head: Uint8Array,
1652
+ ): ImageMimeDecision {
1653
+ if (typeof sniffed === "string") return { mime: sniffed };
1654
+ if (sniffed === null) {
1655
+ if (extMime === "image/png" && hasPngSignature(head)) return { mime: "image/png" };
1656
+ if (extMime && SNIFFABLE_IMAGE_MIMES.has(extMime)) return { reject: true };
1657
+ }
1658
+ return { mime: extMime };
1659
+ }
1660
+
1559
1661
  // ── File-path video detection ────────────────────────────────────────────────
1560
1662
 
1561
1663
  const VIDEO_EXT_TO_MIME: Record<string, string> = {
@@ -1791,11 +1893,20 @@ function maxImageFileBytes(): number {
1791
1893
 
1792
1894
  export type ReadImageReason =
1793
1895
  | "not-an-image"
1896
+ | "invalid-image"
1794
1897
  | "denied"
1795
1898
  | "unreadable"
1796
1899
  | "empty"
1797
1900
  | "too-large";
1798
1901
 
1902
+ /**
1903
+ * Optional content sniffer: returns a sniffed MIME type, `null` when the
1904
+ * contents are not a recognizable image, or `undefined` when sniffing is
1905
+ * unavailable (Pi < 0.84.4, or the export failed to load). Backed by Pi's
1906
+ * exported `detectSupportedImageMimeTypeFromFile` at runtime.
1907
+ */
1908
+ export type SniffImageFile = (filePath: string) => Promise<string | null | undefined>;
1909
+
1799
1910
  export interface ReadImageResult {
1800
1911
  image: PiAiImage | null;
1801
1912
  reason?: ReadImageReason;
@@ -1911,10 +2022,18 @@ export async function isPathAllowed(filePath: string, access?: PathAccessOptions
1911
2022
 
1912
2023
  /**
1913
2024
  * Read an image file and return as base64 ImageContent with a structured reason on failure.
1914
- */
1915
- export async function readImageFileWithReason(filePath: string, access?: PathAccessOptions): Promise<ReadImageResult> {
2025
+ *
2026
+ * The MIME type comes from the extension map, validated (and corrected) by a
2027
+ * content sniff when a sniffer is available — see `decideImageMime`. With no
2028
+ * sniffer the behavior is exactly the pre-1.18 extension-only path.
2029
+ */
2030
+ export async function readImageFileWithReason(
2031
+ filePath: string,
2032
+ access?: PathAccessOptions,
2033
+ sniffFile?: SniffImageFile,
2034
+ ): Promise<ReadImageResult> {
1916
2035
  const mimeType = mimeTypeForExt(filePath);
1917
- if (!mimeType) return { image: null, reason: "not-an-image" };
2036
+ if (!mimeType && !sniffFile) return { image: null, reason: "not-an-image" };
1918
2037
  if (!(await isPathAllowed(filePath, access))) return { image: null, reason: "denied" };
1919
2038
  let content: Buffer;
1920
2039
  try {
@@ -1930,8 +2049,22 @@ export async function readImageFileWithReason(filePath: string, access?: PathAcc
1930
2049
  if (content.length === 0) return { image: null, reason: "empty", bytes: 0 };
1931
2050
  const limit = maxImageFileBytes();
1932
2051
  if (content.length > limit) return { image: null, reason: "too-large", bytes: content.length };
2052
+
2053
+ let sniffed: string | null | undefined;
2054
+ if (sniffFile) {
2055
+ try {
2056
+ sniffed = await sniffFile(filePath);
2057
+ } catch {
2058
+ sniffed = undefined; // sniffer failure degrades to extension-only
2059
+ }
2060
+ }
2061
+ const decision = decideImageMime(mimeType, sniffed, content);
2062
+ if (decision.reject) {
2063
+ return { image: null, reason: "invalid-image", bytes: content.length, filename: basename(filePath) };
2064
+ }
2065
+ if (!decision.mime) return { image: null, reason: "not-an-image", bytes: content.length };
1933
2066
  return {
1934
- image: { type: "image", data: content.toString("base64"), mimeType },
2067
+ image: { type: "image", data: content.toString("base64"), mimeType: decision.mime },
1935
2068
  bytes: content.length,
1936
2069
  filename: basename(filePath),
1937
2070
  };
@@ -18,6 +18,8 @@
18
18
  * - persisted pre-consented providers
19
19
  * - use "*" or "all" to consent globally for all providers
20
20
  * /multimodal-proxy tool on|off - enable/disable analyze_image tool
21
+ * /multimodal-proxy doctor - one-shot setup diagnostics
22
+ * /multimodal-proxy test - end-to-end self-test (synthetic image)
21
23
  * /multimodal-proxy max-images-per-call <n>
22
24
  * /multimodal-proxy max-batch <n>
23
25
  * /multimodal-proxy cache-size <n>
@@ -274,6 +276,7 @@ import {
274
276
  canonicalYouTubeUrl,
275
277
  youTubeVideoId,
276
278
  applyDefaultModelFallback,
279
+ DEFAULT_MODEL_FALLBACKS,
277
280
  applyRecallCompletion,
278
281
  buildRecallItems,
279
282
  collectRecallCandidates,
@@ -317,6 +320,7 @@ import {
317
320
  persistedBase,
318
321
  pluralImages,
319
322
  type ReadImageReason,
323
+ type SniffImageFile,
320
324
  readImageFileWithReason,
321
325
  readPersistentFile,
322
326
  resolveConfig,
@@ -447,6 +451,10 @@ interface SessionState {
447
451
  * post-compaction recall digest.
448
452
  */
449
453
  compaction?: { reason?: "manual" | "threshold" | "overflow"; willRetry?: boolean };
454
+ /** Cached "provider/model has an API key" probes (cleared per session). */
455
+ keyProbe?: Map<string, boolean>;
456
+ /** Last implicit-default substitution already notified this session. */
457
+ notifiedDefaultSub?: string;
450
458
  }
451
459
 
452
460
  const _sessionState = new WeakMap<object, SessionState>();
@@ -649,17 +657,94 @@ function shouldStripImages(config: VisionConfig, model: ExtensionContext["model"
649
657
  }
650
658
 
651
659
  /**
652
- * Swap the untouched built-in default vision model for a fallback when the
653
- * running Pi's catalog doesn't know it (e.g. Claude Sonnet 5 on Pi < 0.80.3).
654
- * A model set via PI_VISION_PROXY_MODEL is an explicit user choice and is
655
- * never rewritten, even when it equals the built-in default.
660
+ * Swap the untouched built-in default vision model for a usable one when the
661
+ * running Pi's catalog doesn't know it (e.g. GLM 5.3 Flash on Pi < 0.84.x) or
662
+ * its provider has no API key (1.18.0: key-aware resolution). A model set via
663
+ * PI_VISION_PROXY_MODEL is an explicit user choice and is never rewritten,
664
+ * even when it equals the built-in default.
665
+ *
666
+ * Key probes are cached per session — they are cheap env/file lookups, but
667
+ * this runs on every prompt, tool result, and status refresh.
656
668
  */
657
- function withModelFallback(config: VisionConfig, ctx: ExtensionContext): VisionConfig {
658
- return applyDefaultModelFallback(
659
- config,
660
- (p, m) => Boolean(ctx.modelRegistry.find(p, m)),
661
- envFlags().model,
662
- );
669
+ async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): Promise<VisionConfig> {
670
+ // Explicit choices are never rewritten — skip the key probes entirely.
671
+ if (envFlags().model || config.modelExplicit === true) return config;
672
+
673
+ const hasModel = (p: string, m: string) => Boolean(ctx.modelRegistry.find(p, m));
674
+ const state = getSessionState(ctx);
675
+ state.keyProbe ??= new Map<string, boolean>();
676
+ const probe = async (p: string, m: string): Promise<boolean> => {
677
+ const key = `${p}/${m}`;
678
+ const cached = state.keyProbe!.get(key);
679
+ if (cached !== undefined) return cached;
680
+ let ok = false;
681
+ try {
682
+ const model = ctx.modelRegistry.find(p, m);
683
+ if (model) {
684
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
685
+ ok = auth.ok && Boolean(auth.apiKey);
686
+ }
687
+ } catch {
688
+ ok = false;
689
+ }
690
+ state.keyProbe!.set(key, ok);
691
+ return ok;
692
+ };
693
+
694
+ const pairs = [
695
+ { provider: DEFAULT_CONFIG.provider, modelId: DEFAULT_CONFIG.modelId },
696
+ ...DEFAULT_MODEL_FALLBACKS,
697
+ { provider: config.provider, modelId: config.modelId },
698
+ ];
699
+ const keys = new Map<string, boolean>();
700
+ for (const pair of pairs) {
701
+ keys.set(`${pair.provider}/${pair.modelId}`, await probe(pair.provider, pair.modelId));
702
+ }
703
+ const hasKey = (p: string, m: string) => keys.get(`${p}/${m}`) === true;
704
+ return applyDefaultModelFallback(config, hasModel, false, hasKey);
705
+ }
706
+
707
+ /**
708
+ * Notify (once per session) when implicit-default resolution substituted the
709
+ * configured model — e.g. no zai key → deepseek/deepseek-v4-flash-vision-exp.
710
+ */
711
+ function notifyDefaultSubstitution(config: VisionConfig, resolved: VisionConfig, ctx: ExtensionContext): void {
712
+ // Proxy disabled — model resolution is irrelevant, don't add noise.
713
+ if (resolved.mode === "off") return;
714
+ const state = getSessionState(ctx);
715
+ const before = `${config.provider}/${config.modelId}`;
716
+ const after = `${resolved.provider}/${resolved.modelId}`;
717
+ if (before === after) return;
718
+ const key = `${before}→${after}`;
719
+ if (state.notifiedDefaultSub === key) return;
720
+ state.notifiedDefaultSub = key;
721
+ const missingFromCatalog = !ctx.modelRegistry.find(config.provider, config.modelId);
722
+ const hint = missingFromCatalog
723
+ ? "update pi (older model catalog), or pin a model with /multimodal-proxy model"
724
+ : `add a key: pi --login ${config.provider} — or pin a model with /multimodal-proxy model`;
725
+ ctx.ui.notify(`[multimodal-proxy] Vision default resolved to ${after} (${before} not usable here). ${hint}`, "info");
726
+ }
727
+
728
+ // ── Content sniffing (Pi ≥ 0.84.4) ───────────────────────────────────────
729
+
730
+ /**
731
+ * Lazily resolve Pi's exported `detectSupportedImageMimeTypeFromFile`
732
+ * (public since 0.84.4). Returns undefined when unavailable — callers then
733
+ * degrade to the extension-only MIME path.
734
+ */
735
+ let _sniffImageFile: SniffImageFile | null | undefined;
736
+ async function sniffImageFile(filePath: string): Promise<string | null | undefined> {
737
+ if (_sniffImageFile === undefined) {
738
+ try {
739
+ const mod = (await import("@earendil-works/pi-coding-agent")) as Record<string, unknown>;
740
+ const fn = mod.detectSupportedImageMimeTypeFromFile;
741
+ _sniffImageFile = typeof fn === "function" ? (fn as SniffImageFile) : null;
742
+ } catch {
743
+ _sniffImageFile = null;
744
+ }
745
+ }
746
+ if (_sniffImageFile === null) return undefined;
747
+ return _sniffImageFile(filePath);
663
748
  }
664
749
 
665
750
  /**
@@ -764,6 +849,8 @@ function describeReadReason(reason: ReadImageReason, bytes?: number): string {
764
849
  return `${bytes ?? "?"} bytes exceeds limit (override with PI_VISION_PROXY_MAX_IMAGE_BYTES)`;
765
850
  case "not-an-image":
766
851
  return "unsupported extension";
852
+ case "invalid-image":
853
+ return "file contents are not a recognized image (extension does not match contents — e.g. a non-image renamed to .png)";
767
854
  default:
768
855
  return reason;
769
856
  }
@@ -1567,7 +1654,7 @@ async function handleAnalyzeImage(
1567
1654
  if (ref.includes("..")) {
1568
1655
  return `Error: path contains disallowed ".." segments.`;
1569
1656
  }
1570
- const r = await readImageFileWithReason(ref, pathAccessFromConfig(config));
1657
+ const r = await readImageFileWithReason(ref, pathAccessFromConfig(config), sniffImageFile);
1571
1658
  if (!r.image) {
1572
1659
  return `Error: could not read image: ${describeReadReason(r.reason ?? "not-an-image", r.bytes)}`;
1573
1660
  }
@@ -1862,7 +1949,7 @@ export default function (pi: ExtensionAPI) {
1862
1949
  parameters: AnalyzeImageParams,
1863
1950
  execute: async (_toolCallId, params, _signal, _onUpdate, extCtx) => {
1864
1951
  const entries = extCtx.sessionManager.getEntries();
1865
- const config = withModelFallback(resolveConfig(entries, process.env, _fileConfig), extCtx);
1952
+ const config = await withModelFallback(resolveConfig(entries, process.env, _fileConfig), extCtx);
1866
1953
 
1867
1954
  // Runtime check - tool may have been disabled mid-session
1868
1955
  if (config.tool !== "on" || config.mode === "off") {
@@ -1901,12 +1988,13 @@ export default function (pi: ExtensionAPI) {
1901
1988
  state.imageMeta.clear();
1902
1989
  clearImageData(state.imageData);
1903
1990
  state.compaction = undefined;
1991
+ state.keyProbe = undefined;
1992
+ state.notifiedDefaultSub = undefined;
1904
1993
 
1905
1994
  _fileConfig = await readPersistentFile();
1906
- const config = withModelFallback(
1907
- resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig),
1908
- ctx,
1909
- );
1995
+ const rawConfig = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
1996
+ const config = await withModelFallback(rawConfig, ctx);
1997
+ notifyDefaultSubstitution(rawConfig, config, ctx);
1910
1998
  ctx.ui.setStatus("multimodal-proxy", steadyStatusText(config, ctx.modelRegistry));
1911
1999
 
1912
2000
  // Register tool if enabled
@@ -1931,7 +2019,9 @@ export default function (pi: ExtensionAPI) {
1931
2019
  // Resolve config up front — file loading below honors the configurable
1932
2020
  // folder allowlist (allowedFolders / allowHome).
1933
2021
  const entries = ctx.sessionManager.getEntries();
1934
- const config = withModelFallback(resolveConfig(entries, process.env, _fileConfig), ctx);
2022
+ const rawConfig = resolveConfig(entries, process.env, _fileConfig);
2023
+ const config = await withModelFallback(rawConfig, ctx);
2024
+ notifyDefaultSubstitution(rawConfig, config, ctx);
1935
2025
  const pathAccess = pathAccessFromConfig(config);
1936
2026
 
1937
2027
  // Collect images: structured attachments + file paths detected in prompt
@@ -1944,7 +2034,7 @@ export default function (pi: ExtensionAPI) {
1944
2034
  const acceptedPaths: string[] = [];
1945
2035
  for (const fp of filePaths) {
1946
2036
  if (fp.includes("..")) continue; // defense-in-depth: reject traversal
1947
- const r = await readImageFileWithReason(fp, pathAccess);
2037
+ const r = await readImageFileWithReason(fp, pathAccess, sniffImageFile);
1948
2038
  if (r.image) {
1949
2039
  images.push(r.image);
1950
2040
  acceptedPaths.push(fp);
@@ -2378,7 +2468,7 @@ export default function (pi: ExtensionAPI) {
2378
2468
  if (images.length === 0) return; // fast path: most tool results carry no image
2379
2469
 
2380
2470
  const entries = ctx.sessionManager.getEntries();
2381
- const config = withModelFallback(resolveConfig(entries, process.env, _fileConfig), ctx);
2471
+ const config = await withModelFallback(resolveConfig(entries, process.env, _fileConfig), ctx);
2382
2472
  // Model supports images, or proxy is off → pass the block through unchanged.
2383
2473
  if (!shouldStripImages(config, ctx.model)) return;
2384
2474
 
@@ -2567,6 +2657,138 @@ export default function (pi: ExtensionAPI) {
2567
2657
 
2568
2658
  // ── /multimodal-proxy command ─────────────────────────────────────────
2569
2659
 
2660
+ /**
2661
+ * /multimodal-proxy doctor — one-shot setup diagnostics: effective model
2662
+ * resolution, catalog presence, API keys, consent, fallback and video model,
2663
+ * yt-dlp/ffmpeg availability, path access, and recall-store usage.
2664
+ */
2665
+ const runDoctor = async (ctx: ExtensionContext, entries: readonly SessionEntry[], effective: VisionConfig) => {
2666
+ const lines: string[] = [];
2667
+ const resolved = await withModelFallback(effective, ctx);
2668
+ const explicit = Boolean(envFlags().model) || effective.modelExplicit === true;
2669
+ const substituted =
2670
+ !explicit && (resolved.provider !== effective.provider || resolved.modelId !== effective.modelId);
2671
+ const model = ctx.modelRegistry.find(resolved.provider, resolved.modelId);
2672
+ let keyOk = false;
2673
+ try {
2674
+ if (model) {
2675
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2676
+ keyOk = auth.ok && Boolean(auth.apiKey);
2677
+ }
2678
+ } catch {
2679
+ keyOk = false;
2680
+ }
2681
+ lines.push(`mode: ${modeLabel(resolved.mode)}`);
2682
+ lines.push(
2683
+ `vision model: ${resolved.provider}/${resolved.modelId}` +
2684
+ (explicit ? " (explicit)" : substituted ? " (implicit default, substituted)" : " (implicit default)") +
2685
+ (model ? "" : " — NOT in catalog (update pi, or /multimodal-proxy model)") +
2686
+ (model && !keyOk ? ` — no API key (run: pi --login ${resolved.provider})` : ""),
2687
+ );
2688
+ lines.push(
2689
+ `consent (${resolved.provider}): ` +
2690
+ (hasConsent(entries, resolved.provider, resolved.allowedProviders, resolved.deniedProviders)
2691
+ ? "granted"
2692
+ : "not granted (/multimodal-proxy consent yes)"),
2693
+ );
2694
+ if (resolved.fallbackProvider && resolved.fallbackModelId) {
2695
+ const fb = ctx.modelRegistry.find(resolved.fallbackProvider, resolved.fallbackModelId);
2696
+ lines.push(
2697
+ `fallback model: ${resolved.fallbackProvider}/${resolved.fallbackModelId}` +
2698
+ (fb ? "" : " — NOT in catalog"),
2699
+ );
2700
+ }
2701
+ const videoModel = ctx.modelRegistry.find(resolved.videoProvider, resolved.videoModelId);
2702
+ let videoKeyOk = false;
2703
+ try {
2704
+ if (videoModel) {
2705
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(videoModel);
2706
+ videoKeyOk = auth.ok && Boolean(auth.apiKey);
2707
+ }
2708
+ } catch {
2709
+ videoKeyOk = false;
2710
+ }
2711
+ lines.push(
2712
+ `video model: ${resolved.videoProvider}/${resolved.videoModelId}` +
2713
+ (videoModel ? "" : " — NOT in catalog") +
2714
+ (videoModel && !videoKeyOk ? ` — no API key (run: pi --login ${resolved.videoProvider})` : ""),
2715
+ );
2716
+ const toolVersion = async (bin: string): Promise<string> => {
2717
+ try {
2718
+ const { stdout } = await execFileAsync(bin, ["--version"], { windowsHide: true, timeout: 10_000 });
2719
+ return (stdout.trim().split(/\r?\n/)[0] ?? "").slice(0, 48);
2720
+ } catch {
2721
+ return "";
2722
+ }
2723
+ };
2724
+ const ytdlpVersion = await toolVersion("yt-dlp");
2725
+ lines.push(`yt-dlp: ${ytdlpVersion || "not found (needed for YouTube URLs)"}`);
2726
+ const ffmpegVersion = await toolVersion("ffmpeg");
2727
+ lines.push(`ffmpeg: ${ffmpegVersion || "not found (needed for video duration probing / stream merging)"}`);
2728
+ lines.push(
2729
+ `path detection: ${resolved.pathDetection === "on" ? "ON" : "OFF"}` +
2730
+ ` | allow home: ${resolved.allowHome ? "ON" : "OFF"}` +
2731
+ ` | extra folders: ${resolved.allowedFolders.length}`,
2732
+ );
2733
+ const store = getSessionState(ctx).imageData;
2734
+ lines.push(
2735
+ `recall store: ${store.map.size} image(s), ${(store.totalBytes / 1048576).toFixed(1)} MB retained (in-memory only)`,
2736
+ );
2737
+ ctx.ui.notify(`[multimodal-proxy] doctor\n ${lines.join("\n ")}`, "info");
2738
+ };
2739
+
2740
+ /**
2741
+ * /multimodal-proxy test — end-to-end self-test: synthesizes a tiny image
2742
+ * locally and describes it through the full pipeline (model resolution, key,
2743
+ * consent, retry/backoff, upload path). No file or network fixture needed.
2744
+ */
2745
+ const runSelfTest = async (ctx: ExtensionContext, entries: readonly SessionEntry[], effective: VisionConfig) => {
2746
+ if (effective.mode === "off") {
2747
+ ctx.ui.notify("[multimodal-proxy] Proxy is off — enable with /multimodal-proxy fallback or always.", "warning");
2748
+ return;
2749
+ }
2750
+ const resolved = await withModelFallback(effective, ctx);
2751
+ // Tiny locally generated gradient PNG — no fixture file, no network.
2752
+ const { Image } = await import("imagescript");
2753
+ const img = new Image(96, 96);
2754
+ img.fill(
2755
+ (x, y) => ((0xff << 24) | ((x * 2) << 16) | ((y * 2) << 8) | ((x + y) % 256)) >>> 0,
2756
+ );
2757
+ const encoded = await img.encode();
2758
+ const image: PiAiImage = {
2759
+ type: "image",
2760
+ data: Buffer.from(encoded).toString("base64"),
2761
+ mimeType: "image/png",
2762
+ };
2763
+ if (!(await ensureConsent(resolved, ctx, entries, pi))) {
2764
+ ctx.ui.notify("[multimodal-proxy] Self-test aborted — data-egress consent declined.", "warning");
2765
+ return;
2766
+ }
2767
+ const started = Date.now();
2768
+ const results = await analyzeImages(
2769
+ [image],
2770
+ "This is a connectivity self-test with a locally generated gradient image. Describe it in one short sentence.",
2771
+ "",
2772
+ resolved,
2773
+ ctx,
2774
+ );
2775
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1);
2776
+ const first = results?.[0];
2777
+ if (first?.description) {
2778
+ const snippet = first.description.length > 200 ? `${first.description.slice(0, 200)}…` : first.description;
2779
+ ctx.ui.notify(
2780
+ `[multimodal-proxy] Self-test OK via ${resolved.provider}/${resolved.modelId} in ${elapsed}s:\n"${snippet}"`,
2781
+ "info",
2782
+ );
2783
+ } else {
2784
+ ctx.ui.notify(
2785
+ `[multimodal-proxy] Self-test FAILED in ${elapsed}s — run /multimodal-proxy doctor (model, API key, consent)` +
2786
+ (first?.error ? ` Error: ${first.error}` : ""),
2787
+ "error",
2788
+ );
2789
+ }
2790
+ };
2791
+
2570
2792
  // Register both names — /multimodal-proxy (canonical) and /multimodal-proxy (legacy alias)
2571
2793
  const commandHandler = async (args: string, ctx: ExtensionContext) => {
2572
2794
  const entries = ctx.sessionManager.getEntries();
@@ -2593,10 +2815,15 @@ export default function (pi: ExtensionAPI) {
2593
2815
  writePersistentFile(fileNext);
2594
2816
  _fileConfig = fileNext;
2595
2817
  const eff = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
2596
- ctx.ui.setStatus(
2597
- "multimodal-proxy",
2598
- steadyStatusText(withModelFallback(eff, ctx), ctx.modelRegistry),
2599
- );
2818
+ // Fire-and-forget: writePersisted stays synchronous for its ~30 call
2819
+ // sites; the status refresh just needs the resolved model eventually.
2820
+ void withModelFallback(eff, ctx)
2821
+ .then((resolved) =>
2822
+ ctx.ui.setStatus("multimodal-proxy", steadyStatusText(resolved, ctx.modelRegistry)),
2823
+ )
2824
+ .catch(() => {
2825
+ // Key probing must never surface as an unhandled rejection in daemons.
2826
+ });
2600
2827
  return validated;
2601
2828
  };
2602
2829
 
@@ -3384,6 +3611,17 @@ Use "*" or "all" to grant consent for all providers globally.`,
3384
3611
  }
3385
3612
 
3386
3613
  // ── describe / redescribe ───────────────────────────
3614
+ // ── Diagnostics ────────────────────────────────────────
3615
+ if (sub === "doctor") {
3616
+ await runDoctor(ctx, entries, effective);
3617
+ return;
3618
+ }
3619
+
3620
+ if (sub === "test") {
3621
+ await runSelfTest(ctx, entries, effective);
3622
+ return;
3623
+ }
3624
+
3387
3625
  if (sub === "describe" || sub === "redescribe") {
3388
3626
  if (effective.mode === "off") {
3389
3627
  ctx.ui.notify("[multimodal-proxy] Proxy is off - enable with /multimodal-proxy fallback or /multimodal-proxy always.", "warning");
@@ -3396,7 +3634,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3396
3634
  }
3397
3635
 
3398
3636
  // Resolve model override
3399
- let descConfig = withModelFallback(effective, ctx);
3637
+ let descConfig = await withModelFallback(effective, ctx);
3400
3638
  if (parsed.model) {
3401
3639
  const parsedModel = parseModelString(parsed.model);
3402
3640
  if (!parsedModel) {
@@ -3438,7 +3676,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3438
3676
  ctx.ui.notify(`[multimodal-proxy] Error: path contains disallowed \"..\" segments.`, "error");
3439
3677
  return;
3440
3678
  }
3441
- const r = await readImageFileWithReason(ref, pathAccessFromConfig(effective));
3679
+ const r = await readImageFileWithReason(ref, pathAccessFromConfig(effective), sniffImageFile);
3442
3680
  if (!r.image) {
3443
3681
  ctx.ui.notify(`[multimodal-proxy] Could not read image: ${ref} (${describeReadReason(r.reason ?? "not-an-image", r.bytes)})`, "error");
3444
3682
  return;
@@ -3629,7 +3867,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3629
3867
 
3630
3868
  // ── Interactive config ──────────────────────────────
3631
3869
  // Display the model requests will actually use (registry fallback applied)
3632
- const friendlyEffective = friendlyModelLabel(withModelFallback(effective, ctx), ctx.modelRegistry);
3870
+ const friendlyEffective = friendlyModelLabel(await withModelFallback(effective, ctx), ctx.modelRegistry);
3633
3871
  const activeEnvOverrides = [
3634
3872
  env.mode && "mode", env.model && "model", env.context && "context", env.tool && "tool",
3635
3873
  env.maxImagesPerCall && "maxImagesPerCall", env.maxBatch && "maxBatch", env.cacheSize && "cacheSize",
@@ -3661,7 +3899,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3661
3899
  if (!ctx.hasUI) {
3662
3900
  ctx.ui.notify(
3663
3901
  summary +
3664
- `\nCommands: /multimodal-proxy fallback|always|off | pick | model provider/model-id | video-model provider/model-id | context on|off | consent yes|no|always | allowed-providers add|remove <provider>|clear | tool on|off | max-images-per-call <n> | max-batch <n> | cache-size <n> | folders list|add|remove|reset | allow-home on|off | status on|off | path-detection on|off | fallback-model provider/model-id|clear | retry <0-5> | max-upload <dim|nmb|off>`,
3902
+ `\nCommands: /multimodal-proxy fallback|always|off | pick | model provider/model-id | video-model provider/model-id | context on|off | consent yes|no|always | allowed-providers add|remove <provider>|clear | tool on|off | max-images-per-call <n> | max-batch <n> | cache-size <n> | folders list|add|remove|reset | allow-home on|off | status on|off | path-detection on|off | fallback-model provider/model-id|clear | retry <0-5> | max-upload <dim|nmb|off> | doctor | test`,
3665
3903
  "info",
3666
3904
  );
3667
3905
  return;
@@ -3684,6 +3922,8 @@ Use "*" or "all" to grant consent for all providers globally.`,
3684
3922
  `Path detection: ${effective.pathDetection === "on" ? "ON" : "OFF"}`,
3685
3923
  `Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}`,
3686
3924
  `Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}`,
3925
+ "Run diagnostics (doctor)",
3926
+ "Run self-test (test image)",
3687
3927
  ]);
3688
3928
 
3689
3929
  if (!choice) return;
@@ -3943,6 +4183,16 @@ Use "*" or "all" to grant consent for all providers globally.`,
3943
4183
  );
3944
4184
  return;
3945
4185
  }
4186
+
4187
+ if (choice === "Run diagnostics (doctor)") {
4188
+ await runDoctor(ctx, entries, effective);
4189
+ return;
4190
+ }
4191
+
4192
+ if (choice === "Run self-test (test image)") {
4193
+ await runSelfTest(ctx, entries, effective);
4194
+ return;
4195
+ }
3946
4196
  };
3947
4197
 
3948
4198
  // Register both command names
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.17.0",
3
+ "version": "1.18.1",
4
4
  "description": "Automatic image, video and audio description for any model in Pi. Routes media to a multimodal model and injects descriptions into context.",
5
5
  "keywords": [
6
6
  "pi-package"