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 +20 -0
- package/README.md +11 -1
- package/extensions/__tests__/internal.test.ts +204 -1
- package/extensions/internal.ts +152 -19
- package/extensions/vision-proxy.ts +277 -27
- package/package.json +1 -1
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([], {});
|
package/extensions/internal.ts
CHANGED
|
@@ -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
|
|
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
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
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
|
-
|
|
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
|
|
653
|
-
* running Pi's catalog doesn't know it (e.g.
|
|
654
|
-
*
|
|
655
|
-
*
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
1907
|
-
|
|
1908
|
-
|
|
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
|
|
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
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
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.
|
|
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"
|