pi-multimodal-proxy 1.18.1 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +31 -0
- package/extensions/__tests__/internal.test.ts +199 -3
- package/extensions/__tests__/recall-autocomplete.test.ts +10 -0
- package/extensions/internal.ts +215 -19
- package/extensions/vision-proxy.ts +635 -52
- 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
|
|
@@ -276,6 +282,8 @@ import {
|
|
|
276
282
|
canonicalYouTubeUrl,
|
|
277
283
|
youTubeVideoId,
|
|
278
284
|
applyDefaultModelFallback,
|
|
285
|
+
closeMatches,
|
|
286
|
+
pickDynamicVisionCandidate,
|
|
279
287
|
DEFAULT_MODEL_FALLBACKS,
|
|
280
288
|
applyRecallCompletion,
|
|
281
289
|
buildRecallItems,
|
|
@@ -316,6 +324,7 @@ import {
|
|
|
316
324
|
modelLabel,
|
|
317
325
|
normalizeAllowedProviders,
|
|
318
326
|
parseModelString,
|
|
327
|
+
modelImageResizeLimits,
|
|
319
328
|
parseProviderList,
|
|
320
329
|
persistedBase,
|
|
321
330
|
pluralImages,
|
|
@@ -404,23 +413,57 @@ const AnalyzeImageParams = Type.Object({
|
|
|
404
413
|
reason: Type.Optional(Type.String({ description: "Optional; logged for analytics only" })),
|
|
405
414
|
});
|
|
406
415
|
|
|
416
|
+
/**
|
|
417
|
+
* outputSchema of analyze_image (pi ≥ 0.99): codemode scripts and other
|
|
418
|
+
* programmatic callers receive this structuredContent instead of the text
|
|
419
|
+
* fence; the model still sees `content`. Declared but harmlessly ignored by
|
|
420
|
+
* older hosts.
|
|
421
|
+
*/
|
|
422
|
+
const AnalyzeImageOutput = Type.Object({
|
|
423
|
+
ok: Type.Boolean({ description: "Whether the analysis succeeded." }),
|
|
424
|
+
text: Type.String({ description: "The analysis text (same content the model sees, without the XML fence)." }),
|
|
425
|
+
error: Type.Optional(Type.String({ description: "Failure message, present when ok is false." })),
|
|
426
|
+
cached: Type.Optional(Type.Boolean({ description: "True when served from the per-session tool cache." })),
|
|
427
|
+
provider: Type.Optional(Type.String({ description: "Provider that answered (fallback-aware)." })),
|
|
428
|
+
model: Type.Optional(Type.String({ description: "Model id that answered (fallback-aware)." })),
|
|
429
|
+
latencyMs: Type.Optional(Type.Number()),
|
|
430
|
+
groundingFormat: Type.Optional(Type.String()),
|
|
431
|
+
images: Type.Optional(
|
|
432
|
+
Type.Array(
|
|
433
|
+
Type.Object({
|
|
434
|
+
id: Type.String({ description: "Stable image id usable for recall (image=\"...\")." }),
|
|
435
|
+
filename: Type.Optional(Type.String()),
|
|
436
|
+
width: Type.Optional(Type.Number()),
|
|
437
|
+
height: Type.Optional(Type.Number()),
|
|
438
|
+
crop: Type.Optional(
|
|
439
|
+
Type.Object(
|
|
440
|
+
{ x: Type.Number(), y: Type.Number(), width: Type.Number(), height: Type.Number() },
|
|
441
|
+
{
|
|
442
|
+
// PR #34 review: options argument, not a property — a raw-string
|
|
443
|
+
// "description" inside the properties map makes the schema invalid.
|
|
444
|
+
description:
|
|
445
|
+
"Absolute-pixel crop applied before the call; add (x, y) to returned coordinates to map back to the full image.",
|
|
446
|
+
},
|
|
447
|
+
),
|
|
448
|
+
),
|
|
449
|
+
}),
|
|
450
|
+
),
|
|
451
|
+
),
|
|
452
|
+
});
|
|
453
|
+
|
|
407
454
|
const TOOL_DESCRIPTION = [
|
|
408
|
-
"
|
|
409
|
-
"(b) you need to compare or cross-reference multiple images, or (c) you need to focus on a specific region.",
|
|
410
|
-
"",
|
|
411
|
-
"**Cropping.** Three forms, in order of preference:",
|
|
412
|
-
"",
|
|
413
|
-
"- **`region`** - coarse cut by name. Use when you don't have exact dimensions: `{ image_index: 0, region: \"bottom-right\" }`.",
|
|
414
|
-
"- **`normalized`** - fractional coordinates 0.0-1.0. Default choice for precise crops without knowing image dimensions: `{ image_index: 0, normalized: { x: 0.5, y: 0.5, width: 0.4, height: 0.4 } }`.",
|
|
415
|
-
"- **`pixels`** - absolute pixels. Use only when you have authoritative coordinates from a prior `<vision_proxy_description>` or `<vision_proxy_analysis>` (which carry `width` and `height` attributes) or from a previous grounded response. Example: `{ image_index: 0, pixels: { x: 1840, y: 120, width: 840, height: 360 } }`.",
|
|
455
|
+
"Targeted image analysis with cropping and model-native grounding support.",
|
|
416
456
|
"",
|
|
417
|
-
"
|
|
457
|
+
"Use it when (a) a cached image description lacks a detail you need, (b) you must compare or cross-reference images, or (c) you must focus on a region. The result is authoritative for the question asked; the cached generic description stays the default for everything else.",
|
|
418
458
|
"",
|
|
419
|
-
"
|
|
459
|
+
"Image references are file paths OR the `image=\"<id>\"` id carried by every `<vision_proxy_description>`/`<vision_proxy_analysis>`/`<vision_proxy_joint_description>` fence — pass an id to re-examine or crop an image the user shared earlier, even if it is no longer attached.",
|
|
420
460
|
"",
|
|
421
|
-
"
|
|
461
|
+
"Cropping, in order of preference (one crop per image, keyed by `image_index`):",
|
|
462
|
+
"- `region` — named cut (`top-left`, `bottom-right`, `center`, `right-half`, …) when you don't know the dimensions: `{ image_index: 0, region: \"bottom-right\" }`.",
|
|
463
|
+
"- `normalized` — `{ x, y, width, height }` as 0.0-1.0 fractions; the default for precise crops without dimensions.",
|
|
464
|
+
"- `pixels` — `{ x, y, width, height }` absolute; only with authoritative coordinates from a prior fence's `width`/`height` attributes or a grounded response.",
|
|
422
465
|
"",
|
|
423
|
-
"
|
|
466
|
+
"Fences carry `width`, `height`, and `filename`, and a crop adds `crop_origin=\"x,y\"` — add the origin to returned coordinates to map them back to the full image.",
|
|
424
467
|
].join("\n");
|
|
425
468
|
|
|
426
469
|
/** Maximum analyze_image tool calls per agent turn. Prevents cost runaway. */
|
|
@@ -436,9 +479,19 @@ const DEFAULT_TOOL_CACHE_SIZE = 50;
|
|
|
436
479
|
// unreliable. Key the state off the session's SessionManager instance, which
|
|
437
480
|
// is unique per session; the WeakMap entry is reclaimed when the session ends.
|
|
438
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Cached analyze_image result: the model-facing fence plus the raw analysis
|
|
484
|
+
* text, so cache hits can return the same schema-shaped structuredContent as
|
|
485
|
+
* fresh calls (PR #34 review).
|
|
486
|
+
*/
|
|
487
|
+
interface AnalyzeImageCacheValue {
|
|
488
|
+
fence: string;
|
|
489
|
+
raw: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
439
492
|
interface SessionState {
|
|
440
493
|
/** Tool result cache, shared across calls within one session. */
|
|
441
|
-
toolCache: LRUCache<string,
|
|
494
|
+
toolCache: LRUCache<string, AnalyzeImageCacheValue>;
|
|
442
495
|
/** Current turn's tool call count (reset on each before_agent_start). */
|
|
443
496
|
toolCallCount: number;
|
|
444
497
|
/** Image hash → dimensions/filename, populated on first ingestion this session. */
|
|
@@ -453,8 +506,16 @@ interface SessionState {
|
|
|
453
506
|
compaction?: { reason?: "manual" | "threshold" | "overflow"; willRetry?: boolean };
|
|
454
507
|
/** Cached "provider/model has an API key" probes (cleared per session). */
|
|
455
508
|
keyProbe?: Map<string, boolean>;
|
|
509
|
+
/** Cached dynamic (registry-keyed) vision candidate; null = none or runtime without the API (Pi ≥ 1.0). */
|
|
510
|
+
dynamicVisionProbe?: { provider: string; modelId: string } | null;
|
|
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(),
|
|
@@ -701,7 +762,56 @@ async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): P
|
|
|
701
762
|
keys.set(`${pair.provider}/${pair.modelId}`, await probe(pair.provider, pair.modelId));
|
|
702
763
|
}
|
|
703
764
|
const hasKey = (p: string, m: string) => keys.get(`${p}/${m}`) === true;
|
|
704
|
-
|
|
765
|
+
const resolved = applyDefaultModelFallback(config, hasModel, false, hasKey);
|
|
766
|
+
// Pi ≥ 1.0 last resort: the curated chain can be entirely unkeyed while
|
|
767
|
+
// the user has another keyed vision-capable model (openrouter, google, …).
|
|
768
|
+
// getAvailableOfType lists exactly those — land the implicit default on
|
|
769
|
+
// the first one instead of dead-ending on the "No API key" notice.
|
|
770
|
+
// (Explicit choices returned early above; a keyed chain resolution already
|
|
771
|
+
// changed provider/modelId, which the equality check skips.)
|
|
772
|
+
const anyKeyed = [...keys.values()].some(Boolean);
|
|
773
|
+
if (!anyKeyed && resolved.provider === config.provider && resolved.modelId === config.modelId) {
|
|
774
|
+
const dyn = await dynamicVisionCandidate(ctx);
|
|
775
|
+
if (dyn) return { ...resolved, provider: dyn.provider, modelId: dyn.modelId };
|
|
776
|
+
}
|
|
777
|
+
return resolved;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Pi ≥ 1.0 dynamic vision-candidate probe (ModelRegistry.getAvailableOfType):
|
|
782
|
+
* the first keyed chat model with image input, cheap tier preferred
|
|
783
|
+
* (pickDynamicVisionCandidate). Feature-detected — older runtimes and probe
|
|
784
|
+
* failures cache as null; the probe runs once per session.
|
|
785
|
+
*/
|
|
786
|
+
async function dynamicVisionCandidate(ctx: ExtensionContext): Promise<{ provider: string; modelId: string } | null> {
|
|
787
|
+
const state = getSessionState(ctx);
|
|
788
|
+
if (state.dynamicVisionProbe !== undefined) return state.dynamicVisionProbe;
|
|
789
|
+
let out: { provider: string; modelId: string } | null = null;
|
|
790
|
+
const reg = ctx.modelRegistry as ModelRegistry & {
|
|
791
|
+
getAvailableOfType?: (type: string) => Promise<ReadonlyArray<unknown>>;
|
|
792
|
+
};
|
|
793
|
+
if (typeof reg.getAvailableOfType === "function") {
|
|
794
|
+
try {
|
|
795
|
+
const available = await reg.getAvailableOfType("chat");
|
|
796
|
+
const candidates = (
|
|
797
|
+
available as ReadonlyArray<{ provider?: unknown; id?: unknown; input?: unknown }>
|
|
798
|
+
)
|
|
799
|
+
.filter(
|
|
800
|
+
(m): m is { provider: string; id: string; input: readonly string[] } =>
|
|
801
|
+
typeof m.provider === "string" &&
|
|
802
|
+
typeof m.id === "string" &&
|
|
803
|
+
Array.isArray(m.input) &&
|
|
804
|
+
m.input.includes("image"),
|
|
805
|
+
)
|
|
806
|
+
.map((m) => ({ provider: m.provider, modelId: m.id, input: m.input }));
|
|
807
|
+
const pick = pickDynamicVisionCandidate(candidates);
|
|
808
|
+
if (pick) out = { provider: pick.provider, modelId: pick.modelId };
|
|
809
|
+
} catch {
|
|
810
|
+
out = null;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
state.dynamicVisionProbe = out;
|
|
814
|
+
return out;
|
|
705
815
|
}
|
|
706
816
|
|
|
707
817
|
/**
|
|
@@ -791,6 +901,7 @@ function friendlyModelLabel(
|
|
|
791
901
|
* Returns undefined (which clears the status entry) when the user hid the
|
|
792
902
|
* status line via `/multimodal-proxy status off`.
|
|
793
903
|
*/
|
|
904
|
+
/** Steady-state status-line text summarizing mode, models, tool, and virtual-model state. */
|
|
794
905
|
function steadyStatusText(
|
|
795
906
|
config: VisionConfig,
|
|
796
907
|
registry: ExtensionContext["modelRegistry"],
|
|
@@ -799,7 +910,8 @@ function steadyStatusText(
|
|
|
799
910
|
return (
|
|
800
911
|
`multimodal-proxy: ${config.mode} → ${friendlyModelLabel(config, registry)} ` +
|
|
801
912
|
`| video: ${config.videoProvider}/${config.videoModelId}` +
|
|
802
|
-
`${config.tool === "on" && config.mode !== "off" ? " [+tool]" : ""}`
|
|
913
|
+
`${config.tool === "on" && config.mode !== "off" ? " [+tool]" : ""}` +
|
|
914
|
+
`${config.virtualModel === "on" ? " [+virtual]" : ""}`
|
|
803
915
|
);
|
|
804
916
|
}
|
|
805
917
|
|
|
@@ -967,8 +1079,9 @@ async function analyzeImages(
|
|
|
967
1079
|
|
|
968
1080
|
// 1.16.0 — best-effort downscale of oversized uploads (cost/limit protection).
|
|
969
1081
|
// Hash/cache/recall still key on the ORIGINAL bytes — only the upload
|
|
970
|
-
// payload is shrunk.
|
|
971
|
-
|
|
1082
|
+
// payload is shrunk. 1.19.0 — tightened by the vision model's declared
|
|
1083
|
+
// inputLimits.images.resize (pi ≥ 0.87) when present.
|
|
1084
|
+
const uploadPayload = await downscaleForUpload(piAiImage, config, modelImageResizeLimits(visionModel));
|
|
972
1085
|
|
|
973
1086
|
try {
|
|
974
1087
|
const { response } = await completeVision(
|
|
@@ -1563,6 +1676,78 @@ async function analyzeVideoViaXaiResponsesFile(
|
|
|
1563
1676
|
}
|
|
1564
1677
|
}
|
|
1565
1678
|
|
|
1679
|
+
/**
|
|
1680
|
+
* Structured outcome of one analyze_image call (1.19.0): the model-facing
|
|
1681
|
+
* text plus optional machine-readable fields for programmatic callers
|
|
1682
|
+
* (pi ≥ 0.99 codemode scripts receive `structuredContent` instead of text).
|
|
1683
|
+
*/
|
|
1684
|
+
interface AnalyzeImageOutcome {
|
|
1685
|
+
/** Model-facing text: the analysis fence, or the error message. */
|
|
1686
|
+
text: string;
|
|
1687
|
+
/** True when the call failed — surfaced as `isError` on hosts ≥ 0.99. */
|
|
1688
|
+
isError?: boolean;
|
|
1689
|
+
/** Machine-readable result matching AnalyzeImageOutput. */
|
|
1690
|
+
structured?: Record<string, unknown>;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
/** Wrap a failure message as an AnalyzeImageOutcome. */
|
|
1694
|
+
function analysisError(message: string): AnalyzeImageOutcome {
|
|
1695
|
+
return { text: message, isError: true, structured: { ok: false, error: message } };
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Close-match vision-model suggestions for recovery-style tool errors, in
|
|
1700
|
+
* the spirit of Pi 1.0's codemode errors ("tools.Bash suggests
|
|
1701
|
+
* tools.bash"). Labels every registry model with image input; no key
|
|
1702
|
+
* probing — error paths must stay cheap.
|
|
1703
|
+
*/
|
|
1704
|
+
function registryVisionSuggestions(ctx: ExtensionContext, query: string, limit = 3): string[] {
|
|
1705
|
+
try {
|
|
1706
|
+
const models = ctx.modelRegistry.getAll() as unknown as ReadonlyArray<{
|
|
1707
|
+
provider?: unknown;
|
|
1708
|
+
id?: unknown;
|
|
1709
|
+
input?: unknown;
|
|
1710
|
+
}>;
|
|
1711
|
+
const labels = models
|
|
1712
|
+
.filter(
|
|
1713
|
+
(m): m is { provider: string; id: string; input: readonly string[] } =>
|
|
1714
|
+
typeof m.provider === "string" &&
|
|
1715
|
+
typeof m.id === "string" &&
|
|
1716
|
+
Array.isArray(m.input) &&
|
|
1717
|
+
m.input.includes("image"),
|
|
1718
|
+
)
|
|
1719
|
+
.map((m) => `${m.provider}/${m.id}`);
|
|
1720
|
+
return closeMatches(query, labels, limit);
|
|
1721
|
+
} catch {
|
|
1722
|
+
return [];
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* Close-match recall-id suggestions: matches the mistyped id against every
|
|
1728
|
+
* retained image's hash (and filename, when known) and returns full ids the
|
|
1729
|
+
* agent can pass verbatim.
|
|
1730
|
+
*/
|
|
1731
|
+
function recallSuggestions(imageData: ImageDataStore, imageMeta: ImageMetaStore, query: string, limit = 3): string[] {
|
|
1732
|
+
const candidates: string[] = [];
|
|
1733
|
+
try {
|
|
1734
|
+
for (const hash of imageData.map.keys()) {
|
|
1735
|
+
const meta = imageMeta.get(hash);
|
|
1736
|
+
candidates.push(meta?.filename ? `${hash} (${meta.filename})` : hash);
|
|
1737
|
+
}
|
|
1738
|
+
} catch {
|
|
1739
|
+
return [];
|
|
1740
|
+
}
|
|
1741
|
+
return closeMatches(query, candidates, limit);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* Answer a targeted analyze_image question about one or more images: resolve
|
|
1746
|
+
* references (session recall ids or file paths), validate and apply crops,
|
|
1747
|
+
* consult the per-session cache, and call the vision model (with retry and
|
|
1748
|
+
* fallback). Returns the fenced text for the model plus a machine-readable
|
|
1749
|
+
* structured payload for programmatic callers.
|
|
1750
|
+
*/
|
|
1566
1751
|
async function handleAnalyzeImage(
|
|
1567
1752
|
params: {
|
|
1568
1753
|
images: string[];
|
|
@@ -1574,20 +1759,20 @@ async function handleAnalyzeImage(
|
|
|
1574
1759
|
ctx: ExtensionContext,
|
|
1575
1760
|
pi: ExtensionAPI,
|
|
1576
1761
|
config: VisionConfig,
|
|
1577
|
-
): Promise<
|
|
1762
|
+
): Promise<AnalyzeImageOutcome> {
|
|
1578
1763
|
const { images: imageRefs, question, model: modelOverride, crop: crops, reason } = params;
|
|
1579
1764
|
|
|
1580
1765
|
if (!question || question.trim().length === 0) {
|
|
1581
|
-
return "Error: question is required and must be non-empty.";
|
|
1766
|
+
return analysisError("Error: question is required and must be non-empty.");
|
|
1582
1767
|
}
|
|
1583
1768
|
if (question.length > 4000) {
|
|
1584
|
-
return "Error: question must be at most 4000 characters.";
|
|
1769
|
+
return analysisError("Error: question must be at most 4000 characters.");
|
|
1585
1770
|
}
|
|
1586
1771
|
if (imageRefs.length === 0) {
|
|
1587
|
-
return "Error: at least one image is required.";
|
|
1772
|
+
return analysisError("Error: at least one image is required.");
|
|
1588
1773
|
}
|
|
1589
1774
|
if (imageRefs.length > config.maxImagesPerCall) {
|
|
1590
|
-
return `Error: too many images (${imageRefs.length}). Maximum is ${config.maxImagesPerCall}
|
|
1775
|
+
return analysisError(`Error: too many images (${imageRefs.length}). Maximum is ${config.maxImagesPerCall}.`);
|
|
1591
1776
|
}
|
|
1592
1777
|
|
|
1593
1778
|
// Validate crop indices: no duplicates, all in range
|
|
@@ -1595,11 +1780,11 @@ async function handleAnalyzeImage(
|
|
|
1595
1780
|
const seen = new Set<number>();
|
|
1596
1781
|
for (const c of crops) {
|
|
1597
1782
|
if (seen.has(c.image_index)) {
|
|
1598
|
-
return `Error: duplicate crop for image index ${c.image_index}. At most one crop per image
|
|
1783
|
+
return analysisError(`Error: duplicate crop for image index ${c.image_index}. At most one crop per image.`);
|
|
1599
1784
|
}
|
|
1600
1785
|
seen.add(c.image_index);
|
|
1601
1786
|
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})
|
|
1787
|
+
return analysisError(`Error: crop image_index ${c.image_index} is out of range (0-${imageRefs.length - 1}).`);
|
|
1603
1788
|
}
|
|
1604
1789
|
}
|
|
1605
1790
|
}
|
|
@@ -1610,7 +1795,7 @@ async function handleAnalyzeImage(
|
|
|
1610
1795
|
if (modelOverride) {
|
|
1611
1796
|
const parsed = parseModelString(modelOverride);
|
|
1612
1797
|
if (!parsed) {
|
|
1613
|
-
return `Error: invalid model string "${modelOverride}". Expected format: provider/model-id
|
|
1798
|
+
return analysisError(`Error: invalid model string "${modelOverride}". Expected format: provider/model-id`);
|
|
1614
1799
|
}
|
|
1615
1800
|
visionProvider = parsed.provider;
|
|
1616
1801
|
visionModelId = parsed.modelId;
|
|
@@ -1619,16 +1804,21 @@ async function handleAnalyzeImage(
|
|
|
1619
1804
|
// Verify model exists and supports images
|
|
1620
1805
|
const visionModel = ctx.modelRegistry.find(visionProvider, visionModelId);
|
|
1621
1806
|
if (!visionModel) {
|
|
1622
|
-
|
|
1807
|
+
const suggestions = registryVisionSuggestions(ctx, `${visionProvider}/${visionModelId}`);
|
|
1808
|
+
return analysisError(
|
|
1809
|
+
`Error: model "${visionProvider}/${visionModelId}" not found in registry.` +
|
|
1810
|
+
(suggestions.length > 0 ? ` Close matches: ${suggestions.join(", ")}.` : "") +
|
|
1811
|
+
" Use /multimodal-proxy pick to choose a vision model.",
|
|
1812
|
+
);
|
|
1623
1813
|
}
|
|
1624
1814
|
if (!visionModel.input.includes("image")) {
|
|
1625
|
-
return `Error: model "${visionModel.name ?? visionModelId}" does not support image input
|
|
1815
|
+
return analysisError(`Error: model "${visionModel.name ?? visionModelId}" does not support image input.`);
|
|
1626
1816
|
}
|
|
1627
1817
|
|
|
1628
1818
|
// Check consent for the resolved vision provider
|
|
1629
1819
|
const entries = ctx.sessionManager.getEntries();
|
|
1630
1820
|
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})`
|
|
1821
|
+
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
1822
|
}
|
|
1633
1823
|
|
|
1634
1824
|
// Resolve image references to PiAiImage objects.
|
|
@@ -1641,7 +1831,12 @@ async function handleAnalyzeImage(
|
|
|
1641
1831
|
if (recallHash) {
|
|
1642
1832
|
const stored = getImageData(imageData, recallHash);
|
|
1643
1833
|
if (!stored) {
|
|
1644
|
-
|
|
1834
|
+
const suggestions = recallSuggestions(imageData, imageMeta, recallHash);
|
|
1835
|
+
return analysisError(
|
|
1836
|
+
`Error: image "${recallHash}" is not available for recall — it may have expired from the session cache or was never analyzed.` +
|
|
1837
|
+
(suggestions.length > 0 ? ` Close ids still retained: ${suggestions.join(", ")}.` : "") +
|
|
1838
|
+
" Ask the user to re-attach it, or pass a file path.",
|
|
1839
|
+
);
|
|
1645
1840
|
}
|
|
1646
1841
|
const image: PiAiImage = { type: "image", data: stored.data, mimeType: stored.mimeType };
|
|
1647
1842
|
// Backfill dimensions if metadata was evicted, so crops still work on recall.
|
|
@@ -1652,11 +1847,11 @@ async function handleAnalyzeImage(
|
|
|
1652
1847
|
|
|
1653
1848
|
// File path
|
|
1654
1849
|
if (ref.includes("..")) {
|
|
1655
|
-
return `Error: path contains disallowed ".." segments
|
|
1850
|
+
return analysisError(`Error: path contains disallowed ".." segments.`);
|
|
1656
1851
|
}
|
|
1657
1852
|
const r = await readImageFileWithReason(ref, pathAccessFromConfig(config), sniffImageFile);
|
|
1658
1853
|
if (!r.image) {
|
|
1659
|
-
return `Error: could not read image: ${describeReadReason(r.reason ?? "not-an-image", r.bytes)}
|
|
1854
|
+
return analysisError(`Error: could not read image: ${describeReadReason(r.reason ?? "not-an-image", r.bytes)}`);
|
|
1660
1855
|
}
|
|
1661
1856
|
const hash = hashImageData(r.image.data);
|
|
1662
1857
|
storeImageMeta(imageMeta, hash, r.image.data, r.filename);
|
|
@@ -1676,13 +1871,13 @@ async function handleAnalyzeImage(
|
|
|
1676
1871
|
if (cropEntry) {
|
|
1677
1872
|
const meta = entry.meta;
|
|
1678
1873
|
if (!meta) {
|
|
1679
|
-
return `Error: cannot crop image ${i} - image dimensions unknown
|
|
1874
|
+
return analysisError(`Error: cannot crop image ${i} - image dimensions unknown.`);
|
|
1680
1875
|
}
|
|
1681
1876
|
try {
|
|
1682
1877
|
const resolved = resolveCropEntry(cropEntry, meta.width, meta.height);
|
|
1683
1878
|
imagePayloads.push({ ...entry, crop: resolved });
|
|
1684
1879
|
} catch (err) {
|
|
1685
|
-
return `Error: crop for image ${i} failed: ${err instanceof Error ? err.message : String(err)}
|
|
1880
|
+
return analysisError(`Error: crop for image ${i} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1686
1881
|
}
|
|
1687
1882
|
} else {
|
|
1688
1883
|
imagePayloads.push(entry);
|
|
@@ -1710,7 +1905,7 @@ async function handleAnalyzeImage(
|
|
|
1710
1905
|
|
|
1711
1906
|
// 1.16.0 — best-effort downscale of oversized uploads after cropping
|
|
1712
1907
|
for (const p of imagePayloads) {
|
|
1713
|
-
p.image = await downscaleForUpload(p.image, config);
|
|
1908
|
+
p.image = await downscaleForUpload(p.image, config, modelImageResizeLimits(visionModel));
|
|
1714
1909
|
}
|
|
1715
1910
|
|
|
1716
1911
|
// Build cache key AFTER crop resolution (so failed crops don't create stale crop keys)
|
|
@@ -1739,13 +1934,16 @@ async function handleAnalyzeImage(
|
|
|
1739
1934
|
cacheHit: true,
|
|
1740
1935
|
groundingFormat,
|
|
1741
1936
|
});
|
|
1742
|
-
return
|
|
1937
|
+
return {
|
|
1938
|
+
text: cached.fence,
|
|
1939
|
+
structured: { ok: true, text: cached.raw, cached: true, groundingFormat },
|
|
1940
|
+
};
|
|
1743
1941
|
}
|
|
1744
1942
|
|
|
1745
1943
|
// Call vision model
|
|
1746
1944
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(visionModel);
|
|
1747
1945
|
if (!auth.ok || !auth.apiKey) {
|
|
1748
|
-
return `Error: no API key for ${visionModel.name ?? modelLabel({ provider: visionProvider, modelId: visionModelId })}. Run: pi --login ${visionProvider}
|
|
1946
|
+
return analysisError(`Error: no API key for ${visionModel.name ?? modelLabel({ provider: visionProvider, modelId: visionModelId })}. Run: pi --login ${visionProvider}`);
|
|
1749
1947
|
}
|
|
1750
1948
|
|
|
1751
1949
|
ctx.ui.notify(
|
|
@@ -1806,7 +2004,7 @@ async function handleAnalyzeImage(
|
|
|
1806
2004
|
const latencyMs = Date.now() - startTime;
|
|
1807
2005
|
|
|
1808
2006
|
if (response.stopReason === "aborted") {
|
|
1809
|
-
return "Error: analysis was cancelled.";
|
|
2007
|
+
return analysisError("Error: analysis was cancelled.");
|
|
1810
2008
|
}
|
|
1811
2009
|
|
|
1812
2010
|
const text = response.content
|
|
@@ -1816,7 +2014,7 @@ async function handleAnalyzeImage(
|
|
|
1816
2014
|
.trim();
|
|
1817
2015
|
|
|
1818
2016
|
if (!text) {
|
|
1819
|
-
return "Error: vision model returned an empty response.";
|
|
2017
|
+
return analysisError("Error: vision model returned an empty response.");
|
|
1820
2018
|
}
|
|
1821
2019
|
|
|
1822
2020
|
// Build result fence(s)
|
|
@@ -1838,8 +2036,8 @@ async function handleAnalyzeImage(
|
|
|
1838
2036
|
);
|
|
1839
2037
|
}
|
|
1840
2038
|
|
|
1841
|
-
// Cache the result
|
|
1842
|
-
_toolCache.set(cacheKey, result);
|
|
2039
|
+
// Cache the result (fence for the model + raw text for structured output)
|
|
2040
|
+
_toolCache.set(cacheKey, { fence: result, raw: text });
|
|
1843
2041
|
|
|
1844
2042
|
// Log telemetry
|
|
1845
2043
|
pi.appendEntry(CUSTOM_TYPE_TOOL_CALL, {
|
|
@@ -1855,9 +2053,28 @@ async function handleAnalyzeImage(
|
|
|
1855
2053
|
groundingFormat,
|
|
1856
2054
|
});
|
|
1857
2055
|
|
|
1858
|
-
return
|
|
2056
|
+
return {
|
|
2057
|
+
text: result,
|
|
2058
|
+
structured: {
|
|
2059
|
+
ok: true,
|
|
2060
|
+
text,
|
|
2061
|
+
provider: usedProvider,
|
|
2062
|
+
model: usedModelId,
|
|
2063
|
+
latencyMs,
|
|
2064
|
+
groundingFormat,
|
|
2065
|
+
images: imagePayloads.map((p) => ({
|
|
2066
|
+
id: p.hash,
|
|
2067
|
+
filename: p.meta?.filename,
|
|
2068
|
+
width: p.crop ? p.crop.width : p.meta?.width,
|
|
2069
|
+
height: p.crop ? p.crop.height : p.meta?.height,
|
|
2070
|
+
crop: p.crop
|
|
2071
|
+
? { x: p.crop.x, y: p.crop.y, width: p.crop.width, height: p.crop.height }
|
|
2072
|
+
: undefined,
|
|
2073
|
+
})),
|
|
2074
|
+
},
|
|
2075
|
+
};
|
|
1859
2076
|
} catch (err) {
|
|
1860
|
-
return `Error: vision model call failed: ${err instanceof Error ? err.message : String(err)}
|
|
2077
|
+
return analysisError(`Error: vision model call failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1861
2078
|
}
|
|
1862
2079
|
}
|
|
1863
2080
|
|
|
@@ -1932,6 +2149,196 @@ export default function (pi: ExtensionAPI) {
|
|
|
1932
2149
|
);
|
|
1933
2150
|
}
|
|
1934
2151
|
|
|
2152
|
+
// ── Virtual model (pi ≥ 0.99) ─────────────────────────────────────────────
|
|
2153
|
+
|
|
2154
|
+
const VIRTUAL_PROVIDER = "multimodal-proxy";
|
|
2155
|
+
const VIRTUAL_MODEL_ID = "auto";
|
|
2156
|
+
let _virtualRegistered = false;
|
|
2157
|
+
|
|
2158
|
+
/** Minimal structural type of the virtual-model route request (pi ≥ 0.99). */
|
|
2159
|
+
interface VirtualRouteRequestLike {
|
|
2160
|
+
reason: "user" | "continuation" | "retry" | "direct";
|
|
2161
|
+
thinkingLevel: string;
|
|
2162
|
+
messages: readonly { role?: string; content?: unknown }[];
|
|
2163
|
+
previous?: { model: unknown; thinkingLevel?: string };
|
|
2164
|
+
failed?: { model: unknown; thinkingLevel?: string };
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
/** Whether the host exposes pi.registerVirtualModel (pi ≥ 0.99). */
|
|
2168
|
+
function virtualModelSupported(): boolean {
|
|
2169
|
+
return typeof (pi as unknown as { registerVirtualModel?: unknown }).registerVirtualModel === "function";
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
/**
|
|
2173
|
+
* Sync vision-target resolution for route(): honors an explicit pick
|
|
2174
|
+
* as-is; otherwise walks the same registry-only fallback chain as
|
|
2175
|
+
* applyDefaultModelFallback (route must not await key probes). Returns
|
|
2176
|
+
* null when nothing resolves.
|
|
2177
|
+
*/
|
|
2178
|
+
function resolveVisionTarget(ctx: ExtensionContext): { provider: string; modelId: string } | null {
|
|
2179
|
+
const config = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
|
|
2180
|
+
const explicit = Boolean(envFlags().model) || config.modelExplicit === true;
|
|
2181
|
+
const chain = explicit
|
|
2182
|
+
? [{ provider: config.provider, modelId: config.modelId }]
|
|
2183
|
+
: [{ provider: config.provider, modelId: config.modelId }, ...DEFAULT_MODEL_FALLBACKS];
|
|
2184
|
+
for (const cand of chain) {
|
|
2185
|
+
const model = ctx.modelRegistry.find(cand.provider, cand.modelId);
|
|
2186
|
+
// Only vision-capable entries qualify: a text-only pin (via
|
|
2187
|
+
// /multimodal-proxy model or PI_VISION_PROXY_MODEL) must not route
|
|
2188
|
+
// media turns to a model that would receive image placeholders
|
|
2189
|
+
// instead of native pixels (PR #34 review).
|
|
2190
|
+
if (model && Array.isArray(model.input) && model.input.includes("image")) return cand;
|
|
2191
|
+
}
|
|
2192
|
+
return null;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/** True when the pending user input (tail after the last assistant message) carries an image block. */
|
|
2196
|
+
function requestHasMedia(messages: readonly { role?: string; content?: unknown }[]): boolean {
|
|
2197
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2198
|
+
const msg = messages[i];
|
|
2199
|
+
if (msg?.role === "assistant") return false;
|
|
2200
|
+
if (msg?.role === "user" && Array.isArray(msg.content)) {
|
|
2201
|
+
if ((msg.content as { type?: string }[]).some((c) => c && typeof c === "object" && c.type === "image")) {
|
|
2202
|
+
return true;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
return false;
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
/**
|
|
2210
|
+
* Register/unregister the `multimodal-proxy/auto` virtual model to match
|
|
2211
|
+
* config (1.19.0). Selecting it in /model routes media-bearing turns
|
|
2212
|
+
* natively to the configured vision model and text turns to the base model
|
|
2213
|
+
* (pinned via /multimodal-proxy virtual-base, else the physical model that
|
|
2214
|
+
* handled the session before, else the vision model). Route() resolves
|
|
2215
|
+
* config per request, so model changes need no re-registration.
|
|
2216
|
+
*/
|
|
2217
|
+
function syncVirtualModelRegistration(config: VisionConfig) {
|
|
2218
|
+
const api = pi as unknown as {
|
|
2219
|
+
registerVirtualModel?: (m: unknown) => void;
|
|
2220
|
+
unregisterVirtualModel?: (provider: string, id: string) => void;
|
|
2221
|
+
};
|
|
2222
|
+
if (typeof api.registerVirtualModel !== "function") return; // pi < 0.99
|
|
2223
|
+
const want = config.virtualModel === "on";
|
|
2224
|
+
if (want && !_virtualRegistered) {
|
|
2225
|
+
api.registerVirtualModel({
|
|
2226
|
+
provider: VIRTUAL_PROVIDER,
|
|
2227
|
+
id: VIRTUAL_MODEL_ID,
|
|
2228
|
+
name: "Multimodal Auto",
|
|
2229
|
+
thinkingLevels: ["off", "low", "medium", "high"],
|
|
2230
|
+
input: ["text", "image"],
|
|
2231
|
+
route: (request: VirtualRouteRequestLike, ctx: ExtensionContext) => {
|
|
2232
|
+
// Sticky routing keeps prompt caches and thinking signatures
|
|
2233
|
+
// valid: continuations stay on the model that handled the turn,
|
|
2234
|
+
// retries on the model that failed (provider-level failover stays
|
|
2235
|
+
// with the vision fallback chain).
|
|
2236
|
+
if (request.reason === "retry" && request.failed) {
|
|
2237
|
+
return {
|
|
2238
|
+
model: request.failed.model,
|
|
2239
|
+
thinkingLevel: request.failed.thinkingLevel ?? request.thinkingLevel,
|
|
2240
|
+
};
|
|
2241
|
+
}
|
|
2242
|
+
if (request.reason !== "user" && request.previous) {
|
|
2243
|
+
return {
|
|
2244
|
+
model: request.previous.model,
|
|
2245
|
+
thinkingLevel: request.previous.thinkingLevel ?? request.thinkingLevel,
|
|
2246
|
+
};
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
const findOrThrow = (provider: string, modelId: string, what: string): unknown => {
|
|
2250
|
+
const model = ctx.modelRegistry.find(provider, modelId);
|
|
2251
|
+
if (!model) {
|
|
2252
|
+
throw new Error(
|
|
2253
|
+
`[multimodal-proxy] virtual model cannot find ${what} "${provider}/${modelId}". Run /multimodal-proxy pick to choose a vision model.`,
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
return model;
|
|
2257
|
+
};
|
|
2258
|
+
|
|
2259
|
+
// PR #34 review (security): the virtual model auto-selects the
|
|
2260
|
+
// vision provider on the user's behalf, so native delivery must
|
|
2261
|
+
// respect the extension's data-egress consent state for it —
|
|
2262
|
+
// selecting automatic routing must not silently contradict a
|
|
2263
|
+
// revocation. Consent is evaluated whenever a target is
|
|
2264
|
+
// (re)selected — i.e. at the start of every user turn; sticky
|
|
2265
|
+
// continuations/retries keep the already-authorized target so
|
|
2266
|
+
// prompt caches stay valid.
|
|
2267
|
+
const requireConsent = (provider: string): void => {
|
|
2268
|
+
const entries = ctx.sessionManager.getEntries();
|
|
2269
|
+
if (!hasConsent(entries, provider, _fileConfig.allowedProviders, _fileConfig.deniedProviders)) {
|
|
2270
|
+
throw new Error(
|
|
2271
|
+
`[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}`,
|
|
2272
|
+
);
|
|
2273
|
+
}
|
|
2274
|
+
};
|
|
2275
|
+
|
|
2276
|
+
if (requestHasMedia(request.messages)) {
|
|
2277
|
+
// Media turn: route natively to the vision model — the actual
|
|
2278
|
+
// pixels reach a model that can see them, no text fence needed.
|
|
2279
|
+
const target = resolveVisionTarget(ctx);
|
|
2280
|
+
if (!target) {
|
|
2281
|
+
throw new Error(
|
|
2282
|
+
"[multimodal-proxy] no vision model configured for the virtual model — run /multimodal-proxy pick.",
|
|
2283
|
+
);
|
|
2284
|
+
}
|
|
2285
|
+
requireConsent(target.provider);
|
|
2286
|
+
return {
|
|
2287
|
+
model: findOrThrow(target.provider, target.modelId, "vision model"),
|
|
2288
|
+
thinkingLevel: request.thinkingLevel,
|
|
2289
|
+
};
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// Text turn: pinned base, else the physical model that handled
|
|
2293
|
+
// the session before, else the vision model.
|
|
2294
|
+
const cfg = resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig);
|
|
2295
|
+
if (cfg.virtualBaseProvider && cfg.virtualBaseModelId) {
|
|
2296
|
+
const base = ctx.modelRegistry.find(cfg.virtualBaseProvider, cfg.virtualBaseModelId);
|
|
2297
|
+
if (base) return { model: base, thinkingLevel: request.thinkingLevel };
|
|
2298
|
+
}
|
|
2299
|
+
if (request.previous) {
|
|
2300
|
+
return {
|
|
2301
|
+
model: request.previous.model,
|
|
2302
|
+
thinkingLevel: request.previous.thinkingLevel ?? request.thinkingLevel,
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
const target = resolveVisionTarget(ctx);
|
|
2306
|
+
if (!target) {
|
|
2307
|
+
throw new Error(
|
|
2308
|
+
"[multimodal-proxy] no vision model configured for the virtual model — run /multimodal-proxy pick.",
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
requireConsent(target.provider);
|
|
2312
|
+
return {
|
|
2313
|
+
model: findOrThrow(target.provider, target.modelId, "vision model"),
|
|
2314
|
+
thinkingLevel: request.thinkingLevel,
|
|
2315
|
+
};
|
|
2316
|
+
},
|
|
2317
|
+
});
|
|
2318
|
+
_virtualRegistered = true;
|
|
2319
|
+
} else if (!want && _virtualRegistered) {
|
|
2320
|
+
api.unregisterVirtualModel?.(VIRTUAL_PROVIDER, VIRTUAL_MODEL_ID);
|
|
2321
|
+
_virtualRegistered = false;
|
|
2322
|
+
}
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
// Register the virtual model at extension LOAD time (not just session_start)
|
|
2326
|
+
// so it is visible to /model, --model, scoped models, and settings, which
|
|
2327
|
+
// resolve before the first session event fires. The synchronous call uses
|
|
2328
|
+
// defaults + env only (no file I/O) so it lands before CLI model
|
|
2329
|
+
// resolution; the async file-config load then corrects it (unregisters if
|
|
2330
|
+
// the persisted config says 'off'). session_start re-syncs with the full
|
|
2331
|
+
// entry-aware config afterwards.
|
|
2332
|
+
syncVirtualModelRegistration(resolveConfig([], process.env, {}));
|
|
2333
|
+
void readPersistentFile()
|
|
2334
|
+
.then((fileConfig) => {
|
|
2335
|
+
_fileConfig = fileConfig;
|
|
2336
|
+
syncVirtualModelRegistration(resolveConfig([], process.env, _fileConfig));
|
|
2337
|
+
})
|
|
2338
|
+
.catch(() => {
|
|
2339
|
+
// unreadable config file — session_start surfaces the error path
|
|
2340
|
+
});
|
|
2341
|
+
|
|
1935
2342
|
/** Register or unregister the analyze_image tool based on config. */
|
|
1936
2343
|
function syncToolRegistration(config: VisionConfig) {
|
|
1937
2344
|
const shouldHaveTool = config.mode !== "off" && config.tool === "on";
|
|
@@ -1946,6 +2353,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
1946
2353
|
"The tool supports cropping - use region, normalized, or pixel coordinates to focus on a specific area.",
|
|
1947
2354
|
"Results include image dimensions, filename, and grounding format metadata in the response fence.",
|
|
1948
2355
|
],
|
|
2356
|
+
// pi ≥ 0.99 tool API: MCP-style hints (analysis is read-only but sends
|
|
2357
|
+
// image data to the configured vision provider), a namespace grouping
|
|
2358
|
+
// for codemode listings, and a structured output schema so codemode
|
|
2359
|
+
// scripts receive machine-readable results. Older hosts ignore these.
|
|
2360
|
+
annotations: {
|
|
2361
|
+
readOnlyHint: true,
|
|
2362
|
+
destructiveHint: false,
|
|
2363
|
+
idempotentHint: false,
|
|
2364
|
+
openWorldHint: true,
|
|
2365
|
+
},
|
|
2366
|
+
namespace: {
|
|
2367
|
+
name: "multimodal-proxy",
|
|
2368
|
+
description: "pi-multimodal-proxy media analysis (targeted image re-analysis with crop and grounding)",
|
|
2369
|
+
},
|
|
2370
|
+
outputSchema: AnalyzeImageOutput,
|
|
1949
2371
|
parameters: AnalyzeImageParams,
|
|
1950
2372
|
execute: async (_toolCallId, params, _signal, _onUpdate, extCtx) => {
|
|
1951
2373
|
const entries = extCtx.sessionManager.getEntries();
|
|
@@ -1953,14 +2375,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
1953
2375
|
|
|
1954
2376
|
// Runtime check - tool may have been disabled mid-session
|
|
1955
2377
|
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." }] };
|
|
2378
|
+
return { content: [{ type: "text" as const, text: "Error: analyze_image tool is currently disabled. Use /multimodal-proxy tool on to enable." }], isError: true };
|
|
1957
2379
|
}
|
|
1958
2380
|
|
|
1959
2381
|
// Rate limit per turn
|
|
1960
2382
|
const state = getSessionState(extCtx);
|
|
1961
2383
|
state.toolCallCount++;
|
|
1962
2384
|
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.` }] };
|
|
2385
|
+
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
2386
|
}
|
|
1965
2387
|
|
|
1966
2388
|
// Sync cache size with current config
|
|
@@ -1968,8 +2390,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
1968
2390
|
state.toolCache.resize(config.cacheSize);
|
|
1969
2391
|
}
|
|
1970
2392
|
|
|
1971
|
-
const
|
|
1972
|
-
return {
|
|
2393
|
+
const outcome = await handleAnalyzeImage(params, extCtx, pi, config);
|
|
2394
|
+
return {
|
|
2395
|
+
content: [{ type: "text" as const, text: outcome.text }],
|
|
2396
|
+
// pi ≥ 0.99: `isError` marks failures for the model, and
|
|
2397
|
+
// `structuredContent` carries the outputSchema-shaped result for
|
|
2398
|
+
// codemode scripts. Older hosts ignore both fields.
|
|
2399
|
+
...(outcome.isError ? { isError: true } : {}),
|
|
2400
|
+
...(outcome.structured ? { structuredContent: outcome.structured } : {}),
|
|
2401
|
+
};
|
|
1973
2402
|
},
|
|
1974
2403
|
});
|
|
1975
2404
|
_toolRegistered = true;
|
|
@@ -2000,6 +2429,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
2000
2429
|
// Register tool if enabled
|
|
2001
2430
|
syncToolRegistration(config);
|
|
2002
2431
|
|
|
2432
|
+
// Register the multimodal-proxy/auto virtual model (pi ≥ 0.99)
|
|
2433
|
+
syncVirtualModelRegistration(config);
|
|
2434
|
+
|
|
2003
2435
|
// Stack the `#` image-recall autocomplete on the editor
|
|
2004
2436
|
registerRecallAutocomplete(ctx);
|
|
2005
2437
|
});
|
|
@@ -2022,6 +2454,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2022
2454
|
const rawConfig = resolveConfig(entries, process.env, _fileConfig);
|
|
2023
2455
|
const config = await withModelFallback(rawConfig, ctx);
|
|
2024
2456
|
notifyDefaultSubstitution(rawConfig, config, ctx);
|
|
2457
|
+
syncVirtualModelRegistration(config);
|
|
2025
2458
|
const pathAccess = pathAccessFromConfig(config);
|
|
2026
2459
|
|
|
2027
2460
|
// Collect images: structured attachments + file paths detected in prompt
|
|
@@ -2355,7 +2788,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2355
2788
|
|
|
2356
2789
|
// 1.16.0 — best-effort downscale of oversized joint uploads
|
|
2357
2790
|
const jointPayloads = await Promise.all(
|
|
2358
|
-
jointImages.map((img) => downscaleForUpload(img, config)),
|
|
2791
|
+
jointImages.map((img) => downscaleForUpload(img, config, modelImageResizeLimits(jointVisionModel))),
|
|
2359
2792
|
);
|
|
2360
2793
|
|
|
2361
2794
|
const contentParts: Array<{ type: "text"; text: string } | PiAiImage> = [
|
|
@@ -2534,8 +2967,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2534
2967
|
// Restate the recall affordance once per context build (not per image),
|
|
2535
2968
|
// so the agent is reminded it can re-query earlier images even on turns
|
|
2536
2969
|
// where no new image was attached. Trusted extension text — kept outside
|
|
2537
|
-
// the untrusted description fence.
|
|
2538
|
-
|
|
2970
|
+
// the untrusted description fence. Once the host's context_with_system
|
|
2971
|
+
// event has been observed (pi ≥ 0.87, see the handler below), that
|
|
2972
|
+
// system-layer block owns the affordance and this path stays silent.
|
|
2973
|
+
let recallHintInjected = sessionState.cwsSupported === true;
|
|
2539
2974
|
|
|
2540
2975
|
let modified = false;
|
|
2541
2976
|
let messages = !strip ? event.messages : event.messages.map((msg) => {
|
|
@@ -2655,6 +3090,32 @@ export default function (pi: ExtensionAPI) {
|
|
|
2655
3090
|
if (modified) return { messages };
|
|
2656
3091
|
});
|
|
2657
3092
|
|
|
3093
|
+
// pi ≥ 0.87: `context_with_system` runs after `context` on the full
|
|
3094
|
+
// transcript (system messages included) and its result is sent verbatim.
|
|
3095
|
+
// The first invocation only probes support — the context-path hint already
|
|
3096
|
+
// covered that request — then a stable system-layer block carries the recall
|
|
3097
|
+
// affordance. Unlike the user-message path it survives compaction and also
|
|
3098
|
+
// reaches vision-capable session models, where the strip path never fired.
|
|
3099
|
+
// The handler never fires on older hosts, so behavior there is unchanged.
|
|
3100
|
+
pi.on("context_with_system", async (event: { messages: unknown[] }, ctx: ExtensionContext) => {
|
|
3101
|
+
const state = getSessionState(ctx);
|
|
3102
|
+
if (!state.cwsSupported) {
|
|
3103
|
+
state.cwsSupported = true;
|
|
3104
|
+
return; // probe-only: the context-path hint covered this request
|
|
3105
|
+
}
|
|
3106
|
+
const entries = ctx.sessionManager.getEntries();
|
|
3107
|
+
const config = resolveConfig(entries, process.env, _fileConfig);
|
|
3108
|
+
if (config.mode === "off" || config.tool !== "on") return;
|
|
3109
|
+
// Only advertise recall once media has actually been described this session.
|
|
3110
|
+
const descriptions = findDescriptions(entries);
|
|
3111
|
+
if (descriptions.size === 0 && findVideoDescriptions(entries).size === 0) return;
|
|
3112
|
+
const messages = event.messages as Array<{ role: string; content?: unknown }>;
|
|
3113
|
+
const hint = `[vision-proxy] ${RECALL_HINT}`;
|
|
3114
|
+
const last = messages[messages.length - 1];
|
|
3115
|
+
if (last?.role === "system" && last.content === hint) return; // already present
|
|
3116
|
+
return { messages: [...messages, { role: "system", content: hint }] };
|
|
3117
|
+
});
|
|
3118
|
+
|
|
2658
3119
|
// ── /multimodal-proxy command ─────────────────────────────────────────
|
|
2659
3120
|
|
|
2660
3121
|
/**
|
|
@@ -2862,6 +3323,77 @@ export default function (pi: ExtensionAPI) {
|
|
|
2862
3323
|
return;
|
|
2863
3324
|
}
|
|
2864
3325
|
|
|
3326
|
+
// ── Virtual model toggle (pi ≥ 0.99) ─────────────────
|
|
3327
|
+
if (sub === "virtual") {
|
|
3328
|
+
if (env.virtual) {
|
|
3329
|
+
ctx.ui.notify(
|
|
3330
|
+
"[multimodal-proxy] PI_VISION_PROXY_VIRTUAL is set - env overrides commands. Unset to change.",
|
|
3331
|
+
"warning",
|
|
3332
|
+
);
|
|
3333
|
+
return;
|
|
3334
|
+
}
|
|
3335
|
+
if (valueLower === "on" || valueLower === "off") {
|
|
3336
|
+
const next = writePersisted({ ...persisted, virtualModel: valueLower as "on" | "off" });
|
|
3337
|
+
if (!virtualModelSupported() && next.virtualModel === "on") {
|
|
3338
|
+
ctx.ui.notify(
|
|
3339
|
+
"Virtual model: on — but this pi version does not support virtual models (needs pi ≥ 0.99). It will activate after upgrading pi.",
|
|
3340
|
+
"warning",
|
|
3341
|
+
);
|
|
3342
|
+
} else {
|
|
3343
|
+
ctx.ui.notify(
|
|
3344
|
+
next.virtualModel === "on"
|
|
3345
|
+
? "Virtual model: on — select 'Multimodal Auto' (multimodal-proxy/auto) in /model. Media turns route to the vision model, text turns to the base model."
|
|
3346
|
+
: "Virtual model: off",
|
|
3347
|
+
next.virtualModel === "on" ? "info" : "warning",
|
|
3348
|
+
);
|
|
3349
|
+
}
|
|
3350
|
+
syncVirtualModelRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
|
|
3351
|
+
return;
|
|
3352
|
+
}
|
|
3353
|
+
ctx.ui.notify(
|
|
3354
|
+
`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)`,
|
|
3355
|
+
"info",
|
|
3356
|
+
);
|
|
3357
|
+
return;
|
|
3358
|
+
}
|
|
3359
|
+
|
|
3360
|
+
// ── Virtual model base pin ───────────────────────────
|
|
3361
|
+
if (sub === "virtual-base") {
|
|
3362
|
+
if (env.virtualBase) {
|
|
3363
|
+
ctx.ui.notify(
|
|
3364
|
+
"[multimodal-proxy] PI_VISION_PROXY_VIRTUAL_BASE is set - env overrides commands. Unset to change.",
|
|
3365
|
+
"warning",
|
|
3366
|
+
);
|
|
3367
|
+
return;
|
|
3368
|
+
}
|
|
3369
|
+
if (valueLower === "off" || valueLower === "none" || valueLower === "clear") {
|
|
3370
|
+
writePersisted({ ...persisted, virtualBaseProvider: undefined, virtualBaseModelId: undefined });
|
|
3371
|
+
ctx.ui.notify("Virtual model base: auto (last physical model, else the vision model)", "info");
|
|
3372
|
+
return;
|
|
3373
|
+
}
|
|
3374
|
+
if (!value) {
|
|
3375
|
+
ctx.ui.notify(
|
|
3376
|
+
`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`,
|
|
3377
|
+
"info",
|
|
3378
|
+
);
|
|
3379
|
+
return;
|
|
3380
|
+
}
|
|
3381
|
+
const parsed = parseModelString(value);
|
|
3382
|
+
if (!parsed) {
|
|
3383
|
+
ctx.ui.notify(
|
|
3384
|
+
"Usage: /multimodal-proxy virtual-base provider/model-id|off\nExample: /multimodal-proxy virtual-base anthropic/claude-haiku-4-5",
|
|
3385
|
+
"warning",
|
|
3386
|
+
);
|
|
3387
|
+
return;
|
|
3388
|
+
}
|
|
3389
|
+
writePersisted({ ...persisted, virtualBaseProvider: parsed.provider, virtualBaseModelId: parsed.modelId });
|
|
3390
|
+
ctx.ui.notify(
|
|
3391
|
+
`Virtual model base: ${parsed.provider}/${parsed.modelId} (text turns while 'Multimodal Auto' is selected)`,
|
|
3392
|
+
"info",
|
|
3393
|
+
);
|
|
3394
|
+
return;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
2865
3397
|
// ── Pick from vision-capable registry ───────────────
|
|
2866
3398
|
if (sub === "pick") {
|
|
2867
3399
|
await pickVisionModel(ctx, persisted, writePersisted, !!env.model);
|
|
@@ -3751,7 +4283,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3751
4283
|
|
|
3752
4284
|
// 1.16.0 — best-effort downscale of oversized uploads after cropping
|
|
3753
4285
|
for (const p of imagePayloads) {
|
|
3754
|
-
p.image = await downscaleForUpload(p.image, descConfig);
|
|
4286
|
+
p.image = await downscaleForUpload(p.image, descConfig, modelImageResizeLimits(descVisionModel));
|
|
3755
4287
|
}
|
|
3756
4288
|
|
|
3757
4289
|
// Get auth
|
|
@@ -3875,6 +4407,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3875
4407
|
env.allowHome && "allowHome", env.allowedFolders && "allowedFolders",
|
|
3876
4408
|
env.statusLine && "statusLine", env.pathDetection && "pathDetection",
|
|
3877
4409
|
env.retryMax && "retryMax", env.maxUpload && "maxUpload", env.fallbackModel && "fallbackModel",
|
|
4410
|
+
env.virtual && "virtual", env.virtualBase && "virtualBase",
|
|
3878
4411
|
].filter(Boolean).join(", ");
|
|
3879
4412
|
const summary =
|
|
3880
4413
|
`Vision proxy: ${modeLabel(effective.mode)}\n` +
|
|
@@ -3892,6 +4425,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3892
4425
|
`Allow home: ${effective.allowHome ? "ON" : "OFF"}\n` +
|
|
3893
4426
|
`Status line: ${effective.statusLine === "on" ? "ON" : "OFF"}\n` +
|
|
3894
4427
|
`Path detection: ${effective.pathDetection === "on" ? "ON" : "OFF"}\n` +
|
|
4428
|
+
`Virtual model: ${effective.virtualModel === "on" ? `on${virtualModelSupported() ? "" : " (needs pi ≥ 0.99)"} — base ${effective.virtualBaseProvider && effective.virtualBaseModelId ? `${effective.virtualBaseProvider}/${effective.virtualBaseModelId}` : "auto"}` : "off"}\n` +
|
|
3895
4429
|
`Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}\n` +
|
|
3896
4430
|
`Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}\n` +
|
|
3897
4431
|
(activeEnvOverrides ? `Env overrides: ${activeEnvOverrides}\n` : "");
|
|
@@ -3899,7 +4433,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3899
4433
|
if (!ctx.hasUI) {
|
|
3900
4434
|
ctx.ui.notify(
|
|
3901
4435
|
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`,
|
|
4436
|
+
`\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
4437
|
"info",
|
|
3904
4438
|
);
|
|
3905
4439
|
return;
|
|
@@ -3920,6 +4454,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3920
4454
|
`Allow home: ${effective.allowHome ? "ON" : "OFF"}`,
|
|
3921
4455
|
`Status line: ${effective.statusLine === "on" ? "ON" : "OFF"}`,
|
|
3922
4456
|
`Path detection: ${effective.pathDetection === "on" ? "ON" : "OFF"}`,
|
|
4457
|
+
`Virtual model: ${effective.virtualModel === "on" ? "on" : "off"}${effective.virtualModel === "on" && effective.virtualBaseProvider && effective.virtualBaseModelId ? ` (base ${effective.virtualBaseProvider}/${effective.virtualBaseModelId})` : ""}`,
|
|
3923
4458
|
`Consent: ${hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders) ? "granted" : "not granted"}`,
|
|
3924
4459
|
`Allowed providers: ${(effective.allowedProviders ?? []).length > 0 ? effective.allowedProviders!.join(", ") : "none"}`,
|
|
3925
4460
|
"Run diagnostics (doctor)",
|
|
@@ -4158,6 +4693,54 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
4158
4693
|
return;
|
|
4159
4694
|
}
|
|
4160
4695
|
|
|
4696
|
+
if (choice.startsWith("Virtual model")) {
|
|
4697
|
+
if (env.virtual) {
|
|
4698
|
+
ctx.ui.notify("[multimodal-proxy] Env override active for virtual model.", "warning");
|
|
4699
|
+
return;
|
|
4700
|
+
}
|
|
4701
|
+
const virtChoice = await ctx.ui.select(
|
|
4702
|
+
"Virtual model (pi ≥ 0.99): select 'Multimodal Auto' in /model; media turns route to the vision model, text turns to the base",
|
|
4703
|
+
["on", "off", "Set base model…"],
|
|
4704
|
+
);
|
|
4705
|
+
if (virtChoice === "on" || virtChoice === "off") {
|
|
4706
|
+
const next = writePersisted({ ...persisted, virtualModel: virtChoice });
|
|
4707
|
+
if (!virtualModelSupported() && next.virtualModel === "on") {
|
|
4708
|
+
ctx.ui.notify("Virtual model: on — this pi version does not support virtual models (needs pi ≥ 0.99); activates after upgrading.", "warning");
|
|
4709
|
+
} else {
|
|
4710
|
+
ctx.ui.notify(`Virtual model: ${next.virtualModel}`, next.virtualModel === "on" ? "info" : "warning");
|
|
4711
|
+
}
|
|
4712
|
+
syncVirtualModelRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
|
|
4713
|
+
return;
|
|
4714
|
+
}
|
|
4715
|
+
if (virtChoice === "Set base model…") {
|
|
4716
|
+
if (env.virtualBase) {
|
|
4717
|
+
ctx.ui.notify("[multimodal-proxy] Env override active for virtual base.", "warning");
|
|
4718
|
+
return;
|
|
4719
|
+
}
|
|
4720
|
+
const val = await ctx.ui.input(
|
|
4721
|
+
"Virtual model base (provider/model-id, or blank for auto)",
|
|
4722
|
+
effective.virtualBaseProvider && effective.virtualBaseModelId
|
|
4723
|
+
? `${effective.virtualBaseProvider}/${effective.virtualBaseModelId}`
|
|
4724
|
+
: "",
|
|
4725
|
+
);
|
|
4726
|
+
if (val === undefined || val === null) return;
|
|
4727
|
+
const trimmed = val.trim();
|
|
4728
|
+
if (!trimmed) {
|
|
4729
|
+
writePersisted({ ...persisted, virtualBaseProvider: undefined, virtualBaseModelId: undefined });
|
|
4730
|
+
ctx.ui.notify("Virtual model base: auto (last physical model, else the vision model)", "info");
|
|
4731
|
+
return;
|
|
4732
|
+
}
|
|
4733
|
+
const parsed = parseModelString(trimmed);
|
|
4734
|
+
if (!parsed) {
|
|
4735
|
+
ctx.ui.notify("Usage: provider/model-id — Example: anthropic/claude-haiku-4-5", "warning");
|
|
4736
|
+
return;
|
|
4737
|
+
}
|
|
4738
|
+
writePersisted({ ...persisted, virtualBaseProvider: parsed.provider, virtualBaseModelId: parsed.modelId });
|
|
4739
|
+
ctx.ui.notify(`Virtual model base: ${parsed.provider}/${parsed.modelId}`, "info");
|
|
4740
|
+
}
|
|
4741
|
+
return;
|
|
4742
|
+
}
|
|
4743
|
+
|
|
4161
4744
|
if (choice.startsWith("Consent")) {
|
|
4162
4745
|
const granted = !hasConsent(entries, effective.provider, effective.allowedProviders, effective.deniedProviders);
|
|
4163
4746
|
pi.appendEntry<ConsentEntry>(CUSTOM_TYPE_CONSENT, { granted, provider: effective.provider });
|