pi-multimodal-proxy 1.18.0 → 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 +30 -0
- package/README.md +31 -0
- package/extensions/__tests__/internal.test.ts +122 -3
- package/extensions/internal.ts +143 -17
- package/extensions/vision-proxy.ts +533 -48
- package/package.json +1 -1
|
@@ -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,
|
|
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,
|
|
528
|
+
toolCache: new LRUCache<string, AnalyzeImageCacheValue>(DEFAULT_TOOL_CACHE_SIZE),
|
|
468
529
|
toolCallCount: 0,
|
|
469
530
|
imageMeta: createImageMetaStore(),
|
|
470
531
|
imageData: createImageDataStore(),
|
|
@@ -667,9 +728,10 @@ function shouldStripImages(config: VisionConfig, model: ExtensionContext["model"
|
|
|
667
728
|
* this runs on every prompt, tool result, and status refresh.
|
|
668
729
|
*/
|
|
669
730
|
async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): Promise<VisionConfig> {
|
|
670
|
-
|
|
671
|
-
if (envFlags().model
|
|
731
|
+
// Explicit choices are never rewritten — skip the key probes entirely.
|
|
732
|
+
if (envFlags().model || config.modelExplicit === true) return config;
|
|
672
733
|
|
|
734
|
+
const hasModel = (p: string, m: string) => Boolean(ctx.modelRegistry.find(p, m));
|
|
673
735
|
const state = getSessionState(ctx);
|
|
674
736
|
state.keyProbe ??= new Map<string, boolean>();
|
|
675
737
|
const probe = async (p: string, m: string): Promise<boolean> => {
|
|
@@ -708,6 +770,8 @@ async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): P
|
|
|
708
770
|
* configured model — e.g. no zai key → deepseek/deepseek-v4-flash-vision-exp.
|
|
709
771
|
*/
|
|
710
772
|
function notifyDefaultSubstitution(config: VisionConfig, resolved: VisionConfig, ctx: ExtensionContext): void {
|
|
773
|
+
// Proxy disabled — model resolution is irrelevant, don't add noise.
|
|
774
|
+
if (resolved.mode === "off") return;
|
|
711
775
|
const state = getSessionState(ctx);
|
|
712
776
|
const before = `${config.provider}/${config.modelId}`;
|
|
713
777
|
const after = `${resolved.provider}/${resolved.modelId}`;
|
|
@@ -715,11 +779,11 @@ function notifyDefaultSubstitution(config: VisionConfig, resolved: VisionConfig,
|
|
|
715
779
|
const key = `${before}→${after}`;
|
|
716
780
|
if (state.notifiedDefaultSub === key) return;
|
|
717
781
|
state.notifiedDefaultSub = key;
|
|
718
|
-
ctx.
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
);
|
|
782
|
+
const missingFromCatalog = !ctx.modelRegistry.find(config.provider, config.modelId);
|
|
783
|
+
const hint = missingFromCatalog
|
|
784
|
+
? "update pi (older model catalog), or pin a model with /multimodal-proxy model"
|
|
785
|
+
: `add a key: pi --login ${config.provider} — or pin a model with /multimodal-proxy model`;
|
|
786
|
+
ctx.ui.notify(`[multimodal-proxy] Vision default resolved to ${after} (${before} not usable here). ${hint}`, "info");
|
|
723
787
|
}
|
|
724
788
|
|
|
725
789
|
// ── Content sniffing (Pi ≥ 0.84.4) ───────────────────────────────────────
|
|
@@ -788,6 +852,7 @@ function friendlyModelLabel(
|
|
|
788
852
|
* Returns undefined (which clears the status entry) when the user hid the
|
|
789
853
|
* status line via `/multimodal-proxy status off`.
|
|
790
854
|
*/
|
|
855
|
+
/** Steady-state status-line text summarizing mode, models, tool, and virtual-model state. */
|
|
791
856
|
function steadyStatusText(
|
|
792
857
|
config: VisionConfig,
|
|
793
858
|
registry: ExtensionContext["modelRegistry"],
|
|
@@ -796,7 +861,8 @@ function steadyStatusText(
|
|
|
796
861
|
return (
|
|
797
862
|
`multimodal-proxy: ${config.mode} → ${friendlyModelLabel(config, registry)} ` +
|
|
798
863
|
`| video: ${config.videoProvider}/${config.videoModelId}` +
|
|
799
|
-
`${config.tool === "on" && config.mode !== "off" ? " [+tool]" : ""}`
|
|
864
|
+
`${config.tool === "on" && config.mode !== "off" ? " [+tool]" : ""}` +
|
|
865
|
+
`${config.virtualModel === "on" ? " [+virtual]" : ""}`
|
|
800
866
|
);
|
|
801
867
|
}
|
|
802
868
|
|
|
@@ -964,8 +1030,9 @@ async function analyzeImages(
|
|
|
964
1030
|
|
|
965
1031
|
// 1.16.0 — best-effort downscale of oversized uploads (cost/limit protection).
|
|
966
1032
|
// Hash/cache/recall still key on the ORIGINAL bytes — only the upload
|
|
967
|
-
// payload is shrunk.
|
|
968
|
-
|
|
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));
|
|
969
1036
|
|
|
970
1037
|
try {
|
|
971
1038
|
const { response } = await completeVision(
|
|
@@ -1560,6 +1627,32 @@ async function analyzeVideoViaXaiResponsesFile(
|
|
|
1560
1627
|
}
|
|
1561
1628
|
}
|
|
1562
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
|
+
*/
|
|
1563
1656
|
async function handleAnalyzeImage(
|
|
1564
1657
|
params: {
|
|
1565
1658
|
images: string[];
|
|
@@ -1571,20 +1664,20 @@ async function handleAnalyzeImage(
|
|
|
1571
1664
|
ctx: ExtensionContext,
|
|
1572
1665
|
pi: ExtensionAPI,
|
|
1573
1666
|
config: VisionConfig,
|
|
1574
|
-
): Promise<
|
|
1667
|
+
): Promise<AnalyzeImageOutcome> {
|
|
1575
1668
|
const { images: imageRefs, question, model: modelOverride, crop: crops, reason } = params;
|
|
1576
1669
|
|
|
1577
1670
|
if (!question || question.trim().length === 0) {
|
|
1578
|
-
return "Error: question is required and must be non-empty.";
|
|
1671
|
+
return analysisError("Error: question is required and must be non-empty.");
|
|
1579
1672
|
}
|
|
1580
1673
|
if (question.length > 4000) {
|
|
1581
|
-
return "Error: question must be at most 4000 characters.";
|
|
1674
|
+
return analysisError("Error: question must be at most 4000 characters.");
|
|
1582
1675
|
}
|
|
1583
1676
|
if (imageRefs.length === 0) {
|
|
1584
|
-
return "Error: at least one image is required.";
|
|
1677
|
+
return analysisError("Error: at least one image is required.");
|
|
1585
1678
|
}
|
|
1586
1679
|
if (imageRefs.length > config.maxImagesPerCall) {
|
|
1587
|
-
return `Error: too many images (${imageRefs.length}). Maximum is ${config.maxImagesPerCall}
|
|
1680
|
+
return analysisError(`Error: too many images (${imageRefs.length}). Maximum is ${config.maxImagesPerCall}.`);
|
|
1588
1681
|
}
|
|
1589
1682
|
|
|
1590
1683
|
// Validate crop indices: no duplicates, all in range
|
|
@@ -1592,11 +1685,11 @@ async function handleAnalyzeImage(
|
|
|
1592
1685
|
const seen = new Set<number>();
|
|
1593
1686
|
for (const c of crops) {
|
|
1594
1687
|
if (seen.has(c.image_index)) {
|
|
1595
|
-
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.`);
|
|
1596
1689
|
}
|
|
1597
1690
|
seen.add(c.image_index);
|
|
1598
1691
|
if (c.image_index < 0 || c.image_index >= imageRefs.length) {
|
|
1599
|
-
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}).`);
|
|
1600
1693
|
}
|
|
1601
1694
|
}
|
|
1602
1695
|
}
|
|
@@ -1607,7 +1700,7 @@ async function handleAnalyzeImage(
|
|
|
1607
1700
|
if (modelOverride) {
|
|
1608
1701
|
const parsed = parseModelString(modelOverride);
|
|
1609
1702
|
if (!parsed) {
|
|
1610
|
-
return `Error: invalid model string "${modelOverride}". Expected format: provider/model-id
|
|
1703
|
+
return analysisError(`Error: invalid model string "${modelOverride}". Expected format: provider/model-id`);
|
|
1611
1704
|
}
|
|
1612
1705
|
visionProvider = parsed.provider;
|
|
1613
1706
|
visionModelId = parsed.modelId;
|
|
@@ -1616,16 +1709,16 @@ async function handleAnalyzeImage(
|
|
|
1616
1709
|
// Verify model exists and supports images
|
|
1617
1710
|
const visionModel = ctx.modelRegistry.find(visionProvider, visionModelId);
|
|
1618
1711
|
if (!visionModel) {
|
|
1619
|
-
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.`);
|
|
1620
1713
|
}
|
|
1621
1714
|
if (!visionModel.input.includes("image")) {
|
|
1622
|
-
return `Error: model "${visionModel.name ?? visionModelId}" does not support image input
|
|
1715
|
+
return analysisError(`Error: model "${visionModel.name ?? visionModelId}" does not support image input.`);
|
|
1623
1716
|
}
|
|
1624
1717
|
|
|
1625
1718
|
// Check consent for the resolved vision provider
|
|
1626
1719
|
const entries = ctx.sessionManager.getEntries();
|
|
1627
1720
|
if (!hasConsent(entries, visionProvider, config.allowedProviders, config.deniedProviders)) {
|
|
1628
|
-
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})`)
|
|
1629
1722
|
}
|
|
1630
1723
|
|
|
1631
1724
|
// Resolve image references to PiAiImage objects.
|
|
@@ -1638,7 +1731,7 @@ async function handleAnalyzeImage(
|
|
|
1638
1731
|
if (recallHash) {
|
|
1639
1732
|
const stored = getImageData(imageData, recallHash);
|
|
1640
1733
|
if (!stored) {
|
|
1641
|
-
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.`);
|
|
1642
1735
|
}
|
|
1643
1736
|
const image: PiAiImage = { type: "image", data: stored.data, mimeType: stored.mimeType };
|
|
1644
1737
|
// Backfill dimensions if metadata was evicted, so crops still work on recall.
|
|
@@ -1649,11 +1742,11 @@ async function handleAnalyzeImage(
|
|
|
1649
1742
|
|
|
1650
1743
|
// File path
|
|
1651
1744
|
if (ref.includes("..")) {
|
|
1652
|
-
return `Error: path contains disallowed ".." segments
|
|
1745
|
+
return analysisError(`Error: path contains disallowed ".." segments.`);
|
|
1653
1746
|
}
|
|
1654
1747
|
const r = await readImageFileWithReason(ref, pathAccessFromConfig(config), sniffImageFile);
|
|
1655
1748
|
if (!r.image) {
|
|
1656
|
-
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)}`);
|
|
1657
1750
|
}
|
|
1658
1751
|
const hash = hashImageData(r.image.data);
|
|
1659
1752
|
storeImageMeta(imageMeta, hash, r.image.data, r.filename);
|
|
@@ -1673,13 +1766,13 @@ async function handleAnalyzeImage(
|
|
|
1673
1766
|
if (cropEntry) {
|
|
1674
1767
|
const meta = entry.meta;
|
|
1675
1768
|
if (!meta) {
|
|
1676
|
-
return `Error: cannot crop image ${i} - image dimensions unknown
|
|
1769
|
+
return analysisError(`Error: cannot crop image ${i} - image dimensions unknown.`);
|
|
1677
1770
|
}
|
|
1678
1771
|
try {
|
|
1679
1772
|
const resolved = resolveCropEntry(cropEntry, meta.width, meta.height);
|
|
1680
1773
|
imagePayloads.push({ ...entry, crop: resolved });
|
|
1681
1774
|
} catch (err) {
|
|
1682
|
-
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)}`);
|
|
1683
1776
|
}
|
|
1684
1777
|
} else {
|
|
1685
1778
|
imagePayloads.push(entry);
|
|
@@ -1707,7 +1800,7 @@ async function handleAnalyzeImage(
|
|
|
1707
1800
|
|
|
1708
1801
|
// 1.16.0 — best-effort downscale of oversized uploads after cropping
|
|
1709
1802
|
for (const p of imagePayloads) {
|
|
1710
|
-
p.image = await downscaleForUpload(p.image, config);
|
|
1803
|
+
p.image = await downscaleForUpload(p.image, config, modelImageResizeLimits(visionModel));
|
|
1711
1804
|
}
|
|
1712
1805
|
|
|
1713
1806
|
// Build cache key AFTER crop resolution (so failed crops don't create stale crop keys)
|
|
@@ -1736,13 +1829,16 @@ async function handleAnalyzeImage(
|
|
|
1736
1829
|
cacheHit: true,
|
|
1737
1830
|
groundingFormat,
|
|
1738
1831
|
});
|
|
1739
|
-
return
|
|
1832
|
+
return {
|
|
1833
|
+
text: cached.fence,
|
|
1834
|
+
structured: { ok: true, text: cached.raw, cached: true, groundingFormat },
|
|
1835
|
+
};
|
|
1740
1836
|
}
|
|
1741
1837
|
|
|
1742
1838
|
// Call vision model
|
|
1743
1839
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(visionModel);
|
|
1744
1840
|
if (!auth.ok || !auth.apiKey) {
|
|
1745
|
-
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}`);
|
|
1746
1842
|
}
|
|
1747
1843
|
|
|
1748
1844
|
ctx.ui.notify(
|
|
@@ -1803,7 +1899,7 @@ async function handleAnalyzeImage(
|
|
|
1803
1899
|
const latencyMs = Date.now() - startTime;
|
|
1804
1900
|
|
|
1805
1901
|
if (response.stopReason === "aborted") {
|
|
1806
|
-
return "Error: analysis was cancelled.";
|
|
1902
|
+
return analysisError("Error: analysis was cancelled.");
|
|
1807
1903
|
}
|
|
1808
1904
|
|
|
1809
1905
|
const text = response.content
|
|
@@ -1813,7 +1909,7 @@ async function handleAnalyzeImage(
|
|
|
1813
1909
|
.trim();
|
|
1814
1910
|
|
|
1815
1911
|
if (!text) {
|
|
1816
|
-
return "Error: vision model returned an empty response.";
|
|
1912
|
+
return analysisError("Error: vision model returned an empty response.");
|
|
1817
1913
|
}
|
|
1818
1914
|
|
|
1819
1915
|
// Build result fence(s)
|
|
@@ -1835,8 +1931,8 @@ async function handleAnalyzeImage(
|
|
|
1835
1931
|
);
|
|
1836
1932
|
}
|
|
1837
1933
|
|
|
1838
|
-
// Cache the result
|
|
1839
|
-
_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 });
|
|
1840
1936
|
|
|
1841
1937
|
// Log telemetry
|
|
1842
1938
|
pi.appendEntry(CUSTOM_TYPE_TOOL_CALL, {
|
|
@@ -1852,9 +1948,28 @@ async function handleAnalyzeImage(
|
|
|
1852
1948
|
groundingFormat,
|
|
1853
1949
|
});
|
|
1854
1950
|
|
|
1855
|
-
return
|
|
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
|
+
};
|
|
1856
1971
|
} catch (err) {
|
|
1857
|
-
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)}`);
|
|
1858
1973
|
}
|
|
1859
1974
|
}
|
|
1860
1975
|
|
|
@@ -1929,6 +2044,196 @@ export default function (pi: ExtensionAPI) {
|
|
|
1929
2044
|
);
|
|
1930
2045
|
}
|
|
1931
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
|
+
|
|
1932
2237
|
/** Register or unregister the analyze_image tool based on config. */
|
|
1933
2238
|
function syncToolRegistration(config: VisionConfig) {
|
|
1934
2239
|
const shouldHaveTool = config.mode !== "off" && config.tool === "on";
|
|
@@ -1943,6 +2248,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
1943
2248
|
"The tool supports cropping - use region, normalized, or pixel coordinates to focus on a specific area.",
|
|
1944
2249
|
"Results include image dimensions, filename, and grounding format metadata in the response fence.",
|
|
1945
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,
|
|
1946
2266
|
parameters: AnalyzeImageParams,
|
|
1947
2267
|
execute: async (_toolCallId, params, _signal, _onUpdate, extCtx) => {
|
|
1948
2268
|
const entries = extCtx.sessionManager.getEntries();
|
|
@@ -1950,14 +2270,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1950
2270
|
|
|
1951
2271
|
// Runtime check - tool may have been disabled mid-session
|
|
1952
2272
|
if (config.tool !== "on" || config.mode === "off") {
|
|
1953
|
-
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 };
|
|
1954
2274
|
}
|
|
1955
2275
|
|
|
1956
2276
|
// Rate limit per turn
|
|
1957
2277
|
const state = getSessionState(extCtx);
|
|
1958
2278
|
state.toolCallCount++;
|
|
1959
2279
|
if (state.toolCallCount > MAX_TOOL_CALLS_PER_TURN) {
|
|
1960
|
-
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 };
|
|
1961
2281
|
}
|
|
1962
2282
|
|
|
1963
2283
|
// Sync cache size with current config
|
|
@@ -1965,8 +2285,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1965
2285
|
state.toolCache.resize(config.cacheSize);
|
|
1966
2286
|
}
|
|
1967
2287
|
|
|
1968
|
-
const
|
|
1969
|
-
return {
|
|
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
|
+
};
|
|
1970
2297
|
},
|
|
1971
2298
|
});
|
|
1972
2299
|
_toolRegistered = true;
|
|
@@ -1997,6 +2324,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
1997
2324
|
// Register tool if enabled
|
|
1998
2325
|
syncToolRegistration(config);
|
|
1999
2326
|
|
|
2327
|
+
// Register the multimodal-proxy/auto virtual model (pi ≥ 0.99)
|
|
2328
|
+
syncVirtualModelRegistration(config);
|
|
2329
|
+
|
|
2000
2330
|
// Stack the `#` image-recall autocomplete on the editor
|
|
2001
2331
|
registerRecallAutocomplete(ctx);
|
|
2002
2332
|
});
|
|
@@ -2019,6 +2349,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2019
2349
|
const rawConfig = resolveConfig(entries, process.env, _fileConfig);
|
|
2020
2350
|
const config = await withModelFallback(rawConfig, ctx);
|
|
2021
2351
|
notifyDefaultSubstitution(rawConfig, config, ctx);
|
|
2352
|
+
syncVirtualModelRegistration(config);
|
|
2022
2353
|
const pathAccess = pathAccessFromConfig(config);
|
|
2023
2354
|
|
|
2024
2355
|
// Collect images: structured attachments + file paths detected in prompt
|
|
@@ -2352,7 +2683,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2352
2683
|
|
|
2353
2684
|
// 1.16.0 — best-effort downscale of oversized joint uploads
|
|
2354
2685
|
const jointPayloads = await Promise.all(
|
|
2355
|
-
jointImages.map((img) => downscaleForUpload(img, config)),
|
|
2686
|
+
jointImages.map((img) => downscaleForUpload(img, config, modelImageResizeLimits(jointVisionModel))),
|
|
2356
2687
|
);
|
|
2357
2688
|
|
|
2358
2689
|
const contentParts: Array<{ type: "text"; text: string } | PiAiImage> = [
|
|
@@ -2531,8 +2862,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2531
2862
|
// Restate the recall affordance once per context build (not per image),
|
|
2532
2863
|
// so the agent is reminded it can re-query earlier images even on turns
|
|
2533
2864
|
// where no new image was attached. Trusted extension text — kept outside
|
|
2534
|
-
// the untrusted description fence.
|
|
2535
|
-
|
|
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;
|
|
2536
2869
|
|
|
2537
2870
|
let modified = false;
|
|
2538
2871
|
let messages = !strip ? event.messages : event.messages.map((msg) => {
|
|
@@ -2652,6 +2985,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
2652
2985
|
if (modified) return { messages };
|
|
2653
2986
|
});
|
|
2654
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
|
+
|
|
2655
3014
|
// ── /multimodal-proxy command ─────────────────────────────────────────
|
|
2656
3015
|
|
|
2657
3016
|
/**
|
|
@@ -2814,9 +3173,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
2814
3173
|
const eff = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
|
|
2815
3174
|
// Fire-and-forget: writePersisted stays synchronous for its ~30 call
|
|
2816
3175
|
// sites; the status refresh just needs the resolved model eventually.
|
|
2817
|
-
void withModelFallback(eff, ctx)
|
|
3176
|
+
void withModelFallback(eff, ctx)
|
|
3177
|
+
.then((resolved) =>
|
|
2818
3178
|
ctx.ui.setStatus("multimodal-proxy", steadyStatusText(resolved, ctx.modelRegistry)),
|
|
2819
|
-
)
|
|
3179
|
+
)
|
|
3180
|
+
.catch(() => {
|
|
3181
|
+
// Key probing must never surface as an unhandled rejection in daemons.
|
|
3182
|
+
});
|
|
2820
3183
|
return validated;
|
|
2821
3184
|
};
|
|
2822
3185
|
|
|
@@ -2855,6 +3218,77 @@ export default function (pi: ExtensionAPI) {
|
|
|
2855
3218
|
return;
|
|
2856
3219
|
}
|
|
2857
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
|
+
|
|
2858
3292
|
// ── Pick from vision-capable registry ───────────────
|
|
2859
3293
|
if (sub === "pick") {
|
|
2860
3294
|
await pickVisionModel(ctx, persisted, writePersisted, !!env.model);
|
|
@@ -3744,7 +4178,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3744
4178
|
|
|
3745
4179
|
// 1.16.0 — best-effort downscale of oversized uploads after cropping
|
|
3746
4180
|
for (const p of imagePayloads) {
|
|
3747
|
-
p.image = await downscaleForUpload(p.image, descConfig);
|
|
4181
|
+
p.image = await downscaleForUpload(p.image, descConfig, modelImageResizeLimits(descVisionModel));
|
|
3748
4182
|
}
|
|
3749
4183
|
|
|
3750
4184
|
// Get auth
|
|
@@ -3868,6 +4302,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3868
4302
|
env.allowHome && "allowHome", env.allowedFolders && "allowedFolders",
|
|
3869
4303
|
env.statusLine && "statusLine", env.pathDetection && "pathDetection",
|
|
3870
4304
|
env.retryMax && "retryMax", env.maxUpload && "maxUpload", env.fallbackModel && "fallbackModel",
|
|
4305
|
+
env.virtual && "virtual", env.virtualBase && "virtualBase",
|
|
3871
4306
|
].filter(Boolean).join(", ");
|
|
3872
4307
|
const summary =
|
|
3873
4308
|
`Vision proxy: ${modeLabel(effective.mode)}\n` +
|
|
@@ -3885,6 +4320,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3885
4320
|
`Allow home: ${effective.allowHome ? "ON" : "OFF"}\n` +
|
|
3886
4321
|
`Status line: ${effective.statusLine === "on" ? "ON" : "OFF"}\n` +
|
|
3887
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` +
|
|
3888
4324
|
`Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}\n` +
|
|
3889
4325
|
`Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}\n` +
|
|
3890
4326
|
(activeEnvOverrides ? `Env overrides: ${activeEnvOverrides}\n` : "");
|
|
@@ -3892,7 +4328,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3892
4328
|
if (!ctx.hasUI) {
|
|
3893
4329
|
ctx.ui.notify(
|
|
3894
4330
|
summary +
|
|
3895
|
-
`\nCommands: /multimodal-proxy fallback|always|off | pick | model provider/model-id | video-model provider/model-id | context on|off | consent yes|no|always | allowed-providers add|remove <provider>|clear | tool on|off | max-images-per-call <n> | max-batch <n> | cache-size <n> | folders list|add|remove|reset | allow-home on|off | status on|off | path-detection on|off | fallback-model provider/model-id|clear | retry <0-5> | max-upload <dim|nmb|off> | doctor | test`,
|
|
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`,
|
|
3896
4332
|
"info",
|
|
3897
4333
|
);
|
|
3898
4334
|
return;
|
|
@@ -3913,6 +4349,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3913
4349
|
`Allow home: ${effective.allowHome ? "ON" : "OFF"}`,
|
|
3914
4350
|
`Status line: ${effective.statusLine === "on" ? "ON" : "OFF"}`,
|
|
3915
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})` : ""}`,
|
|
3916
4353
|
`Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}`,
|
|
3917
4354
|
`Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}`,
|
|
3918
4355
|
"Run diagnostics (doctor)",
|
|
@@ -4151,6 +4588,54 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
4151
4588
|
return;
|
|
4152
4589
|
}
|
|
4153
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
|
+
|
|
4154
4639
|
if (choice.startsWith("Consent")) {
|
|
4155
4640
|
const granted = !hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders);
|
|
4156
4641
|
pi.appendEntry<ConsentEntry>(CUSTOM_TYPE_CONSENT, { granted, provider: effective.provider });
|