pi-multimodal-proxy 1.18.1 → 1.19.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,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.19.0] - 2026-10-01
8
+
9
+ ### Added
10
+
11
+ - **Virtual model — `Multimodal Auto`** (`multimodal-proxy/auto`, Pi ≥ 0.99, feature-detected). The extension registers a virtual model via `pi.registerVirtualModel()`; selecting it in `/model` routes each request: turns whose pending user input carries image blocks go natively to the configured vision model (real pixels, no description fence), everything else goes to the base model — the pinned `virtual-base` model, else the physical model that answered last (`request.previous`), else the vision model. Sticky routing (`retry` → `failed`, `continuation`/`direct` → `previous`) keeps prompt caches and thinking signatures valid. `route()` resolves config per request (no registration churn on model changes) and throws an actionable error pointing at `/multimodal-proxy pick` when no vision model resolves. New commands `/multimodal-proxy virtual on|off` (default on; `unregisterVirtualModel` when off), `/multimodal-proxy virtual-base <provider/model-id>|off`, env overrides `PI_VISION_PROXY_VIRTUAL` / `PI_VISION_PROXY_VIRTUAL_BASE`, status-line `[+virtual]` marker, interactive-menu entry, and summary/help lines. With the virtual model selected, image stripping never applies (it advertises image input); video/audio detection, `analyze_image`, and consent gates are unchanged.
12
+ - **`analyze_image` adopts the Pi 0.99 extension tool API** (older hosts ignore the new fields): results carry `isError: true` for failures instead of "Error: …" text masquerading as content (all ~18 failure paths now route through an `AnalyzeImageOutcome`), successful calls return an `outputSchema`-shaped `structuredContent` — analysis text, `cached`, fallback-aware `provider`/`model`, `latencyMs`, `groundingFormat`, and per-image `id`/`filename`/dimensions/absolute-pixel `crop` — so codemode scripts and other `ctx.executeTool()` callers receive machine-readable results, and MCP-style `annotations` (`readOnlyHint: true`, `openWorldHint: true`) plus a `multimodal-proxy` namespace group the tool in codemode listings.
13
+ - **Model-aware upload downscale** (Pi ≥ 0.87). `modelImageResizeLimits()` reads the vision model catalog entry's `inputLimits.images.resize` (`maxWidth`/`maxHeight`/`maxBytes`/`jpegQuality`, all optional) and `downscaleForUpload()` tightens the user's `maxUploadDim`/`maxUploadBytes`/quality with it — the derived long-edge target is `min(maxWidth, maxHeight)`, the base64 `maxBytes` budget is converted to raw bytes (×0.75) before mixing with the user's raw budget, and a user-disabled downscale (`maxUploadDim: 0`) always wins. `downscaleImage` gained a `quality` parameter threaded through both the worker task and the in-thread path. All four upload paths (auto-proxy, `analyze_image`, joint descriptions, `/describe`) pass the resolved vision model's limits.
14
+ - **Compaction-proof, cache-stable recall hint** (Pi ≥ 0.87). A `context_with_system` handler — probed on first invocation so the affordance never appears twice — takes over the `analyze_image` recall affordance once media has been described: a stable system-layer block replaces the per-user-message restatement. It survives compaction (which summarized the old hint away) and reaches vision-capable session models, where the strip path never fired. Older hosts keep the exact previous behavior.
15
+
16
+ ### Changed
17
+
18
+ - `envFlags()` reports two new presence flags (`virtual`, `virtualBase`); the config summary, no-UI command list, and interactive menu show the virtual-model state (including a "(needs pi ≥ 0.99)" qualifier when registered on an older host).
19
+ - Six new tests: env override + flag presence for the virtual vars, `modelImageResizeLimits` derivation (null cases, box target, quality default, raw-byte conversion), model-limits tightening vs. user-disabled downscale vs. in-budget images, and sanitize validation of the new fields (494 total, all green).
20
+
21
+ ### Fixed (PR #34 review)
22
+
23
+ - **`modelImageResizeLimits` no longer drops maxBytes-only declarations** — a catalog entry with `inputLimits.images.resize.maxBytes` but no `maxWidth`/`maxHeight` is now honored: `targetDim` stays unset (the user's dim target applies) while the byte budget (converted to raw bytes, floored at 1) and JPEG quality still tighten the upload path. Quality-only declarations remain a no-op.
24
+ - **Virtual-model routing filters for vision-capable entries** — `resolveVisionTarget` only accepts models whose `input` includes `image`, so a text-only pin (via `/multimodal-proxy model` or `PI_VISION_PROXY_MODEL`) can no longer route a media turn to a model that would receive image placeholders; `route()` throws the actionable `/multimodal-proxy pick` error instead.
25
+ - **Cache-hit `structuredContent.text` now matches fresh calls** — the analyze_image tool cache stores `{ fence, raw }`, so cached requests return the raw analysis text in `structuredContent` (as `AnalyzeImageOutput` advertises) while the model still sees the identical fence.
26
+
7
27
  ## [1.18.1] - 2026-08-30
8
28
 
9
29
  ### Fixed (post-release review of 1.18.0)
package/README.md CHANGED
@@ -8,6 +8,15 @@ 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.19.0
12
+
13
+ Deep integration with recent Pi releases (0.87 / 0.99):
14
+
15
+ - **Virtual model — `Multimodal Auto`** (Pi ≥ 0.99, on by default): a new entry in `/model` that routes each request for you — turns whose pending input carries images go **natively** to your configured vision model (real pixels, no text fence), everything else stays on your base model (auto = whatever physical model handled the session before, or pin one with `/multimodal-proxy virtual-base`). Sticky routing keeps prompt caches valid within a turn; the footer shows the routed model and `/session` lists cost per physical model. Disable with `/multimodal-proxy virtual off`.
16
+ - **`analyze_image` speaks the 0.99 tool API** — errors now surface as real tool errors (`isError`) instead of fake text, results carry a machine-readable `structuredContent` (analysis text, provider/model, latency, grounding format, per-image ids/dimensions/crop) so **codemode scripts** can consume them programmatically, and the tool is annotated (`readOnlyHint`) and namespaced (`multimodal-proxy`) for codemode listings.
17
+ - **Model-aware upload downscale** (Pi ≥ 0.87) — when the configured vision model declares `inputLimits.images.resize` in its catalog entry, the proxy derives the downscale target, JPEG quality, and byte budget from it (tightening, never loosening, your `max-upload` settings). No more double-resizing against provider limits.
18
+ - **Compaction-proof recall hint** (Pi ≥ 0.87) — the `analyze_image` recall affordance moves to a stable system-layer block via `context_with_system` once media has been described: it survives compaction (the old per-user-message hint was summarized away) and also reaches vision-capable session models, where the strip path never fired. Older hosts keep the previous behavior.
19
+
11
20
  ## What's new in 1.18.0
12
21
 
13
22
  - **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.
@@ -118,6 +127,24 @@ Both are persisted in `~/.pi/agent/multimodal-proxy.json` and can be set via env
118
127
  | **`always`** | Always uses the proxy, even if the active model supports images |
119
128
  | **`off`** | Disabled entirely |
120
129
 
130
+ ## Virtual model (Pi ≥ 0.99)
131
+
132
+ `Multimodal Auto` (`multimodal-proxy/auto`) is a [virtual model](https://pi.dev/docs/latest/virtual-models) the extension registers on Pi 0.99+ (on by default; `/multimodal-proxy virtual off` removes it). Select it once in `/model` and every request is routed for you:
133
+
134
+ | Turn | Routed to |
135
+ |------|-----------|
136
+ | Pending input carries an image attachment | Your configured **vision model** — natively, so the model sees the actual pixels |
137
+ | Everything else | Your **base model**: the pinned `/multimodal-proxy virtual-base` model, else the physical model that handled the session before, else the vision model |
138
+
139
+ Why you'd want it:
140
+
141
+ - **One selection, automatic escalation** — keep a cheap/fast model for text turns and pay for the vision model only when media actually appears. No manual `/model` switching.
142
+ - **Native vision instead of descriptions** — unlike proxy mode (which converts images to text fences for non-vision models), media turns get real pixel fidelity while `analyze_image` recall stays available for re-querying.
143
+ - **Cost transparency for free** — the footer shows `Multimodal Auto → <routed model>`, and `/session` lists cost per physical model.
144
+ - **Cache-safe** — continuations and retries stay on the model that handled the turn, so prompt caches and thinking signatures survive.
145
+
146
+ Virtual-model routing is orthogonal to proxy mode: with `Multimodal Auto` selected, image stripping never applies (the virtual model advertises image input), while video/audio path detection, `analyze_image`, and consent gates keep working exactly as before.
147
+
121
148
  ## Configuration
122
149
 
123
150
  Settings persist across sessions in `~/.pi/agent/multimodal-proxy.json`. Environment variables override file settings; in-session commands override both.
@@ -147,6 +174,8 @@ Settings persist across sessions in `~/.pi/agent/multimodal-proxy.json`. Environ
147
174
  (clear with: /multimodal-proxy fallback-model clear)
148
175
  /multimodal-proxy retry <0-5> → retries on transient errors (429/5xx/network), default 2
149
176
  /multimodal-proxy max-upload <dim | n mb | off> → downscale uploads larger than this (default 2048 px / 5 MB; off = 8192 px / 20 MB)
177
+ /multimodal-proxy virtual on | off → register/remove the 'Multimodal Auto' virtual model (Pi ≥ 0.99, default on)
178
+ /multimodal-proxy virtual-base <provider/model-id> → pin the text-turn model for the virtual model ('off' clears → auto)
150
179
  /multimodal-proxy status on | off → show/hide the steady status line
151
180
  /multimodal-proxy grounding-models list → show grounding-capable models
152
181
  /multimodal-proxy grounding-models add <provider/id> [--format <fmt>]
@@ -188,6 +217,8 @@ Legacy alias: /vision-proxy <args> works identically.
188
217
  | `PI_VISION_PROXY_MAX_UPLOAD_DIM` | `0` (disable downscaling entirely), or 512–8192 px long-edge threshold | `2048` |
189
218
  | `PI_VISION_PROXY_MAX_UPLOAD_MB` | 0.5–20 upload byte budget before downscale | `5` |
190
219
  | `PI_VISION_PROXY_FALLBACK_MODEL` | `provider/model-id`, or `none`/`off` to clear | not set |
220
+ | `PI_VISION_PROXY_VIRTUAL` | `on`, `off` — register the `Multimodal Auto` virtual model (Pi ≥ 0.99) | `on` |
221
+ | `PI_VISION_PROXY_VIRTUAL_BASE` | `provider/model-id` pinning the virtual model's text-turn model, or `none` to clear | not set |
191
222
  | `PI_VISION_PROXY_STATUS_LINE` | `on`, `off` | `on` |
192
223
  | `PI_VISION_PROXY_PATH_DETECTION` | `on`, `off` — `off` disables scanning prompt text for media file paths; structured attachments are always processed | `on` |
193
224
 
@@ -98,6 +98,7 @@ import {
98
98
  RECALL_HINT,
99
99
  createAbortError,
100
100
  downscaleForUpload,
101
+ modelImageResizeLimits,
101
102
  overUploadDim,
102
103
  downscaleTargetDim,
103
104
  isAbortError,
@@ -278,18 +279,18 @@ describe("readEnvOverrides", () => {
278
279
 
279
280
  describe("envFlags", () => {
280
281
  it("reports presence per variable", () => {
281
- assert.deepEqual(envFlags({}), { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false });
282
+ assert.deepEqual(envFlags({}), { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false, virtual: false, virtualBase: false });
282
283
  assert.deepEqual(
283
284
  envFlags({
284
285
  PI_VISION_PROXY_MODE: "x",
285
286
  PI_VISION_PROXY_MODEL: "y",
286
287
  PI_VISION_PROXY_INCLUDE_CONTEXT: "",
287
288
  }),
288
- { mode: true, model: true, context: true, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false },
289
+ { mode: true, model: true, context: true, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: false, allowedFolders: false, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false, virtual: false, virtualBase: false },
289
290
  );
290
291
  assert.deepEqual(
291
292
  envFlags({ PI_VISION_PROXY_ALLOW_HOME: "1", PI_VISION_PROXY_ALLOWED_FOLDERS: "/a" }),
292
- { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: true, allowedFolders: true, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false },
293
+ { mode: false, model: false, context: false, tool: false, maxImagesPerCall: false, maxBatch: false, cacheSize: false, videoModel: false, allowedProviders: false, allowHome: true, allowedFolders: true, statusLine: false, pathDetection: false, ytdlpCookies: false, ytdlpExtractorArgs: false, retryMax: false, maxUpload: false, fallbackModel: false, virtual: false, virtualBase: false },
293
294
  );
294
295
  assert.equal(envFlags({ PI_VISION_PROXY_ALLOWED_PROVIDERS: "" }).allowedProviders, true);
295
296
  // An unrecognized ALLOW_HOME value is not an override and must not lock the command
@@ -302,6 +303,21 @@ describe("envFlags", () => {
302
303
  assert.equal(envFlags({ PI_VISION_PROXY_STATUS_LINE: "bogus" }).statusLine, false);
303
304
  assert.equal(envFlags({ PI_VISION_PROXY_YTDLP_COOKIES_FROM_BROWSER: "chrome" }).ytdlpCookies, true);
304
305
  assert.equal(envFlags({ PI_VISION_PROXY_YTDLP_EXTRACTOR_ARGS: "youtube:player_client=web" }).ytdlpExtractorArgs, true);
306
+ // 1.19.0 virtual-model overrides
307
+ assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL: "on" }).virtual, true);
308
+ assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL: "bogus" }).virtual, false);
309
+ assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL_BASE: "zai/glm-5.3-flash" }).virtualBase, true);
310
+ assert.equal(envFlags({ PI_VISION_PROXY_VIRTUAL_BASE: "none" }).virtualBase, true);
311
+ assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL: "off" }).virtualModel, "off");
312
+ assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL: "bogus" }).virtualModel, undefined);
313
+ assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL_BASE: "zai/glm-5.3-flash" }), {
314
+ virtualBaseProvider: "zai",
315
+ virtualBaseModelId: "glm-5.3-flash",
316
+ });
317
+ assert.deepEqual(readEnvOverrides({ PI_VISION_PROXY_VIRTUAL_BASE: "none" }), {
318
+ virtualBaseProvider: undefined,
319
+ virtualBaseModelId: undefined,
320
+ });
305
321
  });
306
322
  });
307
323
 
@@ -3480,6 +3496,109 @@ describe("1.16.0 downscaleForUpload (integration)", () => {
3480
3496
  });
3481
3497
  });
3482
3498
 
3499
+ describe("1.19.0 model image limits (pi ≥ 0.87 inputLimits.images.resize)", () => {
3500
+ it("modelImageResizeLimits returns null without a declaration", () => {
3501
+ assert.equal(modelImageResizeLimits(undefined), null);
3502
+ assert.equal(modelImageResizeLimits({}), null);
3503
+ assert.equal(modelImageResizeLimits({ inputLimits: {} }), null);
3504
+ assert.equal(modelImageResizeLimits({ inputLimits: { images: {} } }), null);
3505
+ // No numeric dimension → no usable target
3506
+ assert.equal(modelImageResizeLimits({ inputLimits: { images: { resize: { jpegQuality: 75 } } } }), null);
3507
+ });
3508
+
3509
+ it("modelImageResizeLimits derives a long-edge target, quality, and raw-byte budget", () => {
3510
+ const limits = modelImageResizeLimits({
3511
+ inputLimits: { images: { resize: { maxWidth: 1568, maxHeight: 1040, jpegQuality: 75, maxBytes: 524288 } } },
3512
+ });
3513
+ assert.equal(limits?.targetDim, 1040);
3514
+ assert.equal(limits?.quality, 75);
3515
+ assert.equal(limits?.maxBytes, 393216); // 524288 × 0.75
3516
+ const partial = modelImageResizeLimits({ inputLimits: { images: { resize: { maxWidth: 1568 } } } });
3517
+ assert.equal(partial?.targetDim, 1568);
3518
+ assert.equal(partial?.quality, 88);
3519
+ assert.equal(partial?.maxBytes, undefined);
3520
+ });
3521
+
3522
+ it("maxBytes-only declarations are honored — targetDim stays unset, budget and quality tighten (PR #34 review)", () => {
3523
+ const limits = modelImageResizeLimits({
3524
+ inputLimits: { images: { resize: { maxBytes: 524288, jpegQuality: 70 } } },
3525
+ });
3526
+ assert.notEqual(limits, null);
3527
+ assert.equal(limits?.targetDim, undefined);
3528
+ assert.equal(limits?.quality, 70);
3529
+ assert.equal(limits?.maxBytes, 393216);
3530
+ // A tiny maxBytes never collapses to a zero budget
3531
+ assert.equal(modelImageResizeLimits({ inputLimits: { images: { resize: { maxBytes: 1 } } } })?.maxBytes, 1);
3532
+ });
3533
+
3534
+ it("maxBytes-only limits trigger the byte budget on downscaleForUpload (PR #34 review)", async () => {
3535
+ const { Image } = await import("imagescript");
3536
+ const noise = new Image(200, 150);
3537
+ for (let x = 1; x <= 200; x++) {
3538
+ for (let y = 1; y <= 150; y++) {
3539
+ noise.setPixelAt(x, y, (Math.random() * 0xffffffff) >>> 0);
3540
+ }
3541
+ }
3542
+ const img = bufferToPiAiImage(Buffer.from(await noise.encode(1)), "image/png");
3543
+ // Dims are inside every dim threshold; only the model's byte budget
3544
+ // (2048 raw) is exceeded by the ~100 KB noise PNG → must re-encode.
3545
+ const out = await downscaleForUpload(
3546
+ img,
3547
+ { maxUploadDim: 2048, maxUploadBytes: 5 * 1024 * 1024 },
3548
+ { quality: 75, maxBytes: 2048 },
3549
+ );
3550
+ assert.equal(out.mimeType, "image/jpeg");
3551
+ });
3552
+
3553
+ it("model limits tighten downscaleForUpload below the user config", async () => {
3554
+ const { Image } = await import("imagescript");
3555
+ const big = bufferToPiAiImage(Buffer.from(await new Image(3000, 200).encode(1)), "image/png");
3556
+ const out = await downscaleForUpload(
3557
+ big,
3558
+ { maxUploadDim: 2048, maxUploadBytes: 5 * 1024 * 1024 },
3559
+ { targetDim: 1024, quality: 75 },
3560
+ );
3561
+ assert.equal(out.mimeType, "image/jpeg");
3562
+ const dims = extractDimensions(piAiImageToBuffer(out));
3563
+ assert.equal(dims?.width, 1024);
3564
+ });
3565
+
3566
+ it("model limits never re-enable a user-disabled downscale", async () => {
3567
+ const { Image } = await import("imagescript");
3568
+ const big = bufferToPiAiImage(Buffer.from(await new Image(3000, 200).encode(1)), "image/png");
3569
+ const out = await downscaleForUpload(
3570
+ big,
3571
+ { maxUploadDim: 0, maxUploadBytes: 5 * 1024 * 1024 },
3572
+ { targetDim: 1024, quality: 75 },
3573
+ );
3574
+ assert.equal(out, big);
3575
+ });
3576
+
3577
+ it("model limits under the user config do not shrink an in-budget image", async () => {
3578
+ const { Image } = await import("imagescript");
3579
+ const small = bufferToPiAiImage(Buffer.from(await new Image(800, 600).encode(1)), "image/png");
3580
+ const out = await downscaleForUpload(
3581
+ small,
3582
+ { maxUploadDim: 2048, maxUploadBytes: 5 * 1024 * 1024 },
3583
+ { targetDim: 1024, quality: 75 },
3584
+ );
3585
+ assert.equal(out, small);
3586
+ });
3587
+
3588
+ it("sanitize validates the 1.19.0 virtual-model fields", () => {
3589
+ assert.equal(sanitize({ ...DEFAULT_CONFIG }).virtualModel, "on");
3590
+ assert.equal(sanitize({ ...DEFAULT_CONFIG, virtualModel: "off" }).virtualModel, "off");
3591
+ assert.equal(sanitize({ ...DEFAULT_CONFIG, virtualModel: "bogus" as any }).virtualModel, "on");
3592
+ const pinned = sanitize({ ...DEFAULT_CONFIG, virtualBaseProvider: "x-ai", virtualBaseModelId: "some-model" });
3593
+ assert.equal(pinned.virtualBaseProvider, "xai");
3594
+ assert.equal(pinned.virtualBaseModelId, "some-model");
3595
+ // Both halves required — a lone provider is dropped
3596
+ const lone = sanitize({ ...DEFAULT_CONFIG, virtualBaseProvider: "zai" });
3597
+ assert.equal("virtualBaseProvider" in lone, false);
3598
+ assert.equal("virtualBaseModelId" in lone, false);
3599
+ });
3600
+ });
3601
+
3483
3602
  describe("1.16.0 review fixes (PR #27)", () => {
3484
3603
  it("createAbortError is abort-shaped with the exact 'aborted' message", () => {
3485
3604
  const err = createAbortError();
@@ -94,6 +94,15 @@ export interface VisionConfig {
94
94
  maxUploadBytes: number;
95
95
  fallbackProvider?: string;
96
96
  fallbackModelId?: string;
97
+ // 1.19.0 — virtual model (pi ≥ 0.99): registers `multimodal-proxy/auto`, a
98
+ // selectable model that routes media-bearing turns to the configured vision
99
+ // model and text turns to a base model. "on" registers it, "off" never
100
+ // does. The optional base pin names the physical text model; unset = stay on
101
+ // whatever physical model handled the session before, falling back to the
102
+ // vision model for the first turn.
103
+ virtualModel: ToolSetting;
104
+ virtualBaseProvider?: string;
105
+ virtualBaseModelId?: string;
97
106
  }
98
107
 
99
108
  export interface ImageMeta {
@@ -727,6 +736,9 @@ export const DEFAULT_CONFIG: VisionConfig = {
727
736
  retryMax: 2,
728
737
  maxUploadDim: 2048,
729
738
  maxUploadBytes: 5 * 1024 * 1024,
739
+ // 1.19.0 — virtual model registered on hosts with pi.registerVirtualModel()
740
+ // (pi ≥ 0.99); feature-detected and skipped on older hosts.
741
+ virtualModel: "on",
730
742
  };
731
743
 
732
744
  // ── Persistent file storage ────────────────────────────────────────────────
@@ -759,6 +771,9 @@ const PERSISTED_CONFIG_KEYS = new Set([
759
771
  "maxUploadBytes",
760
772
  "fallbackProvider",
761
773
  "fallbackModelId",
774
+ "virtualModel",
775
+ "virtualBaseProvider",
776
+ "virtualBaseModelId",
762
777
  ]);
763
778
 
764
779
  /** Read config from the persistent file. Returns empty object on any failure. */
@@ -871,6 +886,7 @@ export function parseFallbackModelEnv(
871
886
  return parsed ? { provider: parsed.provider, modelId: parsed.modelId } : undefined;
872
887
  }
873
888
 
889
+ /** Environment overrides (PI_VISION_PROXY_*) applied last; only recognized values land. */
874
890
  export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<VisionConfig> {
875
891
  const overrides: Partial<VisionConfig> = {};
876
892
  const modeEnv = env.PI_VISION_PROXY_MODE;
@@ -966,10 +982,24 @@ export function readEnvOverrides(env: NodeJS.ProcessEnv = process.env): Partial<
966
982
  overrides.fallbackModelId = fallbackModelEnv.modelId;
967
983
  }
968
984
  }
985
+ // 1.19.0 virtual-model overrides
986
+ const virtualEnv = env.PI_VISION_PROXY_VIRTUAL;
987
+ if (virtualEnv === "on" || virtualEnv === "off") overrides.virtualModel = virtualEnv;
988
+ const virtualBaseEnv = parseFallbackModelEnv(env.PI_VISION_PROXY_VIRTUAL_BASE);
989
+ if (virtualBaseEnv) {
990
+ if (virtualBaseEnv.clear) {
991
+ overrides.virtualBaseProvider = undefined;
992
+ overrides.virtualBaseModelId = undefined;
993
+ } else {
994
+ overrides.virtualBaseProvider = virtualBaseEnv.provider;
995
+ overrides.virtualBaseModelId = virtualBaseEnv.modelId;
996
+ }
997
+ }
969
998
  return overrides;
970
999
  }
971
1000
 
972
- export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean; model: boolean; context: boolean; tool: boolean; maxImagesPerCall: boolean; maxBatch: boolean; cacheSize: boolean; videoModel: boolean; allowedProviders: boolean; allowHome: boolean; allowedFolders: boolean; statusLine: boolean; pathDetection: boolean; ytdlpCookies: boolean; ytdlpExtractorArgs: boolean; retryMax: boolean; maxUpload: boolean; fallbackModel: boolean } {
1001
+ /** Which env vars actually override a setting — used to lock the matching commands. */
1002
+ export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean; model: boolean; context: boolean; tool: boolean; maxImagesPerCall: boolean; maxBatch: boolean; cacheSize: boolean; videoModel: boolean; allowedProviders: boolean; allowHome: boolean; allowedFolders: boolean; statusLine: boolean; pathDetection: boolean; ytdlpCookies: boolean; ytdlpExtractorArgs: boolean; retryMax: boolean; maxUpload: boolean; fallbackModel: boolean; virtual: boolean; virtualBase: boolean } {
973
1003
  return {
974
1004
  mode: Boolean(env.PI_VISION_PROXY_MODE),
975
1005
  model: Boolean(env.PI_VISION_PROXY_MODEL),
@@ -998,6 +1028,9 @@ export function envFlags(env: NodeJS.ProcessEnv = process.env): { mode: boolean;
998
1028
  parseUploadDimEnv(env.PI_VISION_PROXY_MAX_UPLOAD_DIM) !== undefined ||
999
1029
  parseUploadMbEnv(env.PI_VISION_PROXY_MAX_UPLOAD_MB) !== undefined,
1000
1030
  fallbackModel: parseFallbackModelEnv(env.PI_VISION_PROXY_FALLBACK_MODEL) !== undefined,
1031
+ // 1.19.0 — only recognized values count as overrides.
1032
+ virtual: env.PI_VISION_PROXY_VIRTUAL === "on" || env.PI_VISION_PROXY_VIRTUAL === "off",
1033
+ virtualBase: parseFallbackModelEnv(env.PI_VISION_PROXY_VIRTUAL_BASE) !== undefined,
1001
1034
  };
1002
1035
  }
1003
1036
 
@@ -1139,6 +1172,7 @@ export function sanitizeProviderHeaders(
1139
1172
  return out;
1140
1173
  }
1141
1174
 
1175
+ /** Validate and normalize a merged config: canonicalize providers, clamp numbers, drop malformed pairs. */
1142
1176
  export function sanitize(config: VisionConfig): VisionConfig {
1143
1177
  const safe: VisionConfig = { ...config };
1144
1178
  if (typeof safe.provider === "string") safe.provider = canonicalProvider(safe.provider);
@@ -1244,6 +1278,17 @@ export function sanitize(config: VisionConfig): VisionConfig {
1244
1278
  delete safe.fallbackProvider;
1245
1279
  delete safe.fallbackModelId;
1246
1280
  }
1281
+ // 1.19.0 virtual-model fields
1282
+ if (safe.virtualModel !== "on" && safe.virtualModel !== "off") safe.virtualModel = DEFAULT_CONFIG.virtualModel;
1283
+ if (typeof safe.virtualBaseProvider !== "string") delete safe.virtualBaseProvider;
1284
+ else safe.virtualBaseProvider = canonicalProvider(safe.virtualBaseProvider);
1285
+ if (typeof safe.virtualBaseModelId !== "string") delete safe.virtualBaseModelId;
1286
+ if (safe.virtualBaseProvider !== undefined && !PROVIDER_PATTERN.test(safe.virtualBaseProvider)) delete safe.virtualBaseProvider;
1287
+ if (safe.virtualBaseModelId !== undefined && !MODEL_ID_PATTERN.test(safe.virtualBaseModelId)) delete safe.virtualBaseModelId;
1288
+ if (!safe.virtualBaseProvider || !safe.virtualBaseModelId) {
1289
+ delete safe.virtualBaseProvider;
1290
+ delete safe.virtualBaseModelId;
1291
+ }
1247
1292
  return safe;
1248
1293
  }
1249
1294
 
@@ -1251,6 +1296,11 @@ export function persistedBase(entries: readonly SessionEntry[]): VisionConfig {
1251
1296
  return sanitize({ ...DEFAULT_CONFIG, ...readPersistedConfig(entries) });
1252
1297
  }
1253
1298
 
1299
+ /**
1300
+ * Resolve the effective config: defaults ← persisted file ← session-entry
1301
+ * config ← environment overrides, then sanitize (invalid values fall back to
1302
+ * defaults; half-set model pairs are dropped).
1303
+ */
1254
1304
  export function resolveConfig(
1255
1305
  entries: readonly SessionEntry[],
1256
1306
  env: NodeJS.ProcessEnv = process.env,
@@ -2606,7 +2656,7 @@ parentPort.on("message", async (task) => {
2606
2656
  const w = Math.max(1, Math.round(img.width * scale));
2607
2657
  const h = Math.max(1, Math.round(img.height * scale));
2608
2658
  // Downscaled uploads are re-encoded as JPEG — the size reduction is the point.
2609
- encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
2659
+ encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(task.quality ?? 88);
2610
2660
  } else {
2611
2661
  const cropped = img.crop(task.crop.x, task.crop.y, task.crop.width, task.crop.height);
2612
2662
  encoded = mimeType === "image/png" ? await cropped.encode(1) : await cropped.encodeJPEG(90);
@@ -2873,7 +2923,7 @@ export function overUploadDim(
2873
2923
  }
2874
2924
 
2875
2925
  /** In-thread decode → resize → JPEG encode, mirroring cropInThread. */
2876
- async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
2926
+ async function resizeInThread(imageBytes: Buffer, targetDim: number, quality = 88): Promise<Buffer | null> {
2877
2927
  const img = await decodeWithTimeout(imageBytes);
2878
2928
  if (img.width > MAX_IMAGE_DIMENSION || img.height > MAX_IMAGE_DIMENSION) {
2879
2929
  return null;
@@ -2881,7 +2931,7 @@ async function resizeInThread(imageBytes: Buffer, targetDim: number): Promise<Bu
2881
2931
  const scale = Math.min(targetDim / img.width, targetDim / img.height, 1);
2882
2932
  const w = Math.max(1, Math.round(img.width * scale));
2883
2933
  const h = Math.max(1, Math.round(img.height * scale));
2884
- const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(88);
2934
+ const encoded = await (scale >= 1 ? img : img.resize(w, h)).encodeJPEG(quality);
2885
2935
  return Buffer.from(encoded);
2886
2936
  }
2887
2937
 
@@ -2890,6 +2940,7 @@ async function resizeInWorker(
2890
2940
  imageBytes: Buffer,
2891
2941
  targetDim: number,
2892
2942
  timeoutMs: number,
2943
+ quality?: number,
2893
2944
  ): Promise<Buffer | null | typeof WORKER_UNAVAILABLE> {
2894
2945
  if (!(await ensureWorkerInfra())) return WORKER_UNAVAILABLE;
2895
2946
 
@@ -2904,7 +2955,7 @@ async function resizeInWorker(
2904
2955
 
2905
2956
  const { result, reusable } = await runCropTask(
2906
2957
  worker,
2907
- { op: "resize", bytes: ab, targetDim, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
2958
+ { op: "resize", bytes: ab, targetDim, quality, mimeType: "image/jpeg", maxDim: MAX_IMAGE_DIMENSION },
2908
2959
  timeoutMs,
2909
2960
  );
2910
2961
  if (reusable) releaseWorker(worker);
@@ -2913,32 +2964,97 @@ async function resizeInWorker(
2913
2964
  }
2914
2965
 
2915
2966
  /**
2916
- * Downscale image bytes so the long edge fits targetDim, re-encoding as
2917
- * JPEG q88. Returns null on decode/encode failure — callers then send the
2918
- * original bytes rather than failing the whole analysis.
2967
+ * Downscale image bytes so the long edge fits targetDim, re-encoding as JPEG
2968
+ * (quality 88 unless overridden). Returns null on decode/encode failure —
2969
+ * callers then send the original bytes rather than failing the whole analysis.
2919
2970
  */
2920
- export async function downscaleImage(imageBytes: Buffer, targetDim: number): Promise<Buffer | null> {
2971
+ export async function downscaleImage(
2972
+ imageBytes: Buffer,
2973
+ targetDim: number,
2974
+ quality = 88,
2975
+ ): Promise<Buffer | null> {
2921
2976
  try {
2922
2977
  const dims = extractDimensions(imageBytes);
2923
2978
  if (dims && (dims.width > MAX_IMAGE_DIMENSION || dims.height > MAX_IMAGE_DIMENSION)) {
2924
2979
  return null;
2925
2980
  }
2926
2981
  if (decodeWorkerEnabled()) {
2927
- const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs());
2982
+ const viaWorker = await resizeInWorker(imageBytes, targetDim, decodeTimeoutMs(), quality);
2928
2983
  if (viaWorker !== WORKER_UNAVAILABLE) return viaWorker;
2929
2984
  // else: worker infra unavailable — fall through to the in-thread path
2930
2985
  }
2931
- return await resizeInThread(imageBytes, targetDim);
2986
+ return await resizeInThread(imageBytes, targetDim, quality);
2932
2987
  } catch {
2933
2988
  return null;
2934
2989
  }
2935
2990
  }
2936
2991
 
2992
+ /**
2993
+ * Per-model image upload limits declared on a catalog entry (pi ≥ 0.87):
2994
+ * `inputLimits.images.resize` = { maxWidth, maxHeight, maxBytes, jpegQuality }.
2995
+ * `maxBytes` limits the base64 payload; here it is converted to a raw-byte
2996
+ * budget (~3/4) so it can share the raw-byte comparison of the user config.
2997
+ * Returns null when the model declares nothing (or an older pi host).
2998
+ */
2999
+ export interface ModelImageResizeLimits {
3000
+ /** Long-edge target that satisfies the model's max-width/max-height box. Absent when the model declares no dimension limits (byte-budget-only declarations keep the user's dim target). */
3001
+ targetDim?: number;
3002
+ /** JPEG re-encode quality (default 88). */
3003
+ quality: number;
3004
+ /** Raw-byte budget derived from the base64 maxBytes, when declared. */
3005
+ maxBytes?: number;
3006
+ }
3007
+
3008
+ export function modelImageResizeLimits(model: unknown): ModelImageResizeLimits | null {
3009
+ const resize = (
3010
+ model as
3011
+ | {
3012
+ inputLimits?: {
3013
+ images?: {
3014
+ resize?: {
3015
+ maxWidth?: unknown;
3016
+ maxHeight?: unknown;
3017
+ maxBytes?: unknown;
3018
+ jpegQuality?: unknown;
3019
+ };
3020
+ };
3021
+ };
3022
+ }
3023
+ | null
3024
+ | undefined
3025
+ )?.inputLimits?.images?.resize;
3026
+ if (!resize) return null;
3027
+ const maxW = typeof resize.maxWidth === "number" && resize.maxWidth > 0 ? resize.maxWidth : undefined;
3028
+ const maxH = typeof resize.maxHeight === "number" && resize.maxHeight > 0 ? resize.maxHeight : undefined;
3029
+ const dims = [maxW, maxH].filter((d): d is number => d !== undefined);
3030
+ const quality =
3031
+ typeof resize.jpegQuality === "number" && resize.jpegQuality >= 1 && resize.jpegQuality <= 100
3032
+ ? Math.round(resize.jpegQuality)
3033
+ : 88;
3034
+ const maxBytes =
3035
+ typeof resize.maxBytes === "number" && resize.maxBytes > 0
3036
+ ? Math.max(1, Math.floor(resize.maxBytes * 0.75))
3037
+ : undefined;
3038
+ // A declaration must carry at least one meaningful limit — dimensions or a
3039
+ // byte budget. Quality alone changes nothing until a resize triggers, so a
3040
+ // quality-only declaration is treated as absent. A maxBytes-only
3041
+ // declaration is honored (PR #34 review): targetDim stays unset and only
3042
+ // the byte budget and quality tighten the user's thresholds.
3043
+ if (dims.length === 0 && maxBytes === undefined) return null;
3044
+ return {
3045
+ targetDim: dims.length > 0 ? Math.min(...dims) : undefined,
3046
+ quality,
3047
+ maxBytes,
3048
+ };
3049
+ }
3050
+
2937
3051
  /**
2938
3052
  * Downscale an image for upload when it exceeds the configured thresholds
2939
- * (1.16.0). Returns the original image untouched when it is small enough or
2940
- * when downscaling fails for any reason — upload downscale is best-effort
2941
- * cost/limit protection, never a hard failure.
3053
+ * (1.16.0). Model-declared limits (pi ≥ 0.87 `inputLimits.images.resize`,
3054
+ * 1.19.0) tighten the user's thresholds; they never re-enable a downscale the
3055
+ * user disabled with `maxUploadDim: 0`. Returns the original image untouched
3056
+ * when it is small enough or when downscaling fails for any reason — upload
3057
+ * downscale is best-effort cost/limit protection, never a hard failure.
2942
3058
  *
2943
3059
  * Dimension-triggered resizes are accepted even when the JPEG re-encode grows
2944
3060
  * the payload slightly (flat-color PNGs compress better than JPEG) — the pixel
@@ -2948,13 +3064,23 @@ export async function downscaleImage(imageBytes: Buffer, targetDim: number): Pro
2948
3064
  export async function downscaleForUpload(
2949
3065
  img: PiAiImage,
2950
3066
  config: Pick<VisionConfig, "maxUploadDim" | "maxUploadBytes">,
3067
+ modelLimits?: ModelImageResizeLimits | null,
2951
3068
  ): Promise<PiAiImage> {
2952
3069
  if (config.maxUploadDim === 0) return img; // downscaling disabled
3070
+ const targetDim = modelLimits?.targetDim
3071
+ ? Math.min(config.maxUploadDim, modelLimits.targetDim)
3072
+ : config.maxUploadDim;
3073
+ const maxUploadBytes =
3074
+ modelLimits?.maxBytes !== undefined
3075
+ ? Math.min(config.maxUploadBytes, modelLimits.maxBytes)
3076
+ : config.maxUploadBytes;
3077
+ const quality = modelLimits?.quality ?? 88;
3078
+ const effective = { maxUploadDim: targetDim, maxUploadBytes };
2953
3079
  const buf = piAiImageToBuffer(img);
2954
3080
  const dims = extractDimensions(buf);
2955
- const overDim = overUploadDim(dims, config);
2956
- if (downscaleTargetDim(dims, buf.byteLength, config) === null) return img;
2957
- const resized = await downscaleImage(buf, config.maxUploadDim);
3081
+ const overDim = overUploadDim(dims, effective);
3082
+ if (downscaleTargetDim(dims, buf.byteLength, effective) === null) return img;
3083
+ const resized = await downscaleImage(buf, targetDim, quality);
2958
3084
  if (!resized) return img;
2959
3085
  if (!overDim && resized.byteLength >= buf.byteLength) return img;
2960
3086
  return bufferToPiAiImage(resized, "image/jpeg");