pi-multimodal-proxy 1.16.1 → 1.18.0

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,22 @@ 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.0] - 2026-08-30
8
+
9
+ ### Added
10
+
11
+ - **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`.
12
+ - **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.
13
+ - **`/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.
14
+ - **`/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.
15
+ - **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.
16
+
17
+ ## [1.17.0] - 2026-08-27
18
+
19
+ ### Changed
20
+
21
+ - **Default vision model is now GLM 5.3 Flash (`zai/glm-5.3-flash`).** `DEFAULT_CONFIG` ships `zai/glm-5.3-flash` (image input, 1M context, $0.075/M input — $0.25/M output) as the default image-description model, replacing `anthropic/claude-sonnet-5`. Implicit configs keep tracking the package default: `anthropic/claude-sonnet-5` was added to `LEGACY_DEFAULT_MODELS` (so configs that merely inherited Sonnet 5 upgrade to GLM 5.3 Flash once it's in the catalog), `DEFAULT_MODEL_FALLBACKS` now tries `anthropic/claude-sonnet-5` and then `anthropic/claude-sonnet-4-5` on catalogs without GLM 5.3 Flash, and explicit choices (`modelExplicit`, `PI_VISION_PROXY_MODEL`) are never rewritten. README (What's new, env-table default, privacy note, requirements) and the `/multimodal-proxy model` usage example updated to the new default.
22
+
7
23
  ## [1.16.1] - 2026-08-22
8
24
 
9
25
  ### Fixed
package/README.md CHANGED
@@ -8,6 +8,17 @@ 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
+
18
+ ## What's new in 1.17.0
19
+
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`).
21
+
11
22
  ## What's new in 1.16.0
12
23
 
13
24
  Reliability features borrowed from a survey of [atlas-vision-mcp](https://github.com/QuangThai/vision-bridge-mcp) (ideas worth stealing, implementation our own):
@@ -148,6 +159,8 @@ Settings persist across sessions in `~/.pi/agent/multimodal-proxy.json`. Environ
148
159
  /multimodal-proxy allow-home on | off → allow reading media anywhere under your home folder
149
160
  /multimodal-proxy path-detection on | off → auto-load media file paths found in prompt text (off = attachments only)
150
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
151
164
 
152
165
  Legacy alias: /vision-proxy <args> works identically.
153
166
  ```
@@ -157,7 +170,7 @@ Legacy alias: /vision-proxy <args> works identically.
157
170
  | Variable | Values | Default |
158
171
  |----------|--------|---------|
159
172
  | `PI_VISION_PROXY_MODE` | `fallback`, `always`, `off` | `fallback` |
160
- | `PI_VISION_PROXY_MODEL` | `provider/model-id` | `anthropic/claude-sonnet-5` |
173
+ | `PI_VISION_PROXY_MODEL` | `provider/model-id` | `zai/glm-5.3-flash` |
161
174
  | `PI_VISION_PROXY_INCLUDE_CONTEXT` | bool | `true` |
162
175
  | `PI_VISION_PROXY_TOOL` | `on`, `off` | `on` |
163
176
  | `PI_VISION_PROXY_MAX_IMAGES_PER_CALL` | 1–20 | `10` |
@@ -306,7 +319,7 @@ When a model is in the grounding registry, a format-specific instruction is appe
306
319
 
307
320
  ## Privacy & security
308
321
 
309
- This extension **sends data to a third-party provider**. By default that is `anthropic/claude-sonnet-5` for images (`anthropic/claude-sonnet-4-5` on older Pi versions without Sonnet 5 in the catalog) and `xai/grok-4.3` for video/audio. Be aware:
322
+ This extension **sends data to a third-party provider**. By default that is `zai/glm-5.3-flash` for images (`anthropic/claude-sonnet-5` on older Pi versions without GLM 5.3 Flash in the catalog, then `anthropic/claude-sonnet-4-5`) and `xai/grok-4.3` for video/audio. Be aware:
310
323
 
311
324
  1. **Image and video data is uploaded** to the configured provider on every proxied request. Crop coordinates are applied locally before upload — only the cropped region is sent.
312
325
  2. **Recent conversation context** (last 8 messages, truncated) is uploaded with the image unless you set `/multimodal-proxy context off` or `PI_VISION_PROXY_INCLUDE_CONTEXT=false`. Disable it for sensitive sessions.
@@ -325,9 +338,10 @@ For the full security audit see [`SECURITY-REVIEW.md`](./SECURITY-REVIEW.md).
325
338
 
326
339
  ## Requirements
327
340
 
328
- - A vision-capable model with a valid API key (e.g. 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)
329
342
  - For video/audio: a multimodal model that supports video input (e.g. Grok 4.3, Gemini 2.5 Pro)
330
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
331
345
 
332
346
  ## License
333
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([], {});
@@ -681,8 +681,8 @@ export const DEFAULT_VIDEO_SYSTEM_PROMPT = [
681
681
 
682
682
  export const DEFAULT_CONFIG: VisionConfig = {
683
683
  mode: "fallback",
684
- provider: "anthropic",
685
- modelId: "claude-sonnet-5",
684
+ provider: "zai",
685
+ modelId: "glm-5.3-flash",
686
686
  systemPrompt: [
687
687
  "You are a precise image analysis assistant.",
688
688
  "Describe the image factually for a downstream agent that may act on the description.",
@@ -1261,9 +1261,14 @@ 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 < 0.80.3 catalogs without Claude Sonnet 5.
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
1271
+ { provider: "anthropic", modelId: "claude-sonnet-5" },
1267
1272
  { provider: "anthropic", modelId: "claude-sonnet-4-5" },
1268
1273
  ];
1269
1274
 
@@ -1274,6 +1279,7 @@ export const DEFAULT_MODEL_FALLBACKS: ReadonlyArray<{ provider: string; modelId:
1274
1279
  * model" and may track the current package default.
1275
1280
  */
1276
1281
  export const LEGACY_DEFAULT_MODELS: ReadonlyArray<{ provider: string; modelId: string }> = [
1282
+ { provider: "anthropic", modelId: "claude-sonnet-5" },
1277
1283
  { provider: "anthropic", modelId: "claude-sonnet-4-5" },
1278
1284
  ];
1279
1285
 
@@ -1284,36 +1290,76 @@ export const LEGACY_DEFAULT_MODELS: ReadonlyArray<{ provider: string; modelId: s
1284
1290
  * - a legacy baked-in default is upgraded to the current package default when
1285
1291
  * the registry has it (otherwise it keeps working as-is);
1286
1292
  * - the current default is substituted with the first available fallback when
1287
- * 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).
1288
1300
  *
1289
1301
  * Explicit choices are never rewritten: `userConfigured` (the caller saw
1290
1302
  * PI_VISION_PROXY_MODEL) or `config.modelExplicit` (persisted via
1291
- * /multimodal-proxy model|pick) disable both substitutions, so a missing
1303
+ * /multimodal-proxy model|pick) disable all substitutions, so a missing
1292
1304
  * explicit model still surfaces as "Model not found".
1293
1305
  */
1294
1306
  export function applyDefaultModelFallback(
1295
1307
  config: VisionConfig,
1296
1308
  hasModel: (provider: string, modelId: string) => boolean,
1297
1309
  userConfigured = false,
1310
+ hasKey?: (provider: string, modelId: string) => boolean,
1298
1311
  ): VisionConfig {
1299
1312
  if (userConfigured || config.modelExplicit === true) return config;
1300
1313
 
1301
- const isCurrentDefault =
1302
- config.provider === DEFAULT_CONFIG.provider && config.modelId === DEFAULT_CONFIG.modelId;
1303
- if (!isCurrentDefault) {
1304
- const isLegacyDefault = LEGACY_DEFAULT_MODELS.some(
1305
- (m) => m.provider === config.provider && m.modelId === config.modelId,
1306
- );
1307
- if (isLegacyDefault && hasModel(DEFAULT_CONFIG.provider, DEFAULT_CONFIG.modelId)) {
1308
- 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 };
1309
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.
1310
1343
  return config;
1311
1344
  }
1312
1345
 
1313
- if (hasModel(config.provider, config.modelId)) return config;
1314
- for (const fb of DEFAULT_MODEL_FALLBACKS) {
1315
- if (hasModel(fb.provider, fb.modelId)) {
1316
- 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 };
1317
1363
  }
1318
1364
  }
1319
1365
  return config;
@@ -1554,6 +1600,64 @@ function mimeTypeForExt(filePath: string): string | undefined {
1554
1600
  return EXT_TO_MIME[extname(filePath).toLowerCase()];
1555
1601
  }
1556
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
+
1557
1661
  // ── File-path video detection ────────────────────────────────────────────────
1558
1662
 
1559
1663
  const VIDEO_EXT_TO_MIME: Record<string, string> = {
@@ -1789,11 +1893,20 @@ function maxImageFileBytes(): number {
1789
1893
 
1790
1894
  export type ReadImageReason =
1791
1895
  | "not-an-image"
1896
+ | "invalid-image"
1792
1897
  | "denied"
1793
1898
  | "unreadable"
1794
1899
  | "empty"
1795
1900
  | "too-large";
1796
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
+
1797
1910
  export interface ReadImageResult {
1798
1911
  image: PiAiImage | null;
1799
1912
  reason?: ReadImageReason;
@@ -1909,10 +2022,18 @@ export async function isPathAllowed(filePath: string, access?: PathAccessOptions
1909
2022
 
1910
2023
  /**
1911
2024
  * Read an image file and return as base64 ImageContent with a structured reason on failure.
1912
- */
1913
- 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> {
1914
2035
  const mimeType = mimeTypeForExt(filePath);
1915
- if (!mimeType) return { image: null, reason: "not-an-image" };
2036
+ if (!mimeType && !sniffFile) return { image: null, reason: "not-an-image" };
1916
2037
  if (!(await isPathAllowed(filePath, access))) return { image: null, reason: "denied" };
1917
2038
  let content: Buffer;
1918
2039
  try {
@@ -1928,8 +2049,22 @@ export async function readImageFileWithReason(filePath: string, access?: PathAcc
1928
2049
  if (content.length === 0) return { image: null, reason: "empty", bytes: 0 };
1929
2050
  const limit = maxImageFileBytes();
1930
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 };
1931
2066
  return {
1932
- image: { type: "image", data: content.toString("base64"), mimeType },
2067
+ image: { type: "image", data: content.toString("base64"), mimeType: decision.mime },
1933
2068
  bytes: content.length,
1934
2069
  filename: basename(filePath),
1935
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,19 +657,93 @@ 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.
668
+ */
669
+ async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): Promise<VisionConfig> {
670
+ const hasModel = (p: string, m: string) => Boolean(ctx.modelRegistry.find(p, m));
671
+ if (envFlags().model) return applyDefaultModelFallback(config, hasModel, true);
672
+
673
+ const state = getSessionState(ctx);
674
+ state.keyProbe ??= new Map<string, boolean>();
675
+ const probe = async (p: string, m: string): Promise<boolean> => {
676
+ const key = `${p}/${m}`;
677
+ const cached = state.keyProbe!.get(key);
678
+ if (cached !== undefined) return cached;
679
+ let ok = false;
680
+ try {
681
+ const model = ctx.modelRegistry.find(p, m);
682
+ if (model) {
683
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
684
+ ok = auth.ok && Boolean(auth.apiKey);
685
+ }
686
+ } catch {
687
+ ok = false;
688
+ }
689
+ state.keyProbe!.set(key, ok);
690
+ return ok;
691
+ };
692
+
693
+ const pairs = [
694
+ { provider: DEFAULT_CONFIG.provider, modelId: DEFAULT_CONFIG.modelId },
695
+ ...DEFAULT_MODEL_FALLBACKS,
696
+ { provider: config.provider, modelId: config.modelId },
697
+ ];
698
+ const keys = new Map<string, boolean>();
699
+ for (const pair of pairs) {
700
+ keys.set(`${pair.provider}/${pair.modelId}`, await probe(pair.provider, pair.modelId));
701
+ }
702
+ const hasKey = (p: string, m: string) => keys.get(`${p}/${m}`) === true;
703
+ return applyDefaultModelFallback(config, hasModel, false, hasKey);
704
+ }
705
+
706
+ /**
707
+ * Notify (once per session) when implicit-default resolution substituted the
708
+ * configured model — e.g. no zai key → deepseek/deepseek-v4-flash-vision-exp.
656
709
  */
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,
710
+ function notifyDefaultSubstitution(config: VisionConfig, resolved: VisionConfig, ctx: ExtensionContext): void {
711
+ const state = getSessionState(ctx);
712
+ const before = `${config.provider}/${config.modelId}`;
713
+ const after = `${resolved.provider}/${resolved.modelId}`;
714
+ if (before === after) return;
715
+ const key = `${before}→${after}`;
716
+ if (state.notifiedDefaultSub === key) return;
717
+ state.notifiedDefaultSub = key;
718
+ ctx.ui.notify(
719
+ `[multimodal-proxy] Vision default resolved to ${after} (${before} not usable here). ` +
720
+ `Pin a model with /multimodal-proxy model, or add a key: pi --login ${config.provider}`,
721
+ "info",
662
722
  );
663
723
  }
664
724
 
725
+ // ── Content sniffing (Pi ≥ 0.84.4) ───────────────────────────────────────
726
+
727
+ /**
728
+ * Lazily resolve Pi's exported `detectSupportedImageMimeTypeFromFile`
729
+ * (public since 0.84.4). Returns undefined when unavailable — callers then
730
+ * degrade to the extension-only MIME path.
731
+ */
732
+ let _sniffImageFile: SniffImageFile | null | undefined;
733
+ async function sniffImageFile(filePath: string): Promise<string | null | undefined> {
734
+ if (_sniffImageFile === undefined) {
735
+ try {
736
+ const mod = (await import("@earendil-works/pi-coding-agent")) as Record<string, unknown>;
737
+ const fn = mod.detectSupportedImageMimeTypeFromFile;
738
+ _sniffImageFile = typeof fn === "function" ? (fn as SniffImageFile) : null;
739
+ } catch {
740
+ _sniffImageFile = null;
741
+ }
742
+ }
743
+ if (_sniffImageFile === null) return undefined;
744
+ return _sniffImageFile(filePath);
745
+ }
746
+
665
747
  /**
666
748
  * Parse a max-upload value for the /multimodal-proxy max-upload subcommand
667
749
  * and menu: "<dim>" px long-edge, "<n>mb" byte budget, or "off" (both limits
@@ -764,6 +846,8 @@ function describeReadReason(reason: ReadImageReason, bytes?: number): string {
764
846
  return `${bytes ?? "?"} bytes exceeds limit (override with PI_VISION_PROXY_MAX_IMAGE_BYTES)`;
765
847
  case "not-an-image":
766
848
  return "unsupported extension";
849
+ case "invalid-image":
850
+ return "file contents are not a recognized image (extension does not match contents — e.g. a non-image renamed to .png)";
767
851
  default:
768
852
  return reason;
769
853
  }
@@ -1567,7 +1651,7 @@ async function handleAnalyzeImage(
1567
1651
  if (ref.includes("..")) {
1568
1652
  return `Error: path contains disallowed ".." segments.`;
1569
1653
  }
1570
- const r = await readImageFileWithReason(ref, pathAccessFromConfig(config));
1654
+ const r = await readImageFileWithReason(ref, pathAccessFromConfig(config), sniffImageFile);
1571
1655
  if (!r.image) {
1572
1656
  return `Error: could not read image: ${describeReadReason(r.reason ?? "not-an-image", r.bytes)}`;
1573
1657
  }
@@ -1862,7 +1946,7 @@ export default function (pi: ExtensionAPI) {
1862
1946
  parameters: AnalyzeImageParams,
1863
1947
  execute: async (_toolCallId, params, _signal, _onUpdate, extCtx) => {
1864
1948
  const entries = extCtx.sessionManager.getEntries();
1865
- const config = withModelFallback(resolveConfig(entries, process.env, _fileConfig), extCtx);
1949
+ const config = await withModelFallback(resolveConfig(entries, process.env, _fileConfig), extCtx);
1866
1950
 
1867
1951
  // Runtime check - tool may have been disabled mid-session
1868
1952
  if (config.tool !== "on" || config.mode === "off") {
@@ -1901,12 +1985,13 @@ export default function (pi: ExtensionAPI) {
1901
1985
  state.imageMeta.clear();
1902
1986
  clearImageData(state.imageData);
1903
1987
  state.compaction = undefined;
1988
+ state.keyProbe = undefined;
1989
+ state.notifiedDefaultSub = undefined;
1904
1990
 
1905
1991
  _fileConfig = await readPersistentFile();
1906
- const config = withModelFallback(
1907
- resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig),
1908
- ctx,
1909
- );
1992
+ const rawConfig = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
1993
+ const config = await withModelFallback(rawConfig, ctx);
1994
+ notifyDefaultSubstitution(rawConfig, config, ctx);
1910
1995
  ctx.ui.setStatus("multimodal-proxy", steadyStatusText(config, ctx.modelRegistry));
1911
1996
 
1912
1997
  // Register tool if enabled
@@ -1931,7 +2016,9 @@ export default function (pi: ExtensionAPI) {
1931
2016
  // Resolve config up front — file loading below honors the configurable
1932
2017
  // folder allowlist (allowedFolders / allowHome).
1933
2018
  const entries = ctx.sessionManager.getEntries();
1934
- const config = withModelFallback(resolveConfig(entries, process.env, _fileConfig), ctx);
2019
+ const rawConfig = resolveConfig(entries, process.env, _fileConfig);
2020
+ const config = await withModelFallback(rawConfig, ctx);
2021
+ notifyDefaultSubstitution(rawConfig, config, ctx);
1935
2022
  const pathAccess = pathAccessFromConfig(config);
1936
2023
 
1937
2024
  // Collect images: structured attachments + file paths detected in prompt
@@ -1944,7 +2031,7 @@ export default function (pi: ExtensionAPI) {
1944
2031
  const acceptedPaths: string[] = [];
1945
2032
  for (const fp of filePaths) {
1946
2033
  if (fp.includes("..")) continue; // defense-in-depth: reject traversal
1947
- const r = await readImageFileWithReason(fp, pathAccess);
2034
+ const r = await readImageFileWithReason(fp, pathAccess, sniffImageFile);
1948
2035
  if (r.image) {
1949
2036
  images.push(r.image);
1950
2037
  acceptedPaths.push(fp);
@@ -2378,7 +2465,7 @@ export default function (pi: ExtensionAPI) {
2378
2465
  if (images.length === 0) return; // fast path: most tool results carry no image
2379
2466
 
2380
2467
  const entries = ctx.sessionManager.getEntries();
2381
- const config = withModelFallback(resolveConfig(entries, process.env, _fileConfig), ctx);
2468
+ const config = await withModelFallback(resolveConfig(entries, process.env, _fileConfig), ctx);
2382
2469
  // Model supports images, or proxy is off → pass the block through unchanged.
2383
2470
  if (!shouldStripImages(config, ctx.model)) return;
2384
2471
 
@@ -2567,6 +2654,138 @@ export default function (pi: ExtensionAPI) {
2567
2654
 
2568
2655
  // ── /multimodal-proxy command ─────────────────────────────────────────
2569
2656
 
2657
+ /**
2658
+ * /multimodal-proxy doctor — one-shot setup diagnostics: effective model
2659
+ * resolution, catalog presence, API keys, consent, fallback and video model,
2660
+ * yt-dlp/ffmpeg availability, path access, and recall-store usage.
2661
+ */
2662
+ const runDoctor = async (ctx: ExtensionContext, entries: readonly SessionEntry[], effective: VisionConfig) => {
2663
+ const lines: string[] = [];
2664
+ const resolved = await withModelFallback(effective, ctx);
2665
+ const explicit = Boolean(envFlags().model) || effective.modelExplicit === true;
2666
+ const substituted =
2667
+ !explicit && (resolved.provider !== effective.provider || resolved.modelId !== effective.modelId);
2668
+ const model = ctx.modelRegistry.find(resolved.provider, resolved.modelId);
2669
+ let keyOk = false;
2670
+ try {
2671
+ if (model) {
2672
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2673
+ keyOk = auth.ok && Boolean(auth.apiKey);
2674
+ }
2675
+ } catch {
2676
+ keyOk = false;
2677
+ }
2678
+ lines.push(`mode: ${modeLabel(resolved.mode)}`);
2679
+ lines.push(
2680
+ `vision model: ${resolved.provider}/${resolved.modelId}` +
2681
+ (explicit ? " (explicit)" : substituted ? " (implicit default, substituted)" : " (implicit default)") +
2682
+ (model ? "" : " — NOT in catalog (update pi, or /multimodal-proxy model)") +
2683
+ (model && !keyOk ? ` — no API key (run: pi --login ${resolved.provider})` : ""),
2684
+ );
2685
+ lines.push(
2686
+ `consent (${resolved.provider}): ` +
2687
+ (hasConsent(entries, resolved.provider, resolved.allowedProviders, resolved.deniedProviders)
2688
+ ? "granted"
2689
+ : "not granted (/multimodal-proxy consent yes)"),
2690
+ );
2691
+ if (resolved.fallbackProvider && resolved.fallbackModelId) {
2692
+ const fb = ctx.modelRegistry.find(resolved.fallbackProvider, resolved.fallbackModelId);
2693
+ lines.push(
2694
+ `fallback model: ${resolved.fallbackProvider}/${resolved.fallbackModelId}` +
2695
+ (fb ? "" : " — NOT in catalog"),
2696
+ );
2697
+ }
2698
+ const videoModel = ctx.modelRegistry.find(resolved.videoProvider, resolved.videoModelId);
2699
+ let videoKeyOk = false;
2700
+ try {
2701
+ if (videoModel) {
2702
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(videoModel);
2703
+ videoKeyOk = auth.ok && Boolean(auth.apiKey);
2704
+ }
2705
+ } catch {
2706
+ videoKeyOk = false;
2707
+ }
2708
+ lines.push(
2709
+ `video model: ${resolved.videoProvider}/${resolved.videoModelId}` +
2710
+ (videoModel ? "" : " — NOT in catalog") +
2711
+ (videoModel && !videoKeyOk ? ` — no API key (run: pi --login ${resolved.videoProvider})` : ""),
2712
+ );
2713
+ const toolVersion = async (bin: string): Promise<string> => {
2714
+ try {
2715
+ const { stdout } = await execFileAsync(bin, ["--version"], { windowsHide: true, timeout: 10_000 });
2716
+ return (stdout.trim().split(/\r?\n/)[0] ?? "").slice(0, 48);
2717
+ } catch {
2718
+ return "";
2719
+ }
2720
+ };
2721
+ const ytdlpVersion = await toolVersion("yt-dlp");
2722
+ lines.push(`yt-dlp: ${ytdlpVersion || "not found (needed for YouTube URLs)"}`);
2723
+ const ffmpegVersion = await toolVersion("ffmpeg");
2724
+ lines.push(`ffmpeg: ${ffmpegVersion || "not found (needed for video duration probing / stream merging)"}`);
2725
+ lines.push(
2726
+ `path detection: ${resolved.pathDetection === "on" ? "ON" : "OFF"}` +
2727
+ ` | allow home: ${resolved.allowHome ? "ON" : "OFF"}` +
2728
+ ` | extra folders: ${resolved.allowedFolders.length}`,
2729
+ );
2730
+ const store = getSessionState(ctx).imageData;
2731
+ lines.push(
2732
+ `recall store: ${store.map.size} image(s), ${(store.totalBytes / 1048576).toFixed(1)} MB retained (in-memory only)`,
2733
+ );
2734
+ ctx.ui.notify(`[multimodal-proxy] doctor\n ${lines.join("\n ")}`, "info");
2735
+ };
2736
+
2737
+ /**
2738
+ * /multimodal-proxy test — end-to-end self-test: synthesizes a tiny image
2739
+ * locally and describes it through the full pipeline (model resolution, key,
2740
+ * consent, retry/backoff, upload path). No file or network fixture needed.
2741
+ */
2742
+ const runSelfTest = async (ctx: ExtensionContext, entries: readonly SessionEntry[], effective: VisionConfig) => {
2743
+ if (effective.mode === "off") {
2744
+ ctx.ui.notify("[multimodal-proxy] Proxy is off — enable with /multimodal-proxy fallback or always.", "warning");
2745
+ return;
2746
+ }
2747
+ const resolved = await withModelFallback(effective, ctx);
2748
+ // Tiny locally generated gradient PNG — no fixture file, no network.
2749
+ const { Image } = await import("imagescript");
2750
+ const img = new Image(96, 96);
2751
+ img.fill(
2752
+ (x, y) => ((0xff << 24) | ((x * 2) << 16) | ((y * 2) << 8) | ((x + y) % 256)) >>> 0,
2753
+ );
2754
+ const encoded = await img.encode();
2755
+ const image: PiAiImage = {
2756
+ type: "image",
2757
+ data: Buffer.from(encoded).toString("base64"),
2758
+ mimeType: "image/png",
2759
+ };
2760
+ if (!(await ensureConsent(resolved, ctx, entries, pi))) {
2761
+ ctx.ui.notify("[multimodal-proxy] Self-test aborted — data-egress consent declined.", "warning");
2762
+ return;
2763
+ }
2764
+ const started = Date.now();
2765
+ const results = await analyzeImages(
2766
+ [image],
2767
+ "This is a connectivity self-test with a locally generated gradient image. Describe it in one short sentence.",
2768
+ "",
2769
+ resolved,
2770
+ ctx,
2771
+ );
2772
+ const elapsed = ((Date.now() - started) / 1000).toFixed(1);
2773
+ const first = results?.[0];
2774
+ if (first?.description) {
2775
+ const snippet = first.description.length > 200 ? `${first.description.slice(0, 200)}…` : first.description;
2776
+ ctx.ui.notify(
2777
+ `[multimodal-proxy] Self-test OK via ${resolved.provider}/${resolved.modelId} in ${elapsed}s:\n"${snippet}"`,
2778
+ "info",
2779
+ );
2780
+ } else {
2781
+ ctx.ui.notify(
2782
+ `[multimodal-proxy] Self-test FAILED in ${elapsed}s — run /multimodal-proxy doctor (model, API key, consent)` +
2783
+ (first?.error ? ` Error: ${first.error}` : ""),
2784
+ "error",
2785
+ );
2786
+ }
2787
+ };
2788
+
2570
2789
  // Register both names — /multimodal-proxy (canonical) and /multimodal-proxy (legacy alias)
2571
2790
  const commandHandler = async (args: string, ctx: ExtensionContext) => {
2572
2791
  const entries = ctx.sessionManager.getEntries();
@@ -2593,9 +2812,10 @@ export default function (pi: ExtensionAPI) {
2593
2812
  writePersistentFile(fileNext);
2594
2813
  _fileConfig = fileNext;
2595
2814
  const eff = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
2596
- ctx.ui.setStatus(
2597
- "multimodal-proxy",
2598
- steadyStatusText(withModelFallback(eff, ctx), ctx.modelRegistry),
2815
+ // Fire-and-forget: writePersisted stays synchronous for its ~30 call
2816
+ // sites; the status refresh just needs the resolved model eventually.
2817
+ void withModelFallback(eff, ctx).then((resolved) =>
2818
+ ctx.ui.setStatus("multimodal-proxy", steadyStatusText(resolved, ctx.modelRegistry)),
2599
2819
  );
2600
2820
  return validated;
2601
2821
  };
@@ -2653,7 +2873,7 @@ export default function (pi: ExtensionAPI) {
2653
2873
  const parsed = parseModelString(value);
2654
2874
  if (!parsed) {
2655
2875
  ctx.ui.notify(
2656
- "Usage: /multimodal-proxy model provider/model-id\nExample: /multimodal-proxy model anthropic/claude-sonnet-5",
2876
+ "Usage: /multimodal-proxy model provider/model-id\nExample: /multimodal-proxy model zai/glm-5.3-flash",
2657
2877
  "warning",
2658
2878
  );
2659
2879
  return;
@@ -3384,6 +3604,17 @@ Use "*" or "all" to grant consent for all providers globally.`,
3384
3604
  }
3385
3605
 
3386
3606
  // ── describe / redescribe ───────────────────────────
3607
+ // ── Diagnostics ────────────────────────────────────────
3608
+ if (sub === "doctor") {
3609
+ await runDoctor(ctx, entries, effective);
3610
+ return;
3611
+ }
3612
+
3613
+ if (sub === "test") {
3614
+ await runSelfTest(ctx, entries, effective);
3615
+ return;
3616
+ }
3617
+
3387
3618
  if (sub === "describe" || sub === "redescribe") {
3388
3619
  if (effective.mode === "off") {
3389
3620
  ctx.ui.notify("[multimodal-proxy] Proxy is off - enable with /multimodal-proxy fallback or /multimodal-proxy always.", "warning");
@@ -3396,7 +3627,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3396
3627
  }
3397
3628
 
3398
3629
  // Resolve model override
3399
- let descConfig = withModelFallback(effective, ctx);
3630
+ let descConfig = await withModelFallback(effective, ctx);
3400
3631
  if (parsed.model) {
3401
3632
  const parsedModel = parseModelString(parsed.model);
3402
3633
  if (!parsedModel) {
@@ -3438,7 +3669,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3438
3669
  ctx.ui.notify(`[multimodal-proxy] Error: path contains disallowed \"..\" segments.`, "error");
3439
3670
  return;
3440
3671
  }
3441
- const r = await readImageFileWithReason(ref, pathAccessFromConfig(effective));
3672
+ const r = await readImageFileWithReason(ref, pathAccessFromConfig(effective), sniffImageFile);
3442
3673
  if (!r.image) {
3443
3674
  ctx.ui.notify(`[multimodal-proxy] Could not read image: ${ref} (${describeReadReason(r.reason ?? "not-an-image", r.bytes)})`, "error");
3444
3675
  return;
@@ -3629,7 +3860,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3629
3860
 
3630
3861
  // ── Interactive config ──────────────────────────────
3631
3862
  // Display the model requests will actually use (registry fallback applied)
3632
- const friendlyEffective = friendlyModelLabel(withModelFallback(effective, ctx), ctx.modelRegistry);
3863
+ const friendlyEffective = friendlyModelLabel(await withModelFallback(effective, ctx), ctx.modelRegistry);
3633
3864
  const activeEnvOverrides = [
3634
3865
  env.mode && "mode", env.model && "model", env.context && "context", env.tool && "tool",
3635
3866
  env.maxImagesPerCall && "maxImagesPerCall", env.maxBatch && "maxBatch", env.cacheSize && "cacheSize",
@@ -3661,7 +3892,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3661
3892
  if (!ctx.hasUI) {
3662
3893
  ctx.ui.notify(
3663
3894
  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>`,
3895
+ `\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
3896
  "info",
3666
3897
  );
3667
3898
  return;
@@ -3684,6 +3915,8 @@ Use "*" or "all" to grant consent for all providers globally.`,
3684
3915
  `Path detection: ${effective.pathDetection === "on" ? "ON" : "OFF"}`,
3685
3916
  `Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}`,
3686
3917
  `Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}`,
3918
+ "Run diagnostics (doctor)",
3919
+ "Run self-test (test image)",
3687
3920
  ]);
3688
3921
 
3689
3922
  if (!choice) return;
@@ -3943,6 +4176,16 @@ Use "*" or "all" to grant consent for all providers globally.`,
3943
4176
  );
3944
4177
  return;
3945
4178
  }
4179
+
4180
+ if (choice === "Run diagnostics (doctor)") {
4181
+ await runDoctor(ctx, entries, effective);
4182
+ return;
4183
+ }
4184
+
4185
+ if (choice === "Run self-test (test image)") {
4186
+ await runSelfTest(ctx, entries, effective);
4187
+ return;
4188
+ }
3946
4189
  };
3947
4190
 
3948
4191
  // Register both command names
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.16.1",
3
+ "version": "1.18.0",
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"