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.
@@ -26,6 +26,10 @@
26
26
  * /multimodal-proxy fallback-model provider/model-id|clear (1.16.0)
27
27
  * /multimodal-proxy retry <0-5> - retries on transient errors (1.16.0)
28
28
  * /multimodal-proxy max-upload <dim|n mb|off> - downscale oversized uploads (1.16.0)
29
+ * /multimodal-proxy virtual on|off - register/unregister the 'Multimodal Auto'
30
+ * virtual model (1.19.0, pi ≥ 0.99)
31
+ * /multimodal-proxy virtual-base provider/model-id|off - pin the text-turn model for the
32
+ * virtual model; 'off'/blank = auto (1.19.0)
29
33
  * /multimodal-proxy status on|off - show/hide the steady status line
30
34
  *
31
35
  * Legacy alias: /vision-proxy <args> works identically.
@@ -48,6 +52,8 @@
48
52
  * PI_VISION_PROXY_MAX_UPLOAD_DIM - 512..8192 px long-edge downscale threshold (default 2048)
49
53
  * PI_VISION_PROXY_MAX_UPLOAD_MB - 0.5..20 upload byte budget (default 5)
50
54
  * PI_VISION_PROXY_FALLBACK_MODEL - "provider/model-id" or "none" (default none)
55
+ * PI_VISION_PROXY_VIRTUAL - "on" | "off" — register the virtual model (1.19.0, default on)
56
+ * PI_VISION_PROXY_VIRTUAL_BASE - "provider/model-id" or "none" — pin its text-turn model (1.19.0)
51
57
  *
52
58
  * Install:
53
59
  * pi install ./packages/pi-multimodal-proxy
@@ -316,6 +322,7 @@ import {
316
322
  modelLabel,
317
323
  normalizeAllowedProviders,
318
324
  parseModelString,
325
+ modelImageResizeLimits,
319
326
  parseProviderList,
320
327
  persistedBase,
321
328
  pluralImages,
@@ -404,6 +411,44 @@ const AnalyzeImageParams = Type.Object({
404
411
  reason: Type.Optional(Type.String({ description: "Optional; logged for analytics only" })),
405
412
  });
406
413
 
414
+ /**
415
+ * outputSchema of analyze_image (pi ≥ 0.99): codemode scripts and other
416
+ * programmatic callers receive this structuredContent instead of the text
417
+ * fence; the model still sees `content`. Declared but harmlessly ignored by
418
+ * older hosts.
419
+ */
420
+ const AnalyzeImageOutput = Type.Object({
421
+ ok: Type.Boolean({ description: "Whether the analysis succeeded." }),
422
+ text: Type.String({ description: "The analysis text (same content the model sees, without the XML fence)." }),
423
+ error: Type.Optional(Type.String({ description: "Failure message, present when ok is false." })),
424
+ cached: Type.Optional(Type.Boolean({ description: "True when served from the per-session tool cache." })),
425
+ provider: Type.Optional(Type.String({ description: "Provider that answered (fallback-aware)." })),
426
+ model: Type.Optional(Type.String({ description: "Model id that answered (fallback-aware)." })),
427
+ latencyMs: Type.Optional(Type.Number()),
428
+ groundingFormat: Type.Optional(Type.String()),
429
+ images: Type.Optional(
430
+ Type.Array(
431
+ Type.Object({
432
+ id: Type.String({ description: "Stable image id usable for recall (image=\"...\")." }),
433
+ filename: Type.Optional(Type.String()),
434
+ width: Type.Optional(Type.Number()),
435
+ height: Type.Optional(Type.Number()),
436
+ crop: Type.Optional(
437
+ Type.Object(
438
+ { x: Type.Number(), y: Type.Number(), width: Type.Number(), height: Type.Number() },
439
+ {
440
+ // PR #34 review: options argument, not a property — a raw-string
441
+ // "description" inside the properties map makes the schema invalid.
442
+ description:
443
+ "Absolute-pixel crop applied before the call; add (x, y) to returned coordinates to map back to the full image.",
444
+ },
445
+ ),
446
+ ),
447
+ }),
448
+ ),
449
+ ),
450
+ });
451
+
407
452
  const TOOL_DESCRIPTION = [
408
453
  "Use `analyze_image` when (a) the cached description of an image lacks a detail you need,",
409
454
  "(b) you need to compare or cross-reference multiple images, or (c) you need to focus on a specific region.",
@@ -436,9 +481,19 @@ const DEFAULT_TOOL_CACHE_SIZE = 50;
436
481
  // unreliable. Key the state off the session's SessionManager instance, which
437
482
  // is unique per session; the WeakMap entry is reclaimed when the session ends.
438
483
 
484
+ /**
485
+ * Cached analyze_image result: the model-facing fence plus the raw analysis
486
+ * text, so cache hits can return the same schema-shaped structuredContent as
487
+ * fresh calls (PR #34 review).
488
+ */
489
+ interface AnalyzeImageCacheValue {
490
+ fence: string;
491
+ raw: string;
492
+ }
493
+
439
494
  interface SessionState {
440
495
  /** Tool result cache, shared across calls within one session. */
441
- toolCache: LRUCache<string, string>;
496
+ toolCache: LRUCache<string, AnalyzeImageCacheValue>;
442
497
  /** Current turn's tool call count (reset on each before_agent_start). */
443
498
  toolCallCount: number;
444
499
  /** Image hash → dimensions/filename, populated on first ingestion this session. */
@@ -455,6 +510,12 @@ interface SessionState {
455
510
  keyProbe?: Map<string, boolean>;
456
511
  /** Last implicit-default substitution already notified this session. */
457
512
  notifiedDefaultSub?: string;
513
+ /**
514
+ * True once a `context_with_system` event has been observed (pi ≥ 0.87).
515
+ * Doubles as the probe that keeps the recall affordance out of the
516
+ * per-image user-message path once the system-layer path takes over.
517
+ */
518
+ cwsSupported?: boolean;
458
519
  }
459
520
 
460
521
  const _sessionState = new WeakMap<object, SessionState>();
@@ -464,7 +525,7 @@ function getSessionState(ctx: ExtensionContext): SessionState {
464
525
  let state = _sessionState.get(key);
465
526
  if (!state) {
466
527
  state = {
467
- toolCache: new LRUCache<string, string>(DEFAULT_TOOL_CACHE_SIZE),
528
+ toolCache: new LRUCache<string, AnalyzeImageCacheValue>(DEFAULT_TOOL_CACHE_SIZE),
468
529
  toolCallCount: 0,
469
530
  imageMeta: createImageMetaStore(),
470
531
  imageData: createImageDataStore(),
@@ -791,6 +852,7 @@ function friendlyModelLabel(
791
852
  * Returns undefined (which clears the status entry) when the user hid the
792
853
  * status line via `/multimodal-proxy status off`.
793
854
  */
855
+ /** Steady-state status-line text summarizing mode, models, tool, and virtual-model state. */
794
856
  function steadyStatusText(
795
857
  config: VisionConfig,
796
858
  registry: ExtensionContext["modelRegistry"],
@@ -799,7 +861,8 @@ function steadyStatusText(
799
861
  return (
800
862
  `multimodal-proxy: ${config.mode} → ${friendlyModelLabel(config, registry)} ` +
801
863
  `| video: ${config.videoProvider}/${config.videoModelId}` +
802
- `${config.tool === "on" && config.mode !== "off" ? " [+tool]" : ""}`
864
+ `${config.tool === "on" && config.mode !== "off" ? " [+tool]" : ""}` +
865
+ `${config.virtualModel === "on" ? " [+virtual]" : ""}`
803
866
  );
804
867
  }
805
868
 
@@ -967,8 +1030,9 @@ async function analyzeImages(
967
1030
 
968
1031
  // 1.16.0 — best-effort downscale of oversized uploads (cost/limit protection).
969
1032
  // Hash/cache/recall still key on the ORIGINAL bytes — only the upload
970
- // payload is shrunk.
971
- const uploadPayload = await downscaleForUpload(piAiImage, config);
1033
+ // payload is shrunk. 1.19.0 — tightened by the vision model's declared
1034
+ // inputLimits.images.resize (pi ≥ 0.87) when present.
1035
+ const uploadPayload = await downscaleForUpload(piAiImage, config, modelImageResizeLimits(visionModel));
972
1036
 
973
1037
  try {
974
1038
  const { response } = await completeVision(
@@ -1563,6 +1627,32 @@ async function analyzeVideoViaXaiResponsesFile(
1563
1627
  }
1564
1628
  }
1565
1629
 
1630
+ /**
1631
+ * Structured outcome of one analyze_image call (1.19.0): the model-facing
1632
+ * text plus optional machine-readable fields for programmatic callers
1633
+ * (pi ≥ 0.99 codemode scripts receive `structuredContent` instead of text).
1634
+ */
1635
+ interface AnalyzeImageOutcome {
1636
+ /** Model-facing text: the analysis fence, or the error message. */
1637
+ text: string;
1638
+ /** True when the call failed — surfaced as `isError` on hosts ≥ 0.99. */
1639
+ isError?: boolean;
1640
+ /** Machine-readable result matching AnalyzeImageOutput. */
1641
+ structured?: Record<string, unknown>;
1642
+ }
1643
+
1644
+ /** Wrap a failure message as an AnalyzeImageOutcome. */
1645
+ function analysisError(message: string): AnalyzeImageOutcome {
1646
+ return { text: message, isError: true, structured: { ok: false, error: message } };
1647
+ }
1648
+
1649
+ /**
1650
+ * Answer a targeted analyze_image question about one or more images: resolve
1651
+ * references (session recall ids or file paths), validate and apply crops,
1652
+ * consult the per-session cache, and call the vision model (with retry and
1653
+ * fallback). Returns the fenced text for the model plus a machine-readable
1654
+ * structured payload for programmatic callers.
1655
+ */
1566
1656
  async function handleAnalyzeImage(
1567
1657
  params: {
1568
1658
  images: string[];
@@ -1574,20 +1664,20 @@ async function handleAnalyzeImage(
1574
1664
  ctx: ExtensionContext,
1575
1665
  pi: ExtensionAPI,
1576
1666
  config: VisionConfig,
1577
- ): Promise<string> {
1667
+ ): Promise<AnalyzeImageOutcome> {
1578
1668
  const { images: imageRefs, question, model: modelOverride, crop: crops, reason } = params;
1579
1669
 
1580
1670
  if (!question || question.trim().length === 0) {
1581
- return "Error: question is required and must be non-empty.";
1671
+ return analysisError("Error: question is required and must be non-empty.");
1582
1672
  }
1583
1673
  if (question.length > 4000) {
1584
- return "Error: question must be at most 4000 characters.";
1674
+ return analysisError("Error: question must be at most 4000 characters.");
1585
1675
  }
1586
1676
  if (imageRefs.length === 0) {
1587
- return "Error: at least one image is required.";
1677
+ return analysisError("Error: at least one image is required.");
1588
1678
  }
1589
1679
  if (imageRefs.length > config.maxImagesPerCall) {
1590
- return `Error: too many images (${imageRefs.length}). Maximum is ${config.maxImagesPerCall}.`;
1680
+ return analysisError(`Error: too many images (${imageRefs.length}). Maximum is ${config.maxImagesPerCall}.`);
1591
1681
  }
1592
1682
 
1593
1683
  // Validate crop indices: no duplicates, all in range
@@ -1595,11 +1685,11 @@ async function handleAnalyzeImage(
1595
1685
  const seen = new Set<number>();
1596
1686
  for (const c of crops) {
1597
1687
  if (seen.has(c.image_index)) {
1598
- return `Error: duplicate crop for image index ${c.image_index}. At most one crop per image.`;
1688
+ return analysisError(`Error: duplicate crop for image index ${c.image_index}. At most one crop per image.`);
1599
1689
  }
1600
1690
  seen.add(c.image_index);
1601
1691
  if (c.image_index < 0 || c.image_index >= imageRefs.length) {
1602
- return `Error: crop image_index ${c.image_index} is out of range (0-${imageRefs.length - 1}).`;
1692
+ return analysisError(`Error: crop image_index ${c.image_index} is out of range (0-${imageRefs.length - 1}).`);
1603
1693
  }
1604
1694
  }
1605
1695
  }
@@ -1610,7 +1700,7 @@ async function handleAnalyzeImage(
1610
1700
  if (modelOverride) {
1611
1701
  const parsed = parseModelString(modelOverride);
1612
1702
  if (!parsed) {
1613
- return `Error: invalid model string "${modelOverride}". Expected format: provider/model-id`;
1703
+ return analysisError(`Error: invalid model string "${modelOverride}". Expected format: provider/model-id`);
1614
1704
  }
1615
1705
  visionProvider = parsed.provider;
1616
1706
  visionModelId = parsed.modelId;
@@ -1619,16 +1709,16 @@ async function handleAnalyzeImage(
1619
1709
  // Verify model exists and supports images
1620
1710
  const visionModel = ctx.modelRegistry.find(visionProvider, visionModelId);
1621
1711
  if (!visionModel) {
1622
- return `Error: model "${visionProvider}/${visionModelId}" not found in registry. Use /multimodal-proxy pick to choose a vision model.`;
1712
+ return analysisError(`Error: model "${visionProvider}/${visionModelId}" not found in registry. Use /multimodal-proxy pick to choose a vision model.`);
1623
1713
  }
1624
1714
  if (!visionModel.input.includes("image")) {
1625
- return `Error: model "${visionModel.name ?? visionModelId}" does not support image input.`;
1715
+ return analysisError(`Error: model "${visionModel.name ?? visionModelId}" does not support image input.`);
1626
1716
  }
1627
1717
 
1628
1718
  // Check consent for the resolved vision provider
1629
1719
  const entries = ctx.sessionManager.getEntries();
1630
1720
  if (!hasConsent(entries, visionProvider, config.allowedProviders, config.deniedProviders)) {
1631
- return `Error: consent required before sending data to ${visionProvider}. Please tell the user to run the following command and then retry:\n\n/multimodal-proxy consent yes\n\n(To pre-consent this provider permanently: /multimodal-proxy allowed-providers add ${visionProvider})`
1721
+ return analysisError(`Error: consent required before sending data to ${visionProvider}. Please tell the user to run the following command and then retry:\n\n/multimodal-proxy consent yes\n\n(To pre-consent this provider permanently: /multimodal-proxy allowed-providers add ${visionProvider})`)
1632
1722
  }
1633
1723
 
1634
1724
  // Resolve image references to PiAiImage objects.
@@ -1641,7 +1731,7 @@ async function handleAnalyzeImage(
1641
1731
  if (recallHash) {
1642
1732
  const stored = getImageData(imageData, recallHash);
1643
1733
  if (!stored) {
1644
- return `Error: image "${recallHash}" is not available for recall — it may have expired from the session cache or was never analyzed. Ask the user to re-attach it, or pass a file path.`;
1734
+ return analysisError(`Error: image "${recallHash}" is not available for recall — it may have expired from the session cache or was never analyzed. Ask the user to re-attach it, or pass a file path.`);
1645
1735
  }
1646
1736
  const image: PiAiImage = { type: "image", data: stored.data, mimeType: stored.mimeType };
1647
1737
  // Backfill dimensions if metadata was evicted, so crops still work on recall.
@@ -1652,11 +1742,11 @@ async function handleAnalyzeImage(
1652
1742
 
1653
1743
  // File path
1654
1744
  if (ref.includes("..")) {
1655
- return `Error: path contains disallowed ".." segments.`;
1745
+ return analysisError(`Error: path contains disallowed ".." segments.`);
1656
1746
  }
1657
1747
  const r = await readImageFileWithReason(ref, pathAccessFromConfig(config), sniffImageFile);
1658
1748
  if (!r.image) {
1659
- return `Error: could not read image: ${describeReadReason(r.reason ?? "not-an-image", r.bytes)}`;
1749
+ return analysisError(`Error: could not read image: ${describeReadReason(r.reason ?? "not-an-image", r.bytes)}`);
1660
1750
  }
1661
1751
  const hash = hashImageData(r.image.data);
1662
1752
  storeImageMeta(imageMeta, hash, r.image.data, r.filename);
@@ -1676,13 +1766,13 @@ async function handleAnalyzeImage(
1676
1766
  if (cropEntry) {
1677
1767
  const meta = entry.meta;
1678
1768
  if (!meta) {
1679
- return `Error: cannot crop image ${i} - image dimensions unknown.`;
1769
+ return analysisError(`Error: cannot crop image ${i} - image dimensions unknown.`);
1680
1770
  }
1681
1771
  try {
1682
1772
  const resolved = resolveCropEntry(cropEntry, meta.width, meta.height);
1683
1773
  imagePayloads.push({ ...entry, crop: resolved });
1684
1774
  } catch (err) {
1685
- return `Error: crop for image ${i} failed: ${err instanceof Error ? err.message : String(err)}`;
1775
+ return analysisError(`Error: crop for image ${i} failed: ${err instanceof Error ? err.message : String(err)}`);
1686
1776
  }
1687
1777
  } else {
1688
1778
  imagePayloads.push(entry);
@@ -1710,7 +1800,7 @@ async function handleAnalyzeImage(
1710
1800
 
1711
1801
  // 1.16.0 — best-effort downscale of oversized uploads after cropping
1712
1802
  for (const p of imagePayloads) {
1713
- p.image = await downscaleForUpload(p.image, config);
1803
+ p.image = await downscaleForUpload(p.image, config, modelImageResizeLimits(visionModel));
1714
1804
  }
1715
1805
 
1716
1806
  // Build cache key AFTER crop resolution (so failed crops don't create stale crop keys)
@@ -1739,13 +1829,16 @@ async function handleAnalyzeImage(
1739
1829
  cacheHit: true,
1740
1830
  groundingFormat,
1741
1831
  });
1742
- return cached;
1832
+ return {
1833
+ text: cached.fence,
1834
+ structured: { ok: true, text: cached.raw, cached: true, groundingFormat },
1835
+ };
1743
1836
  }
1744
1837
 
1745
1838
  // Call vision model
1746
1839
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(visionModel);
1747
1840
  if (!auth.ok || !auth.apiKey) {
1748
- return `Error: no API key for ${visionModel.name ?? modelLabel({ provider: visionProvider, modelId: visionModelId })}. Run: pi --login ${visionProvider}`;
1841
+ return analysisError(`Error: no API key for ${visionModel.name ?? modelLabel({ provider: visionProvider, modelId: visionModelId })}. Run: pi --login ${visionProvider}`);
1749
1842
  }
1750
1843
 
1751
1844
  ctx.ui.notify(
@@ -1806,7 +1899,7 @@ async function handleAnalyzeImage(
1806
1899
  const latencyMs = Date.now() - startTime;
1807
1900
 
1808
1901
  if (response.stopReason === "aborted") {
1809
- return "Error: analysis was cancelled.";
1902
+ return analysisError("Error: analysis was cancelled.");
1810
1903
  }
1811
1904
 
1812
1905
  const text = response.content
@@ -1816,7 +1909,7 @@ async function handleAnalyzeImage(
1816
1909
  .trim();
1817
1910
 
1818
1911
  if (!text) {
1819
- return "Error: vision model returned an empty response.";
1912
+ return analysisError("Error: vision model returned an empty response.");
1820
1913
  }
1821
1914
 
1822
1915
  // Build result fence(s)
@@ -1838,8 +1931,8 @@ async function handleAnalyzeImage(
1838
1931
  );
1839
1932
  }
1840
1933
 
1841
- // Cache the result
1842
- _toolCache.set(cacheKey, result);
1934
+ // Cache the result (fence for the model + raw text for structured output)
1935
+ _toolCache.set(cacheKey, { fence: result, raw: text });
1843
1936
 
1844
1937
  // Log telemetry
1845
1938
  pi.appendEntry(CUSTOM_TYPE_TOOL_CALL, {
@@ -1855,9 +1948,28 @@ async function handleAnalyzeImage(
1855
1948
  groundingFormat,
1856
1949
  });
1857
1950
 
1858
- return result;
1951
+ return {
1952
+ text: result,
1953
+ structured: {
1954
+ ok: true,
1955
+ text,
1956
+ provider: usedProvider,
1957
+ model: usedModelId,
1958
+ latencyMs,
1959
+ groundingFormat,
1960
+ images: imagePayloads.map((p) => ({
1961
+ id: p.hash,
1962
+ filename: p.meta?.filename,
1963
+ width: p.crop ? p.crop.width : p.meta?.width,
1964
+ height: p.crop ? p.crop.height : p.meta?.height,
1965
+ crop: p.crop
1966
+ ? { x: p.crop.x, y: p.crop.y, width: p.crop.width, height: p.crop.height }
1967
+ : undefined,
1968
+ })),
1969
+ },
1970
+ };
1859
1971
  } catch (err) {
1860
- return `Error: vision model call failed: ${err instanceof Error ? err.message : String(err)}`;
1972
+ return analysisError(`Error: vision model call failed: ${err instanceof Error ? err.message : String(err)}`);
1861
1973
  }
1862
1974
  }
1863
1975
 
@@ -1932,6 +2044,196 @@ export default function (pi: ExtensionAPI) {
1932
2044
  );
1933
2045
  }
1934
2046
 
2047
+ // ── Virtual model (pi ≥ 0.99) ─────────────────────────────────────────────
2048
+
2049
+ const VIRTUAL_PROVIDER = "multimodal-proxy";
2050
+ const VIRTUAL_MODEL_ID = "auto";
2051
+ let _virtualRegistered = false;
2052
+
2053
+ /** Minimal structural type of the virtual-model route request (pi ≥ 0.99). */
2054
+ interface VirtualRouteRequestLike {
2055
+ reason: "user" | "continuation" | "retry" | "direct";
2056
+ thinkingLevel: string;
2057
+ messages: readonly { role?: string; content?: unknown }[];
2058
+ previous?: { model: unknown; thinkingLevel?: string };
2059
+ failed?: { model: unknown; thinkingLevel?: string };
2060
+ }
2061
+
2062
+ /** Whether the host exposes pi.registerVirtualModel (pi ≥ 0.99). */
2063
+ function virtualModelSupported(): boolean {
2064
+ return typeof (pi as unknown as { registerVirtualModel?: unknown }).registerVirtualModel === "function";
2065
+ }
2066
+
2067
+ /**
2068
+ * Sync vision-target resolution for route(): honors an explicit pick
2069
+ * as-is; otherwise walks the same registry-only fallback chain as
2070
+ * applyDefaultModelFallback (route must not await key probes). Returns
2071
+ * null when nothing resolves.
2072
+ */
2073
+ function resolveVisionTarget(ctx: ExtensionContext): { provider: string; modelId: string } | null {
2074
+ const config = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
2075
+ const explicit = Boolean(envFlags().model) || config.modelExplicit === true;
2076
+ const chain = explicit
2077
+ ? [{ provider: config.provider, modelId: config.modelId }]
2078
+ : [{ provider: config.provider, modelId: config.modelId }, ...DEFAULT_MODEL_FALLBACKS];
2079
+ for (const cand of chain) {
2080
+ const model = ctx.modelRegistry.find(cand.provider, cand.modelId);
2081
+ // Only vision-capable entries qualify: a text-only pin (via
2082
+ // /multimodal-proxy model or PI_VISION_PROXY_MODEL) must not route
2083
+ // media turns to a model that would receive image placeholders
2084
+ // instead of native pixels (PR #34 review).
2085
+ if (model && Array.isArray(model.input) && model.input.includes("image")) return cand;
2086
+ }
2087
+ return null;
2088
+ }
2089
+
2090
+ /** True when the pending user input (tail after the last assistant message) carries an image block. */
2091
+ function requestHasMedia(messages: readonly { role?: string; content?: unknown }[]): boolean {
2092
+ for (let i = messages.length - 1; i >= 0; i--) {
2093
+ const msg = messages[i];
2094
+ if (msg?.role === "assistant") return false;
2095
+ if (msg?.role === "user" && Array.isArray(msg.content)) {
2096
+ if ((msg.content as { type?: string }[]).some((c) => c && typeof c === "object" && c.type === "image")) {
2097
+ return true;
2098
+ }
2099
+ }
2100
+ }
2101
+ return false;
2102
+ }
2103
+
2104
+ /**
2105
+ * Register/unregister the `multimodal-proxy/auto` virtual model to match
2106
+ * config (1.19.0). Selecting it in /model routes media-bearing turns
2107
+ * natively to the configured vision model and text turns to the base model
2108
+ * (pinned via /multimodal-proxy virtual-base, else the physical model that
2109
+ * handled the session before, else the vision model). Route() resolves
2110
+ * config per request, so model changes need no re-registration.
2111
+ */
2112
+ function syncVirtualModelRegistration(config: VisionConfig) {
2113
+ const api = pi as unknown as {
2114
+ registerVirtualModel?: (m: unknown) => void;
2115
+ unregisterVirtualModel?: (provider: string, id: string) => void;
2116
+ };
2117
+ if (typeof api.registerVirtualModel !== "function") return; // pi < 0.99
2118
+ const want = config.virtualModel === "on";
2119
+ if (want && !_virtualRegistered) {
2120
+ api.registerVirtualModel({
2121
+ provider: VIRTUAL_PROVIDER,
2122
+ id: VIRTUAL_MODEL_ID,
2123
+ name: "Multimodal Auto",
2124
+ thinkingLevels: ["off", "low", "medium", "high"],
2125
+ input: ["text", "image"],
2126
+ route: (request: VirtualRouteRequestLike, ctx: ExtensionContext) => {
2127
+ // Sticky routing keeps prompt caches and thinking signatures
2128
+ // valid: continuations stay on the model that handled the turn,
2129
+ // retries on the model that failed (provider-level failover stays
2130
+ // with the vision fallback chain).
2131
+ if (request.reason === "retry" && request.failed) {
2132
+ return {
2133
+ model: request.failed.model,
2134
+ thinkingLevel: request.failed.thinkingLevel ?? request.thinkingLevel,
2135
+ };
2136
+ }
2137
+ if (request.reason !== "user" && request.previous) {
2138
+ return {
2139
+ model: request.previous.model,
2140
+ thinkingLevel: request.previous.thinkingLevel ?? request.thinkingLevel,
2141
+ };
2142
+ }
2143
+
2144
+ const findOrThrow = (provider: string, modelId: string, what: string): unknown => {
2145
+ const model = ctx.modelRegistry.find(provider, modelId);
2146
+ if (!model) {
2147
+ throw new Error(
2148
+ `[multimodal-proxy] virtual model cannot find ${what} "${provider}/${modelId}". Run /multimodal-proxy pick to choose a vision model.`,
2149
+ );
2150
+ }
2151
+ return model;
2152
+ };
2153
+
2154
+ // PR #34 review (security): the virtual model auto-selects the
2155
+ // vision provider on the user's behalf, so native delivery must
2156
+ // respect the extension's data-egress consent state for it —
2157
+ // selecting automatic routing must not silently contradict a
2158
+ // revocation. Consent is evaluated whenever a target is
2159
+ // (re)selected — i.e. at the start of every user turn; sticky
2160
+ // continuations/retries keep the already-authorized target so
2161
+ // prompt caches stay valid.
2162
+ const requireConsent = (provider: string): void => {
2163
+ const entries = ctx.sessionManager.getEntries();
2164
+ if (!hasConsent(entries, provider, _fileConfig.allowedProviders, _fileConfig.deniedProviders)) {
2165
+ throw new Error(
2166
+ `[multimodal-proxy] data-egress consent for ${provider} is required before the virtual model can route there. Run /multimodal-proxy consent yes, or pre-consent permanently: /multimodal-proxy allowed-providers add ${provider}`,
2167
+ );
2168
+ }
2169
+ };
2170
+
2171
+ if (requestHasMedia(request.messages)) {
2172
+ // Media turn: route natively to the vision model — the actual
2173
+ // pixels reach a model that can see them, no text fence needed.
2174
+ const target = resolveVisionTarget(ctx);
2175
+ if (!target) {
2176
+ throw new Error(
2177
+ "[multimodal-proxy] no vision model configured for the virtual model — run /multimodal-proxy pick.",
2178
+ );
2179
+ }
2180
+ requireConsent(target.provider);
2181
+ return {
2182
+ model: findOrThrow(target.provider, target.modelId, "vision model"),
2183
+ thinkingLevel: request.thinkingLevel,
2184
+ };
2185
+ }
2186
+
2187
+ // Text turn: pinned base, else the physical model that handled
2188
+ // the session before, else the vision model.
2189
+ const cfg = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
2190
+ if (cfg.virtualBaseProvider && cfg.virtualBaseModelId) {
2191
+ const base = ctx.modelRegistry.find(cfg.virtualBaseProvider, cfg.virtualBaseModelId);
2192
+ if (base) return { model: base, thinkingLevel: request.thinkingLevel };
2193
+ }
2194
+ if (request.previous) {
2195
+ return {
2196
+ model: request.previous.model,
2197
+ thinkingLevel: request.previous.thinkingLevel ?? request.thinkingLevel,
2198
+ };
2199
+ }
2200
+ const target = resolveVisionTarget(ctx);
2201
+ if (!target) {
2202
+ throw new Error(
2203
+ "[multimodal-proxy] no vision model configured for the virtual model — run /multimodal-proxy pick.",
2204
+ );
2205
+ }
2206
+ requireConsent(target.provider);
2207
+ return {
2208
+ model: findOrThrow(target.provider, target.modelId, "vision model"),
2209
+ thinkingLevel: request.thinkingLevel,
2210
+ };
2211
+ },
2212
+ });
2213
+ _virtualRegistered = true;
2214
+ } else if (!want && _virtualRegistered) {
2215
+ api.unregisterVirtualModel?.(VIRTUAL_PROVIDER, VIRTUAL_MODEL_ID);
2216
+ _virtualRegistered = false;
2217
+ }
2218
+ }
2219
+
2220
+ // Register the virtual model at extension LOAD time (not just session_start)
2221
+ // so it is visible to /model, --model, scoped models, and settings, which
2222
+ // resolve before the first session event fires. The synchronous call uses
2223
+ // defaults + env only (no file I/O) so it lands before CLI model
2224
+ // resolution; the async file-config load then corrects it (unregisters if
2225
+ // the persisted config says 'off'). session_start re-syncs with the full
2226
+ // entry-aware config afterwards.
2227
+ syncVirtualModelRegistration(resolveConfig([], process.env, {}));
2228
+ void readPersistentFile()
2229
+ .then((fileConfig) => {
2230
+ _fileConfig = fileConfig;
2231
+ syncVirtualModelRegistration(resolveConfig([], process.env, _fileConfig));
2232
+ })
2233
+ .catch(() => {
2234
+ // unreadable config file — session_start surfaces the error path
2235
+ });
2236
+
1935
2237
  /** Register or unregister the analyze_image tool based on config. */
1936
2238
  function syncToolRegistration(config: VisionConfig) {
1937
2239
  const shouldHaveTool = config.mode !== "off" && config.tool === "on";
@@ -1946,6 +2248,21 @@ export default function (pi: ExtensionAPI) {
1946
2248
  "The tool supports cropping - use region, normalized, or pixel coordinates to focus on a specific area.",
1947
2249
  "Results include image dimensions, filename, and grounding format metadata in the response fence.",
1948
2250
  ],
2251
+ // pi ≥ 0.99 tool API: MCP-style hints (analysis is read-only but sends
2252
+ // image data to the configured vision provider), a namespace grouping
2253
+ // for codemode listings, and a structured output schema so codemode
2254
+ // scripts receive machine-readable results. Older hosts ignore these.
2255
+ annotations: {
2256
+ readOnlyHint: true,
2257
+ destructiveHint: false,
2258
+ idempotentHint: false,
2259
+ openWorldHint: true,
2260
+ },
2261
+ namespace: {
2262
+ name: "multimodal-proxy",
2263
+ description: "pi-multimodal-proxy media analysis (targeted image re-analysis with crop and grounding)",
2264
+ },
2265
+ outputSchema: AnalyzeImageOutput,
1949
2266
  parameters: AnalyzeImageParams,
1950
2267
  execute: async (_toolCallId, params, _signal, _onUpdate, extCtx) => {
1951
2268
  const entries = extCtx.sessionManager.getEntries();
@@ -1953,14 +2270,14 @@ export default function (pi: ExtensionAPI) {
1953
2270
 
1954
2271
  // Runtime check - tool may have been disabled mid-session
1955
2272
  if (config.tool !== "on" || config.mode === "off") {
1956
- return { content: [{ type: "text" as const, text: "Error: analyze_image tool is currently disabled. Use /multimodal-proxy tool on to enable." }] };
2273
+ return { content: [{ type: "text" as const, text: "Error: analyze_image tool is currently disabled. Use /multimodal-proxy tool on to enable." }], isError: true };
1957
2274
  }
1958
2275
 
1959
2276
  // Rate limit per turn
1960
2277
  const state = getSessionState(extCtx);
1961
2278
  state.toolCallCount++;
1962
2279
  if (state.toolCallCount > MAX_TOOL_CALLS_PER_TURN) {
1963
- return { content: [{ type: "text" as const, text: `Error: analyze_image call limit reached (${MAX_TOOL_CALLS_PER_TURN} per turn). Rephrase your question or try in the next turn.` }] };
2280
+ return { content: [{ type: "text" as const, text: `Error: analyze_image call limit reached (${MAX_TOOL_CALLS_PER_TURN} per turn). Rephrase your question or try in the next turn.` }], isError: true };
1964
2281
  }
1965
2282
 
1966
2283
  // Sync cache size with current config
@@ -1968,8 +2285,15 @@ export default function (pi: ExtensionAPI) {
1968
2285
  state.toolCache.resize(config.cacheSize);
1969
2286
  }
1970
2287
 
1971
- const result = await handleAnalyzeImage(params, extCtx, pi, config);
1972
- return { content: [{ type: "text" as const, text: result }] };
2288
+ const outcome = await handleAnalyzeImage(params, extCtx, pi, config);
2289
+ return {
2290
+ content: [{ type: "text" as const, text: outcome.text }],
2291
+ // pi ≥ 0.99: `isError` marks failures for the model, and
2292
+ // `structuredContent` carries the outputSchema-shaped result for
2293
+ // codemode scripts. Older hosts ignore both fields.
2294
+ ...(outcome.isError ? { isError: true } : {}),
2295
+ ...(outcome.structured ? { structuredContent: outcome.structured } : {}),
2296
+ };
1973
2297
  },
1974
2298
  });
1975
2299
  _toolRegistered = true;
@@ -2000,6 +2324,9 @@ export default function (pi: ExtensionAPI) {
2000
2324
  // Register tool if enabled
2001
2325
  syncToolRegistration(config);
2002
2326
 
2327
+ // Register the multimodal-proxy/auto virtual model (pi ≥ 0.99)
2328
+ syncVirtualModelRegistration(config);
2329
+
2003
2330
  // Stack the `#` image-recall autocomplete on the editor
2004
2331
  registerRecallAutocomplete(ctx);
2005
2332
  });
@@ -2022,6 +2349,7 @@ export default function (pi: ExtensionAPI) {
2022
2349
  const rawConfig = resolveConfig(entries, process.env, _fileConfig);
2023
2350
  const config = await withModelFallback(rawConfig, ctx);
2024
2351
  notifyDefaultSubstitution(rawConfig, config, ctx);
2352
+ syncVirtualModelRegistration(config);
2025
2353
  const pathAccess = pathAccessFromConfig(config);
2026
2354
 
2027
2355
  // Collect images: structured attachments + file paths detected in prompt
@@ -2355,7 +2683,7 @@ export default function (pi: ExtensionAPI) {
2355
2683
 
2356
2684
  // 1.16.0 — best-effort downscale of oversized joint uploads
2357
2685
  const jointPayloads = await Promise.all(
2358
- jointImages.map((img) => downscaleForUpload(img, config)),
2686
+ jointImages.map((img) => downscaleForUpload(img, config, modelImageResizeLimits(jointVisionModel))),
2359
2687
  );
2360
2688
 
2361
2689
  const contentParts: Array<{ type: "text"; text: string } | PiAiImage> = [
@@ -2534,8 +2862,10 @@ export default function (pi: ExtensionAPI) {
2534
2862
  // Restate the recall affordance once per context build (not per image),
2535
2863
  // so the agent is reminded it can re-query earlier images even on turns
2536
2864
  // where no new image was attached. Trusted extension text — kept outside
2537
- // the untrusted description fence.
2538
- let recallHintInjected = false;
2865
+ // the untrusted description fence. Once the host's context_with_system
2866
+ // event has been observed (pi ≥ 0.87, see the handler below), that
2867
+ // system-layer block owns the affordance and this path stays silent.
2868
+ let recallHintInjected = sessionState.cwsSupported === true;
2539
2869
 
2540
2870
  let modified = false;
2541
2871
  let messages = !strip ? event.messages : event.messages.map((msg) => {
@@ -2655,6 +2985,32 @@ export default function (pi: ExtensionAPI) {
2655
2985
  if (modified) return { messages };
2656
2986
  });
2657
2987
 
2988
+ // pi ≥ 0.87: `context_with_system` runs after `context` on the full
2989
+ // transcript (system messages included) and its result is sent verbatim.
2990
+ // The first invocation only probes support — the context-path hint already
2991
+ // covered that request — then a stable system-layer block carries the recall
2992
+ // affordance. Unlike the user-message path it survives compaction and also
2993
+ // reaches vision-capable session models, where the strip path never fired.
2994
+ // The handler never fires on older hosts, so behavior there is unchanged.
2995
+ pi.on("context_with_system", async (event: { messages: unknown[] }, ctx: ExtensionContext) => {
2996
+ const state = getSessionState(ctx);
2997
+ if (!state.cwsSupported) {
2998
+ state.cwsSupported = true;
2999
+ return; // probe-only: the context-path hint covered this request
3000
+ }
3001
+ const entries = ctx.sessionManager.getEntries();
3002
+ const config = resolveConfig(entries, process.env, _fileConfig);
3003
+ if (config.mode === "off" || config.tool !== "on") return;
3004
+ // Only advertise recall once media has actually been described this session.
3005
+ const descriptions = findDescriptions(entries);
3006
+ if (descriptions.size === 0 && findVideoDescriptions(entries).size === 0) return;
3007
+ const messages = event.messages as Array<{ role: string; content?: unknown }>;
3008
+ const hint = `[vision-proxy] ${RECALL_HINT}`;
3009
+ const last = messages[messages.length - 1];
3010
+ if (last?.role === "system" && last.content === hint) return; // already present
3011
+ return { messages: [...messages, { role: "system", content: hint }] };
3012
+ });
3013
+
2658
3014
  // ── /multimodal-proxy command ─────────────────────────────────────────
2659
3015
 
2660
3016
  /**
@@ -2862,6 +3218,77 @@ export default function (pi: ExtensionAPI) {
2862
3218
  return;
2863
3219
  }
2864
3220
 
3221
+ // ── Virtual model toggle (pi ≥ 0.99) ─────────────────
3222
+ if (sub === "virtual") {
3223
+ if (env.virtual) {
3224
+ ctx.ui.notify(
3225
+ "[multimodal-proxy] PI_VISION_PROXY_VIRTUAL is set - env overrides commands. Unset to change.",
3226
+ "warning",
3227
+ );
3228
+ return;
3229
+ }
3230
+ if (valueLower === "on" || valueLower === "off") {
3231
+ const next = writePersisted({ ...persisted, virtualModel: valueLower as "on" | "off" });
3232
+ if (!virtualModelSupported() && next.virtualModel === "on") {
3233
+ ctx.ui.notify(
3234
+ "Virtual model: on — but this pi version does not support virtual models (needs pi ≥ 0.99). It will activate after upgrading pi.",
3235
+ "warning",
3236
+ );
3237
+ } else {
3238
+ ctx.ui.notify(
3239
+ next.virtualModel === "on"
3240
+ ? "Virtual model: on — select 'Multimodal Auto' (multimodal-proxy/auto) in /model. Media turns route to the vision model, text turns to the base model."
3241
+ : "Virtual model: off",
3242
+ next.virtualModel === "on" ? "info" : "warning",
3243
+ );
3244
+ }
3245
+ syncVirtualModelRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
3246
+ return;
3247
+ }
3248
+ ctx.ui.notify(
3249
+ `Virtual model: ${persisted.virtualModel ?? "on"}\nUsage: /multimodal-proxy virtual on|off\nOptionally pin the text-turn model: /multimodal-proxy virtual-base provider/model-id (or 'off' to clear)`,
3250
+ "info",
3251
+ );
3252
+ return;
3253
+ }
3254
+
3255
+ // ── Virtual model base pin ───────────────────────────
3256
+ if (sub === "virtual-base") {
3257
+ if (env.virtualBase) {
3258
+ ctx.ui.notify(
3259
+ "[multimodal-proxy] PI_VISION_PROXY_VIRTUAL_BASE is set - env overrides commands. Unset to change.",
3260
+ "warning",
3261
+ );
3262
+ return;
3263
+ }
3264
+ if (valueLower === "off" || valueLower === "none" || valueLower === "clear") {
3265
+ writePersisted({ ...persisted, virtualBaseProvider: undefined, virtualBaseModelId: undefined });
3266
+ ctx.ui.notify("Virtual model base: auto (last physical model, else the vision model)", "info");
3267
+ return;
3268
+ }
3269
+ if (!value) {
3270
+ ctx.ui.notify(
3271
+ `Virtual model base: ${persisted.virtualBaseProvider && persisted.virtualBaseModelId ? `${persisted.virtualBaseProvider}/${persisted.virtualBaseModelId}` : "auto (last physical model, else the vision model)"}\nUsage: /multimodal-proxy virtual-base provider/model-id|off`,
3272
+ "info",
3273
+ );
3274
+ return;
3275
+ }
3276
+ const parsed = parseModelString(value);
3277
+ if (!parsed) {
3278
+ ctx.ui.notify(
3279
+ "Usage: /multimodal-proxy virtual-base provider/model-id|off\nExample: /multimodal-proxy virtual-base anthropic/claude-haiku-4-5",
3280
+ "warning",
3281
+ );
3282
+ return;
3283
+ }
3284
+ writePersisted({ ...persisted, virtualBaseProvider: parsed.provider, virtualBaseModelId: parsed.modelId });
3285
+ ctx.ui.notify(
3286
+ `Virtual model base: ${parsed.provider}/${parsed.modelId} (text turns while 'Multimodal Auto' is selected)`,
3287
+ "info",
3288
+ );
3289
+ return;
3290
+ }
3291
+
2865
3292
  // ── Pick from vision-capable registry ───────────────
2866
3293
  if (sub === "pick") {
2867
3294
  await pickVisionModel(ctx, persisted, writePersisted, !!env.model);
@@ -3751,7 +4178,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3751
4178
 
3752
4179
  // 1.16.0 — best-effort downscale of oversized uploads after cropping
3753
4180
  for (const p of imagePayloads) {
3754
- p.image = await downscaleForUpload(p.image, descConfig);
4181
+ p.image = await downscaleForUpload(p.image, descConfig, modelImageResizeLimits(descVisionModel));
3755
4182
  }
3756
4183
 
3757
4184
  // Get auth
@@ -3875,6 +4302,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3875
4302
  env.allowHome && "allowHome", env.allowedFolders && "allowedFolders",
3876
4303
  env.statusLine && "statusLine", env.pathDetection && "pathDetection",
3877
4304
  env.retryMax && "retryMax", env.maxUpload && "maxUpload", env.fallbackModel && "fallbackModel",
4305
+ env.virtual && "virtual", env.virtualBase && "virtualBase",
3878
4306
  ].filter(Boolean).join(", ");
3879
4307
  const summary =
3880
4308
  `Vision proxy: ${modeLabel(effective.mode)}\n` +
@@ -3892,6 +4320,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3892
4320
  `Allow home: ${effective.allowHome ? "ON" : "OFF"}\n` +
3893
4321
  `Status line: ${effective.statusLine === "on" ? "ON" : "OFF"}\n` +
3894
4322
  `Path detection: ${effective.pathDetection === "on" ? "ON" : "OFF"}\n` +
4323
+ `Virtual model: ${effective.virtualModel === "on" ? `on${virtualModelSupported() ? "" : " (needs pi ≥ 0.99)"} — base ${effective.virtualBaseProvider && effective.virtualBaseModelId ? `${effective.virtualBaseProvider}/${effective.virtualBaseModelId}` : "auto"}` : "off"}\n` +
3895
4324
  `Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}\n` +
3896
4325
  `Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}\n` +
3897
4326
  (activeEnvOverrides ? `Env overrides: ${activeEnvOverrides}\n` : "");
@@ -3899,7 +4328,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3899
4328
  if (!ctx.hasUI) {
3900
4329
  ctx.ui.notify(
3901
4330
  summary +
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`,
4331
+ `\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> | virtual on|off | virtual-base provider/model-id|off | doctor | test`,
3903
4332
  "info",
3904
4333
  );
3905
4334
  return;
@@ -3920,6 +4349,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3920
4349
  `Allow home: ${effective.allowHome ? "ON" : "OFF"}`,
3921
4350
  `Status line: ${effective.statusLine === "on" ? "ON" : "OFF"}`,
3922
4351
  `Path detection: ${effective.pathDetection === "on" ? "ON" : "OFF"}`,
4352
+ `Virtual model: ${effective.virtualModel === "on" ? "on" : "off"}${effective.virtualModel === "on" && effective.virtualBaseProvider && effective.virtualBaseModelId ? ` (base ${effective.virtualBaseProvider}/${effective.virtualBaseModelId})` : ""}`,
3923
4353
  `Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}`,
3924
4354
  `Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}`,
3925
4355
  "Run diagnostics (doctor)",
@@ -4158,6 +4588,54 @@ Use "*" or "all" to grant consent for all providers globally.`,
4158
4588
  return;
4159
4589
  }
4160
4590
 
4591
+ if (choice.startsWith("Virtual model")) {
4592
+ if (env.virtual) {
4593
+ ctx.ui.notify("[multimodal-proxy] Env override active for virtual model.", "warning");
4594
+ return;
4595
+ }
4596
+ const virtChoice = await ctx.ui.select(
4597
+ "Virtual model (pi ≥ 0.99): select 'Multimodal Auto' in /model; media turns route to the vision model, text turns to the base",
4598
+ ["on", "off", "Set base model…"],
4599
+ );
4600
+ if (virtChoice === "on" || virtChoice === "off") {
4601
+ const next = writePersisted({ ...persisted, virtualModel: virtChoice });
4602
+ if (!virtualModelSupported() && next.virtualModel === "on") {
4603
+ ctx.ui.notify("Virtual model: on — this pi version does not support virtual models (needs pi ≥ 0.99); activates after upgrading.", "warning");
4604
+ } else {
4605
+ ctx.ui.notify(`Virtual model: ${next.virtualModel}`, next.virtualModel === "on" ? "info" : "warning");
4606
+ }
4607
+ syncVirtualModelRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
4608
+ return;
4609
+ }
4610
+ if (virtChoice === "Set base model…") {
4611
+ if (env.virtualBase) {
4612
+ ctx.ui.notify("[multimodal-proxy] Env override active for virtual base.", "warning");
4613
+ return;
4614
+ }
4615
+ const val = await ctx.ui.input(
4616
+ "Virtual model base (provider/model-id, or blank for auto)",
4617
+ effective.virtualBaseProvider && effective.virtualBaseModelId
4618
+ ? `${effective.virtualBaseProvider}/${effective.virtualBaseModelId}`
4619
+ : "",
4620
+ );
4621
+ if (val === undefined || val === null) return;
4622
+ const trimmed = val.trim();
4623
+ if (!trimmed) {
4624
+ writePersisted({ ...persisted, virtualBaseProvider: undefined, virtualBaseModelId: undefined });
4625
+ ctx.ui.notify("Virtual model base: auto (last physical model, else the vision model)", "info");
4626
+ return;
4627
+ }
4628
+ const parsed = parseModelString(trimmed);
4629
+ if (!parsed) {
4630
+ ctx.ui.notify("Usage: provider/model-id — Example: anthropic/claude-haiku-4-5", "warning");
4631
+ return;
4632
+ }
4633
+ writePersisted({ ...persisted, virtualBaseProvider: parsed.provider, virtualBaseModelId: parsed.modelId });
4634
+ ctx.ui.notify(`Virtual model base: ${parsed.provider}/${parsed.modelId}`, "info");
4635
+ }
4636
+ return;
4637
+ }
4638
+
4161
4639
  if (choice.startsWith("Consent")) {
4162
4640
  const granted = !hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders);
4163
4641
  pi.appendEntry<ConsentEntry>(CUSTOM_TYPE_CONSENT, { granted, provider: effective.provider });