pi-multimodal-proxy 1.19.0 → 1.20.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 +17 -0
- package/README.md +11 -0
- package/extensions/__tests__/internal.test.ts +128 -0
- package/extensions/__tests__/recall-autocomplete.test.ts +10 -0
- package/extensions/internal.ts +116 -2
- package/extensions/vision-proxy.ts +436 -21
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [1.20.0] - 2026-10-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`generate_image` tool (Pi ≥ 1.0, feature-detected)** — agent-facing image creation through `ModelRegistry.generateImages()` with the session's credentials and request-time auth; usage counts toward session cost. Parameters: `prompt` (1..4000 chars), optional `model` (`provider/model-id` of an image-generation model). Model resolution walks `getAvailableOfType("image")` (cached per session): without an override the first keyed image model wins; a mistyped override errors with recovery-style close matches and a malformed one gets the expected format (Pi 1.0 codemode-error philosophy). The prompt leaves only after the data-egress consent gate (`/multimodal-proxy consent yes` / `allowed-providers`). Results carry the generated image blocks (vision-capable base models see the pixels directly) plus a summary text naming each `image="<id>"` id, and an `outputSchema`-shaped `structuredContent` (provider, model, per-image id/mimeType/dimensions, `costUSD` when the provider reported usage; `usage` passed through on the tool result). Generated images are persisted in the per-session recall stores (`imageData`/`imageMeta`) with `generated-<hash8>.<ext>` filename hints, so the existing pipeline takes over: for text-only base models the `tool_result` hook auto-describes them via the vision model and appends `DescriptionEntry`s (making `#` recall work), and `analyze_image` can re-query or crop them by id regardless of base-model modality. Shares the per-turn tool-call budget with `analyze_image` (10/turn); annotated `readOnlyHint: false` (cost-incurring content creation) and namespaced `multimodal-proxy`. Registered only when the runtime exposes `generateImages` — pi < 1.0 never sees the tool.
|
|
12
|
+
- Five new tests for `resolveImageGenModelChoice` (default-first, exact override, close-match suggestions on typos, expected-format error, actionable hint when nothing is keyed) — 512 total, all green.
|
|
13
|
+
|
|
14
|
+
## [1.19.1] - 2026-10-02
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **Leaner `analyze_image` description** — the tool description shrinks ~40% (1.7 KB → ~1.0 KB) while keeping every actionable detail (when to use, the three crop forms with shapes, recall ids, fence attributes, `crop_origin` math). Pi 1.0's codemode lists tool declarations under a 3000-token inline budget and drops what doesn't fit, so a long description both costs tokens on every request and risks pushing other tools out of the listing.
|
|
19
|
+
- **Recovery-style errors (Pi 1.0 codemode philosophy: errors say how to recover)** — `analyze_image`'s model-not-found error now names close registry matches (`anthropic/claude-sont-5` → suggests `anthropic/claude-sonnet-5`, `…-sonnet-4-5`), the recall-id-not-found error lists close ids still retained in the session cache, and zero-area crop errors explain the expected shape (fraction/absolute bounds) instead of just "zero area".
|
|
20
|
+
- **Dynamic implicit-vision-model fallback (Pi ≥ 1.0, feature-detected)** — when the curated fallback chain (`zai/glm-5.3-flash` → `deepseek/…-flash-vision-exp` → `anthropic/claude-sonnet-5/4-5`) is entirely unkeyed but another keyed vision-capable chat model exists (openrouter, google, …), `ModelRegistry.getAvailableOfType("chat")` resolves the implicit default to the first one (cheap-tier ids — flash/mini/fast/haiku/express/lite — preferred so an implicit default never lands on a frontier-priced model). Probe cached per session; older runtimes and the "nothing keyed anywhere" notice path are unchanged.
|
|
21
|
+
- **`#` recall autocomplete regression tests** mirroring pi #10218 (autocomplete must trigger when input starts with whitespace) — the `(?:^|\s)#` token regex already handled it; the tests pin it.
|
|
22
|
+
- Six new tests: `closeMatches` (near-typo ranking, substring bonus, relevance floor, deterministic ties), `pickDynamicVisionCandidate` (cheap-tier preference, first-capable fallback, input filtering), zero-area crop recovery hints (507 total, all green).
|
|
23
|
+
|
|
7
24
|
## [1.19.0] - 2026-10-01
|
|
8
25
|
|
|
9
26
|
### Added
|
package/README.md
CHANGED
|
@@ -8,10 +8,21 @@ When **video or audio files** are detected, they are routed to a **multimodal mo
|
|
|
8
8
|
|
|
9
9
|
**YouTube links** are detected too: paste a URL (`youtube.com/watch?v=…`, `youtu.be/…`, `/shorts/…`, etc.) and the video is downloaded with [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) and analyzed exactly like a local file.
|
|
10
10
|
|
|
11
|
+
## What's new in 1.20.0
|
|
12
|
+
|
|
13
|
+
Image generation, riding Pi 1.0's new `models.generateImages()` API:
|
|
14
|
+
|
|
15
|
+
- **`generate_image` tool** — agent-facing image creation with the session's image-generation models (feature-detected; silently absent on pi < 1.0). Prompt leaves only after the data-egress consent gate; the call reports usage/cost through the normal session accounting.
|
|
16
|
+
- **Generated images join the pipeline** — results return as image blocks (vision base models see the pixels) **and** are persisted in the recall stores, so a text-only base model gets an automatic vision-model description (same as any tool-returned image), `#` recall works once described, and `analyze_image` can re-query or crop them via their `image="<id>"` ids.
|
|
17
|
+
- **Codemode-friendly** — `outputSchema`-shaped `structuredContent` (provider, model, per-image ids/dimensions, `costUSD`) so scripts consume results programmatically; leans into Pi 1.0's leaner codemode with a compact description and recovery-style errors (close-match model suggestions, expected `provider/model-id` format, actionable `pi --login` hint when nothing is keyed).
|
|
18
|
+
- **Shared cost guard** — `generate_image` counts against the same per-turn tool-call budget as `analyze_image`.
|
|
19
|
+
|
|
11
20
|
## What's new in 1.19.0
|
|
12
21
|
|
|
13
22
|
Deep integration with recent Pi releases (0.87 / 0.99):
|
|
14
23
|
|
|
24
|
+
- **`generate_image` tool (Pi ≥ 1.0)** — the agent can now **create** images, not just understand them. Calls run through `models.generateImages()` with the session's credentials (usage counts toward session cost), the model defaults to the first keyed image-generation model (override per call with `model: "provider/model-id"`), results carry image blocks plus stable `image="<id>"` ids, and the existing pipeline takes over automatically: text-only base models get an auto-description via your vision model, and every generated image is recallable (`#`) and re-analyzable/croppable with `analyze_image` like any attachment. Data-egress consent gates the provider before the first call.
|
|
25
|
+
- **Pi 1.0 alignment (1.19.1)** — leaner `analyze_image` description (~40% smaller — codemode lists tools under a 3000-token budget and drops what doesn't fit), recovery-style errors that name close matches (model typos, recall ids) and expected shapes (crops), and a dynamic implicit-vision-model fallback via `getAvailableOfType()` when the curated chain is entirely unkeyed.
|
|
15
26
|
- **Virtual model — `Multimodal Auto`** (Pi ≥ 0.99, on by default): a new entry in `/model` that routes each request for you — turns whose pending input carries images go **natively** to your configured vision model (real pixels, no text fence), everything else stays on your base model (auto = whatever physical model handled the session before, or pin one with `/multimodal-proxy virtual-base`). Sticky routing keeps prompt caches valid within a turn; the footer shows the routed model and `/session` lists cost per physical model. Disable with `/multimodal-proxy virtual off`.
|
|
16
27
|
- **`analyze_image` speaks the 0.99 tool API** — errors now surface as real tool errors (`isError`) instead of fake text, results carry a machine-readable `structuredContent` (analysis text, provider/model, latency, grounding format, per-image ids/dimensions/crop) so **codemode scripts** can consume them programmatically, and the tool is annotated (`readOnlyHint`) and namespaced (`multimodal-proxy`) for codemode listings.
|
|
17
28
|
- **Model-aware upload downscale** (Pi ≥ 0.87) — when the configured vision model declares `inputLimits.images.resize` in its catalog entry, the proxy derives the downscale target, JPEG quality, and byte budget from it (tightening, never loosening, your `max-upload` settings). No more double-resizing against provider limits.
|
|
@@ -79,6 +79,9 @@ import {
|
|
|
79
79
|
bufferToPiAiImage,
|
|
80
80
|
shouldStripImages,
|
|
81
81
|
selectVisionModels,
|
|
82
|
+
closeMatches,
|
|
83
|
+
pickDynamicVisionCandidate,
|
|
84
|
+
resolveImageGenModelChoice,
|
|
82
85
|
splitSubcommand,
|
|
83
86
|
stripImagePaths,
|
|
84
87
|
stripMediaPaths,
|
|
@@ -374,6 +377,116 @@ describe("selectVisionModels", () => {
|
|
|
374
377
|
});
|
|
375
378
|
});
|
|
376
379
|
|
|
380
|
+
describe("closeMatches", () => {
|
|
381
|
+
const catalog = [
|
|
382
|
+
"anthropic/claude-sonnet-5",
|
|
383
|
+
"anthropic/claude-sonnet-4-5",
|
|
384
|
+
"deepseek/deepseek-v4-flash-vision-exp",
|
|
385
|
+
"zai/glm-5.3-flash",
|
|
386
|
+
"openai/gpt-5.6",
|
|
387
|
+
];
|
|
388
|
+
|
|
389
|
+
it("ranks a near-typo above unrelated models", () => {
|
|
390
|
+
assert.deepEqual(closeMatches("anthropic/claude-sont-5", catalog, 2), [
|
|
391
|
+
"anthropic/claude-sonnet-5",
|
|
392
|
+
"anthropic/claude-sonnet-4-5",
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("gives substring matches a strong bonus", () => {
|
|
397
|
+
assert.deepEqual(closeMatches("glm", catalog, 1), ["zai/glm-5.3-flash"]);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("returns nothing for queries with no reasonable match", () => {
|
|
401
|
+
assert.deepEqual(closeMatches("banana-pudding", catalog), []);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("is deterministic on ties (alphabetical)", () => {
|
|
405
|
+
assert.deepEqual(closeMatches("mod", ["mod-b", "mod-a", "mod-c"], 3), ["mod-a", "mod-b", "mod-c"]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("handles empty input", () => {
|
|
409
|
+
assert.deepEqual(closeMatches("", catalog), []);
|
|
410
|
+
assert.deepEqual(closeMatches("x", []), []);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe("pickDynamicVisionCandidate", () => {
|
|
415
|
+
const m = (provider: string, modelId: string, input: readonly string[] = ["text", "image"]) => ({
|
|
416
|
+
provider,
|
|
417
|
+
modelId,
|
|
418
|
+
input,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("prefers a cheap-tier model over registry order", () => {
|
|
422
|
+
const out = pickDynamicVisionCandidate([
|
|
423
|
+
m("anthropic", "claude-opus-6"),
|
|
424
|
+
m("zai", "glm-5.3-flash"),
|
|
425
|
+
]);
|
|
426
|
+
assert.equal(out?.modelId, "glm-5.3-flash");
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("falls back to the first image-capable model when none is cheap-tier", () => {
|
|
430
|
+
const out = pickDynamicVisionCandidate([m("anthropic", "claude-opus-6"), m("x", "y")]);
|
|
431
|
+
assert.equal(out?.modelId, "claude-opus-6");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("skips models without image input and empty lists", () => {
|
|
435
|
+
assert.equal(pickDynamicVisionCandidate([m("a", "b", ["text"])]), undefined);
|
|
436
|
+
assert.equal(pickDynamicVisionCandidate([]), undefined);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
describe("resolveImageGenModelChoice", () => {
|
|
441
|
+
const available = [
|
|
442
|
+
{ provider: "openrouter", modelId: "gpt-image-1" },
|
|
443
|
+
{ provider: "openrouter", modelId: "flux-schnell" },
|
|
444
|
+
{ provider: "google", modelId: "imagen-4" },
|
|
445
|
+
];
|
|
446
|
+
|
|
447
|
+
it("defaults to the first available model without an override", () => {
|
|
448
|
+
assert.deepEqual(resolveImageGenModelChoice(undefined, available), {
|
|
449
|
+
ok: true,
|
|
450
|
+
provider: "openrouter",
|
|
451
|
+
modelId: "gpt-image-1",
|
|
452
|
+
});
|
|
453
|
+
assert.deepEqual(resolveImageGenModelChoice("", available), {
|
|
454
|
+
ok: true,
|
|
455
|
+
provider: "openrouter",
|
|
456
|
+
modelId: "gpt-image-1",
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("accepts an exact override", () => {
|
|
461
|
+
assert.deepEqual(resolveImageGenModelChoice("google/imagen-4", available), {
|
|
462
|
+
ok: true,
|
|
463
|
+
provider: "google",
|
|
464
|
+
modelId: "imagen-4",
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("mistyped overrides carry recovery-style close matches", () => {
|
|
469
|
+
const out = resolveImageGenModelChoice("openrouter/gpt-imge-1", available);
|
|
470
|
+
assert.equal(out.ok, false);
|
|
471
|
+
if (!out.ok) {
|
|
472
|
+
assert.match(out.error, /not found/);
|
|
473
|
+
assert.ok(out.suggestions.includes("openrouter/gpt-image-1"));
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it("rejects malformed model strings with the expected format", () => {
|
|
478
|
+
const out = resolveImageGenModelChoice("no-slash", available);
|
|
479
|
+
assert.equal(out.ok, false);
|
|
480
|
+
if (!out.ok) assert.match(out.error, /Expected format: provider\/model-id/);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it("errors with an actionable hint when nothing is keyed", () => {
|
|
484
|
+
const out = resolveImageGenModelChoice(undefined, []);
|
|
485
|
+
assert.equal(out.ok, false);
|
|
486
|
+
if (!out.ok) assert.match(out.error, /pi --login/);
|
|
487
|
+
});
|
|
488
|
+
});
|
|
489
|
+
|
|
377
490
|
describe("sanitizeProviderHeaders", () => {
|
|
378
491
|
it("drops null deletion markers", () => {
|
|
379
492
|
assert.deepEqual(
|
|
@@ -1844,6 +1957,21 @@ describe("resolveCropEntry", () => {
|
|
|
1844
1957
|
/zero area/,
|
|
1845
1958
|
);
|
|
1846
1959
|
});
|
|
1960
|
+
|
|
1961
|
+
it("zero-area normalized errors carry a recovery hint (pi 1.0 style)", () => {
|
|
1962
|
+
assert.throws(
|
|
1963
|
+
() =>
|
|
1964
|
+
resolveCropEntry({ image_index: 0, normalized: { x: 1.0, y: 1.0, width: 0, height: 0 } }, 100, 100),
|
|
1965
|
+
/fractions of the image/,
|
|
1966
|
+
);
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
it("zero-area pixel errors carry a recovery hint (pi 1.0 style)", () => {
|
|
1970
|
+
assert.throws(
|
|
1971
|
+
() => resolveCropEntry({ image_index: 0, pixels: { x: 200, y: 200, width: 10, height: 10 } }, 100, 100),
|
|
1972
|
+
/inside the image/,
|
|
1973
|
+
);
|
|
1974
|
+
});
|
|
1847
1975
|
});
|
|
1848
1976
|
|
|
1849
1977
|
describe("cropSignature", () => {
|
|
@@ -16,6 +16,16 @@ describe("extractRecallToken", () => {
|
|
|
16
16
|
assert.deepEqual(extractRecallToken(["#"], 0, 1), { query: "", prefix: "#" });
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
it("matches a # token after leading whitespace (pi #10218 mirror)", () => {
|
|
20
|
+
// Pi 1.0 fixed slash-command autocomplete not triggering when the input
|
|
21
|
+
// starts with whitespace; the # recall provider must behave the same.
|
|
22
|
+
assert.deepEqual(extractRecallToken([" #shot"], 0, 7), {
|
|
23
|
+
query: "shot",
|
|
24
|
+
prefix: "#shot",
|
|
25
|
+
});
|
|
26
|
+
assert.deepEqual(extractRecallToken([" #"], 0, 4), { query: "", prefix: "#" });
|
|
27
|
+
});
|
|
28
|
+
|
|
19
29
|
it("matches a # token after whitespace with a partial query", () => {
|
|
20
30
|
assert.deepEqual(extractRecallToken(["zoom into #shot"], 0, 15), {
|
|
21
31
|
query: "shot",
|
package/extensions/internal.ts
CHANGED
|
@@ -2373,6 +2373,112 @@ export function selectVisionModels<T extends VisionModelLike>(
|
|
|
2373
2373
|
return all.filter((m) => m.input.includes("image"));
|
|
2374
2374
|
}
|
|
2375
2375
|
|
|
2376
|
+
/** Lowercased bigram set of a string, for Dice-coefficient similarity. */
|
|
2377
|
+
function bigrams(s: string): Set<string> {
|
|
2378
|
+
const t = s.toLowerCase();
|
|
2379
|
+
const out = new Set<string>();
|
|
2380
|
+
for (let i = 0; i + 1 < t.length; i++) out.add(t.slice(i, i + 2));
|
|
2381
|
+
return out;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/**
|
|
2385
|
+
* Close-match suggestions for a mistyped identifier, in the spirit of Pi
|
|
2386
|
+
* 1.0's codemode recovery errors ("tools.Bash suggests tools.bash").
|
|
2387
|
+
* Ranks by Dice bigram similarity with a strong exact/substring bonus;
|
|
2388
|
+
* returns at most `limit` candidates above the relevance floor, best first,
|
|
2389
|
+
* ties broken alphabetically so the output is deterministic.
|
|
2390
|
+
*/
|
|
2391
|
+
export function closeMatches(query: string, candidates: readonly string[], limit = 3): string[] {
|
|
2392
|
+
if (!query || candidates.length === 0) return [];
|
|
2393
|
+
const q = query.toLowerCase();
|
|
2394
|
+
const scored = candidates.map((candidate) => {
|
|
2395
|
+
const c = candidate.toLowerCase();
|
|
2396
|
+
let score: number;
|
|
2397
|
+
if (c === q) score = 2;
|
|
2398
|
+
else if (c.includes(q) || q.includes(c)) score = 1.5;
|
|
2399
|
+
else {
|
|
2400
|
+
const bq = bigrams(q);
|
|
2401
|
+
const bc = bigrams(c);
|
|
2402
|
+
let shared = 0;
|
|
2403
|
+
for (const g of bq) if (bc.has(g)) shared++;
|
|
2404
|
+
score = bq.size === 0 || bc.size === 0 ? 0 : (2 * shared) / (bq.size + bc.size);
|
|
2405
|
+
}
|
|
2406
|
+
return { candidate, score };
|
|
2407
|
+
});
|
|
2408
|
+
return scored
|
|
2409
|
+
.filter((s) => s.score >= 0.3)
|
|
2410
|
+
.sort((a, b) => b.score - a.score || (a.candidate < b.candidate ? -1 : a.candidate > b.candidate ? 1 : 0))
|
|
2411
|
+
.slice(0, limit)
|
|
2412
|
+
.map((s) => s.candidate);
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
/** Minimal shape for dynamic (registry-provided) vision candidates. */
|
|
2416
|
+
export interface DynamicCandidateLike {
|
|
2417
|
+
provider: string;
|
|
2418
|
+
modelId: string;
|
|
2419
|
+
input?: readonly string[];
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/** Cheap-tier heuristic for implicit default resolution (Pi 1.0 last resort). */
|
|
2423
|
+
const CHEAP_MODEL_RE = /flash|mini|fast|haiku|express|lite/i;
|
|
2424
|
+
|
|
2425
|
+
/**
|
|
2426
|
+
* First keyed registry model with image input — the last-resort implicit
|
|
2427
|
+
* vision default when the curated fallback chain is entirely unkeyed
|
|
2428
|
+
* (Pi ≥ 1.0 `getAvailableOfType("chat")`). Cheap-tier ids are preferred so
|
|
2429
|
+
* an implicit default never lands on a frontier-priced model; registry
|
|
2430
|
+
* order breaks remaining ties.
|
|
2431
|
+
*/
|
|
2432
|
+
export function pickDynamicVisionCandidate<T extends DynamicCandidateLike>(models: readonly T[]): T | undefined {
|
|
2433
|
+
const vision = models.filter((m) => m.input?.includes("image"));
|
|
2434
|
+
if (vision.length === 0) return undefined;
|
|
2435
|
+
return vision.find((m) => CHEAP_MODEL_RE.test(m.modelId)) ?? vision[0];
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
/** Outcome of image-generation model resolution (generate_image tool). */
|
|
2439
|
+
export type ImageGenModelChoice =
|
|
2440
|
+
| { ok: true; provider: string; modelId: string }
|
|
2441
|
+
| { ok: false; error: string; suggestions: readonly string[] };
|
|
2442
|
+
|
|
2443
|
+
/**
|
|
2444
|
+
* Resolve the image-generation model for one `generate_image` call (Pi ≥ 1.0).
|
|
2445
|
+
* An explicit `provider/model-id` override must match an available (keyed)
|
|
2446
|
+
* image model exactly — otherwise the error carries recovery-style close
|
|
2447
|
+
* matches. Without an override the first available model wins.
|
|
2448
|
+
*/
|
|
2449
|
+
export function resolveImageGenModelChoice(
|
|
2450
|
+
override: string | undefined,
|
|
2451
|
+
available: ReadonlyArray<{ provider: string; modelId: string }>,
|
|
2452
|
+
): ImageGenModelChoice {
|
|
2453
|
+
const labels = available.map((m) => `${m.provider}/${m.modelId}`);
|
|
2454
|
+
if (override === undefined || override === "") {
|
|
2455
|
+
const first = available[0];
|
|
2456
|
+
if (!first) {
|
|
2457
|
+
return {
|
|
2458
|
+
ok: false,
|
|
2459
|
+
error: "no image-generation model is available (keyed) in this session — add a provider key (e.g. pi --login openrouter)",
|
|
2460
|
+
suggestions: [],
|
|
2461
|
+
};
|
|
2462
|
+
}
|
|
2463
|
+
return { ok: true, provider: first.provider, modelId: first.modelId };
|
|
2464
|
+
}
|
|
2465
|
+
const parsed = parseModelString(override);
|
|
2466
|
+
if (!parsed) {
|
|
2467
|
+
return {
|
|
2468
|
+
ok: false,
|
|
2469
|
+
error: `invalid model string "${override}". Expected format: provider/model-id`,
|
|
2470
|
+
suggestions: [],
|
|
2471
|
+
};
|
|
2472
|
+
}
|
|
2473
|
+
const exact = available.find((m) => m.provider === parsed.provider && m.modelId === parsed.modelId);
|
|
2474
|
+
if (exact) return { ok: true, provider: exact.provider, modelId: exact.modelId };
|
|
2475
|
+
return {
|
|
2476
|
+
ok: false,
|
|
2477
|
+
error: `model "${parsed.provider}/${parsed.modelId}" not found among available image-generation models`,
|
|
2478
|
+
suggestions: closeMatches(`${parsed.provider}/${parsed.modelId}`, labels, 3),
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2376
2482
|
export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
|
|
2377
2483
|
if (config.mode === "off") return false;
|
|
2378
2484
|
if (config.mode === "always") return true;
|
|
@@ -2524,12 +2630,20 @@ export function resolveCropEntry(crop: CropEntry, imgWidth: number, imgHeight: n
|
|
|
2524
2630
|
}
|
|
2525
2631
|
if ("normalized" in crop) {
|
|
2526
2632
|
const result = normalizedToPixels(crop.normalized, imgWidth, imgHeight);
|
|
2527
|
-
if (!result)
|
|
2633
|
+
if (!result)
|
|
2634
|
+
throw new Error(
|
|
2635
|
+
`Normalized crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
|
|
2636
|
+
`Use fractions of the image: x ≥ 0, y ≥ 0, x + width ≤ 1, y + height ≤ 1.`,
|
|
2637
|
+
);
|
|
2528
2638
|
return result;
|
|
2529
2639
|
}
|
|
2530
2640
|
if ("pixels" in crop) {
|
|
2531
2641
|
const result = clampPixels(crop.pixels, imgWidth, imgHeight);
|
|
2532
|
-
if (!result)
|
|
2642
|
+
if (!result)
|
|
2643
|
+
throw new Error(
|
|
2644
|
+
`Pixel crop has zero area after clamping (image: ${imgWidth}x${imgHeight}). ` +
|
|
2645
|
+
`Keep the rectangle inside the image: x ≥ 0, y ≥ 0, x + width ≤ ${imgWidth}, y + height ≤ ${imgHeight}.`,
|
|
2646
|
+
);
|
|
2533
2647
|
return result;
|
|
2534
2648
|
}
|
|
2535
2649
|
throw new Error("Invalid CropEntry: must have exactly one of region, normalized, or pixels");
|
|
@@ -282,6 +282,9 @@ import {
|
|
|
282
282
|
canonicalYouTubeUrl,
|
|
283
283
|
youTubeVideoId,
|
|
284
284
|
applyDefaultModelFallback,
|
|
285
|
+
closeMatches,
|
|
286
|
+
pickDynamicVisionCandidate,
|
|
287
|
+
resolveImageGenModelChoice,
|
|
285
288
|
DEFAULT_MODEL_FALLBACKS,
|
|
286
289
|
applyRecallCompletion,
|
|
287
290
|
buildRecallItems,
|
|
@@ -450,27 +453,69 @@ const AnalyzeImageOutput = Type.Object({
|
|
|
450
453
|
});
|
|
451
454
|
|
|
452
455
|
const TOOL_DESCRIPTION = [
|
|
453
|
-
"
|
|
454
|
-
"(b) you need to compare or cross-reference multiple images, or (c) you need to focus on a specific region.",
|
|
456
|
+
"Targeted image analysis with cropping and model-native grounding support.",
|
|
455
457
|
"",
|
|
456
|
-
"
|
|
458
|
+
"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.",
|
|
457
459
|
"",
|
|
458
|
-
"
|
|
459
|
-
"- **`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 } }`.",
|
|
460
|
-
"- **`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 } }`.",
|
|
460
|
+
"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.",
|
|
461
461
|
"",
|
|
462
|
-
"
|
|
462
|
+
"Cropping, in order of preference (one crop per image, keyed by `image_index`):",
|
|
463
|
+
"- `region` — named cut (`top-left`, `bottom-right`, `center`, `right-half`, …) when you don't know the dimensions: `{ image_index: 0, region: \"bottom-right\" }`.",
|
|
464
|
+
"- `normalized` — `{ x, y, width, height }` as 0.0-1.0 fractions; the default for precise crops without dimensions.",
|
|
465
|
+
"- `pixels` — `{ x, y, width, height }` absolute; only with authoritative coordinates from a prior fence's `width`/`height` attributes or a grounded response.",
|
|
463
466
|
"",
|
|
464
|
-
"
|
|
465
|
-
"",
|
|
466
|
-
"When a crop is applied, the response fence carries a `crop_origin` attribute (e.g. `crop_origin=\"1840,120\"`). Add the origin's x to any returned x-coordinate and the origin's y to any returned y-coordinate to map coordinates back to the original full image.",
|
|
467
|
-
"",
|
|
468
|
-
"The tool result is authoritative for the specific question asked; the cached generic description remains the default for everything else.",
|
|
467
|
+
"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.",
|
|
469
468
|
].join("\n");
|
|
470
469
|
|
|
471
470
|
/** Maximum analyze_image tool calls per agent turn. Prevents cost runaway. */
|
|
472
471
|
const MAX_TOOL_CALLS_PER_TURN = 10;
|
|
473
472
|
|
|
473
|
+
/**
|
|
474
|
+
* generate_image parameters (Pi ≥ 1.0). Lean on purpose: pi 1.0's codemode
|
|
475
|
+
* lists tool declarations under a 3000-token inline budget.
|
|
476
|
+
*/
|
|
477
|
+
const GenerateImageParams = Type.Object({
|
|
478
|
+
prompt: Type.String({
|
|
479
|
+
description: "Required, non-empty, max 4000 chars. What to draw; include style and aspect-ratio cues the provider supports.",
|
|
480
|
+
}),
|
|
481
|
+
model: Type.Optional(
|
|
482
|
+
Type.String({
|
|
483
|
+
description: "Optional; provider/model-id of an image-generation model. Defaults to the first available (keyed) image model.",
|
|
484
|
+
}),
|
|
485
|
+
),
|
|
486
|
+
reason: Type.Optional(Type.String({ description: "Optional; logged for analytics only" })),
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* outputSchema of generate_image: codemode scripts receive this
|
|
491
|
+
* structuredContent instead of the text + image blocks.
|
|
492
|
+
*/
|
|
493
|
+
const GenerateImageOutput = Type.Object({
|
|
494
|
+
ok: Type.Boolean({ description: "Whether generation succeeded." }),
|
|
495
|
+
error: Type.Optional(Type.String({ description: "Failure message, present when ok is false." })),
|
|
496
|
+
provider: Type.Optional(Type.String({ description: "Provider that generated the images." })),
|
|
497
|
+
model: Type.Optional(Type.String({ description: "Model id that generated the images." })),
|
|
498
|
+
images: Type.Optional(
|
|
499
|
+
Type.Array(
|
|
500
|
+
Type.Object({
|
|
501
|
+
id: Type.String({ description: "Stable image id usable for recall (image=\"...\") and analyze_image." }),
|
|
502
|
+
mimeType: Type.String(),
|
|
503
|
+
width: Type.Optional(Type.Number()),
|
|
504
|
+
height: Type.Optional(Type.Number()),
|
|
505
|
+
}),
|
|
506
|
+
),
|
|
507
|
+
),
|
|
508
|
+
costUSD: Type.Optional(Type.Number({ description: "Reported generation cost, when the provider returned usage." })),
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
const GENERATE_IMAGE_DESCRIPTION = [
|
|
512
|
+
"Generate images with the session's image-generation models (Pi ≥ 1.0, request-time credentials).",
|
|
513
|
+
"",
|
|
514
|
+
"`prompt` describes what to draw; `model` optionally picks an image-generation model (provider/model-id — defaults to the first available).",
|
|
515
|
+
"Returns image blocks attached to this result plus stable `image=\"<id>\"` ids that analyze_image can re-analyze or crop like any other image.",
|
|
516
|
+
"Each call costs money — use it when the user asks for an image to be created, not speculatively.",
|
|
517
|
+
].join("\n");
|
|
518
|
+
|
|
474
519
|
/** Default tool-result cache size; resized to config.cacheSize on first use. */
|
|
475
520
|
const DEFAULT_TOOL_CACHE_SIZE = 50;
|
|
476
521
|
|
|
@@ -508,6 +553,10 @@ interface SessionState {
|
|
|
508
553
|
compaction?: { reason?: "manual" | "threshold" | "overflow"; willRetry?: boolean };
|
|
509
554
|
/** Cached "provider/model has an API key" probes (cleared per session). */
|
|
510
555
|
keyProbe?: Map<string, boolean>;
|
|
556
|
+
/** Cached dynamic (registry-keyed) vision candidate; null = none or runtime without the API (Pi ≥ 1.0). */
|
|
557
|
+
dynamicVisionProbe?: { provider: string; modelId: string } | null;
|
|
558
|
+
/** Cached available (keyed) image-generation models (Pi ≥ 1.0). */
|
|
559
|
+
imageGenModels?: Array<{ provider: string; modelId: string }>;
|
|
511
560
|
/** Last implicit-default substitution already notified this session. */
|
|
512
561
|
notifiedDefaultSub?: string;
|
|
513
562
|
/**
|
|
@@ -762,7 +811,56 @@ async function withModelFallback(config: VisionConfig, ctx: ExtensionContext): P
|
|
|
762
811
|
keys.set(`${pair.provider}/${pair.modelId}`, await probe(pair.provider, pair.modelId));
|
|
763
812
|
}
|
|
764
813
|
const hasKey = (p: string, m: string) => keys.get(`${p}/${m}`) === true;
|
|
765
|
-
|
|
814
|
+
const resolved = applyDefaultModelFallback(config, hasModel, false, hasKey);
|
|
815
|
+
// Pi ≥ 1.0 last resort: the curated chain can be entirely unkeyed while
|
|
816
|
+
// the user has another keyed vision-capable model (openrouter, google, …).
|
|
817
|
+
// getAvailableOfType lists exactly those — land the implicit default on
|
|
818
|
+
// the first one instead of dead-ending on the "No API key" notice.
|
|
819
|
+
// (Explicit choices returned early above; a keyed chain resolution already
|
|
820
|
+
// changed provider/modelId, which the equality check skips.)
|
|
821
|
+
const anyKeyed = [...keys.values()].some(Boolean);
|
|
822
|
+
if (!anyKeyed && resolved.provider === config.provider && resolved.modelId === config.modelId) {
|
|
823
|
+
const dyn = await dynamicVisionCandidate(ctx);
|
|
824
|
+
if (dyn) return { ...resolved, provider: dyn.provider, modelId: dyn.modelId };
|
|
825
|
+
}
|
|
826
|
+
return resolved;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Pi ≥ 1.0 dynamic vision-candidate probe (ModelRegistry.getAvailableOfType):
|
|
831
|
+
* the first keyed chat model with image input, cheap tier preferred
|
|
832
|
+
* (pickDynamicVisionCandidate). Feature-detected — older runtimes and probe
|
|
833
|
+
* failures cache as null; the probe runs once per session.
|
|
834
|
+
*/
|
|
835
|
+
async function dynamicVisionCandidate(ctx: ExtensionContext): Promise<{ provider: string; modelId: string } | null> {
|
|
836
|
+
const state = getSessionState(ctx);
|
|
837
|
+
if (state.dynamicVisionProbe !== undefined) return state.dynamicVisionProbe;
|
|
838
|
+
let out: { provider: string; modelId: string } | null = null;
|
|
839
|
+
const reg = ctx.modelRegistry as ModelRegistry & {
|
|
840
|
+
getAvailableOfType?: (type: string) => Promise<ReadonlyArray<unknown>>;
|
|
841
|
+
};
|
|
842
|
+
if (typeof reg.getAvailableOfType === "function") {
|
|
843
|
+
try {
|
|
844
|
+
const available = await reg.getAvailableOfType("chat");
|
|
845
|
+
const candidates = (
|
|
846
|
+
available as ReadonlyArray<{ provider?: unknown; id?: unknown; input?: unknown }>
|
|
847
|
+
)
|
|
848
|
+
.filter(
|
|
849
|
+
(m): m is { provider: string; id: string; input: readonly string[] } =>
|
|
850
|
+
typeof m.provider === "string" &&
|
|
851
|
+
typeof m.id === "string" &&
|
|
852
|
+
Array.isArray(m.input) &&
|
|
853
|
+
m.input.includes("image"),
|
|
854
|
+
)
|
|
855
|
+
.map((m) => ({ provider: m.provider, modelId: m.id, input: m.input }));
|
|
856
|
+
const pick = pickDynamicVisionCandidate(candidates);
|
|
857
|
+
if (pick) out = { provider: pick.provider, modelId: pick.modelId };
|
|
858
|
+
} catch {
|
|
859
|
+
out = null;
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
state.dynamicVisionProbe = out;
|
|
863
|
+
return out;
|
|
766
864
|
}
|
|
767
865
|
|
|
768
866
|
/**
|
|
@@ -1646,6 +1744,234 @@ function analysisError(message: string): AnalyzeImageOutcome {
|
|
|
1646
1744
|
return { text: message, isError: true, structured: { ok: false, error: message } };
|
|
1647
1745
|
}
|
|
1648
1746
|
|
|
1747
|
+
/**
|
|
1748
|
+
* Close-match vision-model suggestions for recovery-style tool errors, in
|
|
1749
|
+
* the spirit of Pi 1.0's codemode errors ("tools.Bash suggests
|
|
1750
|
+
* tools.bash"). Labels every registry model with image input; no key
|
|
1751
|
+
* probing — error paths must stay cheap.
|
|
1752
|
+
*/
|
|
1753
|
+
function registryVisionSuggestions(ctx: ExtensionContext, query: string, limit = 3): string[] {
|
|
1754
|
+
try {
|
|
1755
|
+
const models = ctx.modelRegistry.getAll() as unknown as ReadonlyArray<{
|
|
1756
|
+
provider?: unknown;
|
|
1757
|
+
id?: unknown;
|
|
1758
|
+
input?: unknown;
|
|
1759
|
+
}>;
|
|
1760
|
+
const labels = models
|
|
1761
|
+
.filter(
|
|
1762
|
+
(m): m is { provider: string; id: string; input: readonly string[] } =>
|
|
1763
|
+
typeof m.provider === "string" &&
|
|
1764
|
+
typeof m.id === "string" &&
|
|
1765
|
+
Array.isArray(m.input) &&
|
|
1766
|
+
m.input.includes("image"),
|
|
1767
|
+
)
|
|
1768
|
+
.map((m) => `${m.provider}/${m.id}`);
|
|
1769
|
+
return closeMatches(query, labels, limit);
|
|
1770
|
+
} catch {
|
|
1771
|
+
return [];
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* Close-match recall-id suggestions: matches the mistyped id against every
|
|
1777
|
+
* retained image's hash (and filename, when known) and returns full ids the
|
|
1778
|
+
* agent can pass verbatim.
|
|
1779
|
+
*/
|
|
1780
|
+
function recallSuggestions(imageData: ImageDataStore, imageMeta: ImageMetaStore, query: string, limit = 3): string[] {
|
|
1781
|
+
const candidates: string[] = [];
|
|
1782
|
+
try {
|
|
1783
|
+
for (const hash of imageData.map.keys()) {
|
|
1784
|
+
const meta = imageMeta.get(hash);
|
|
1785
|
+
candidates.push(meta?.filename ? `${hash} (${meta.filename})` : hash);
|
|
1786
|
+
}
|
|
1787
|
+
} catch {
|
|
1788
|
+
return [];
|
|
1789
|
+
}
|
|
1790
|
+
return closeMatches(query, candidates, limit);
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
/** Content block shapes a tool result may carry (text and/or image). */
|
|
1794
|
+
type ToolContentBlock = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
|
|
1795
|
+
|
|
1796
|
+
/** Structured outcome of one generate_image call (1.20.0). */
|
|
1797
|
+
interface GenerateImageOutcome {
|
|
1798
|
+
content: ToolContentBlock[];
|
|
1799
|
+
isError?: boolean;
|
|
1800
|
+
structured?: Record<string, unknown>;
|
|
1801
|
+
usage?: unknown;
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/** Wrap a failure message as a GenerateImageOutcome. */
|
|
1805
|
+
function generationError(message: string): GenerateImageOutcome {
|
|
1806
|
+
return { content: [{ type: "text", text: message }], isError: true, structured: { ok: false, error: message } };
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* Available (keyed) image-generation models via getAvailableOfType("image")
|
|
1811
|
+
* (Pi ≥ 1.0, feature-detected). Cached per session; probe failures cache as
|
|
1812
|
+
* an empty list so a transient error cannot wedge the tool.
|
|
1813
|
+
*/
|
|
1814
|
+
async function availableImageGenModels(ctx: ExtensionContext): Promise<Array<{ provider: string; modelId: string }>> {
|
|
1815
|
+
const state = getSessionState(ctx);
|
|
1816
|
+
if (state.imageGenModels) return state.imageGenModels;
|
|
1817
|
+
let out: Array<{ provider: string; modelId: string }> = [];
|
|
1818
|
+
const reg = ctx.modelRegistry as ModelRegistry & {
|
|
1819
|
+
getAvailableOfType?: (type: string) => Promise<ReadonlyArray<unknown>>;
|
|
1820
|
+
};
|
|
1821
|
+
if (typeof reg.getAvailableOfType === "function") {
|
|
1822
|
+
try {
|
|
1823
|
+
const models = (await reg.getAvailableOfType("image")) as ReadonlyArray<{ provider?: unknown; id?: unknown }>;
|
|
1824
|
+
out = models
|
|
1825
|
+
.filter((m): m is { provider: string; id: string } => typeof m.provider === "string" && typeof m.id === "string")
|
|
1826
|
+
.map((m) => ({ provider: m.provider, modelId: m.id }));
|
|
1827
|
+
} catch {
|
|
1828
|
+
out = [];
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
state.imageGenModels = out;
|
|
1832
|
+
return out;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/** Minimal response shape of ModelRegistry.generateImages() (Pi ≥ 1.0). */
|
|
1836
|
+
interface GenerateImagesResponseLike {
|
|
1837
|
+
output?: ReadonlyArray<{ type?: unknown; data?: unknown; mimeType?: unknown }>;
|
|
1838
|
+
usage?: { costUSD?: unknown; inputTokens?: unknown; outputTokens?: unknown };
|
|
1839
|
+
stopReason?: unknown;
|
|
1840
|
+
errorMessage?: unknown;
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* Generate one or more images with the session's image-generation models
|
|
1845
|
+
* (Pi ≥ 1.0 `models.generateImages()`): resolve the model (explicit override
|
|
1846
|
+
* with recovery-style suggestions, else the first keyed image model), gate
|
|
1847
|
+
* on data-egress consent (the prompt leaves), call the registry, and return
|
|
1848
|
+
* image blocks the conversation can see. Generated images are persisted in
|
|
1849
|
+
* the recall stores, so analyze_image (and `#` recall once described) work
|
|
1850
|
+
* on them exactly like attached images — including when the base model sees
|
|
1851
|
+
* the pixels directly and the tool_result auto-description path never fires.
|
|
1852
|
+
*/
|
|
1853
|
+
async function handleGenerateImage(
|
|
1854
|
+
params: { prompt: string; model?: string; reason?: string },
|
|
1855
|
+
ctx: ExtensionContext,
|
|
1856
|
+
config: VisionConfig,
|
|
1857
|
+
): Promise<GenerateImageOutcome> {
|
|
1858
|
+
const prompt = typeof params.prompt === "string" ? params.prompt.trim() : "";
|
|
1859
|
+
if (!prompt) return generationError("Error: prompt is required and must be non-empty.");
|
|
1860
|
+
if (prompt.length > 4000) return generationError("Error: prompt must be at most 4000 characters.");
|
|
1861
|
+
|
|
1862
|
+
// Resolve the image-generation model.
|
|
1863
|
+
const available = await availableImageGenModels(ctx);
|
|
1864
|
+
const choice = resolveImageGenModelChoice(params.model, available);
|
|
1865
|
+
if (!choice.ok) {
|
|
1866
|
+
return generationError(
|
|
1867
|
+
`Error: ${choice.error}.` +
|
|
1868
|
+
(choice.suggestions.length > 0 ? ` Close matches: ${choice.suggestions.join(", ")}.` : ""),
|
|
1869
|
+
);
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
// Data-egress consent: the prompt leaves for the image provider.
|
|
1873
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1874
|
+
if (!hasConsent(entries, choice.provider, config.allowedProviders, config.deniedProviders)) {
|
|
1875
|
+
return generationError(
|
|
1876
|
+
`Error: consent required before sending the prompt to ${choice.provider}. 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 ${choice.provider})`,
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
// Fetch the ImageModel object for the chosen pair (sync registry lookups).
|
|
1881
|
+
const reg = ctx.modelRegistry as ModelRegistry & {
|
|
1882
|
+
getModelsOfType?: (type: string) => ReadonlyArray<unknown>;
|
|
1883
|
+
findOfType?: (type: string, provider: string, modelId: string) => unknown;
|
|
1884
|
+
generateImages?: (
|
|
1885
|
+
model: unknown,
|
|
1886
|
+
context: { input: Array<{ type: "text"; text: string }> },
|
|
1887
|
+
options?: { signal?: AbortSignal },
|
|
1888
|
+
) => Promise<GenerateImagesResponseLike>;
|
|
1889
|
+
};
|
|
1890
|
+
if (typeof reg.generateImages !== "function") {
|
|
1891
|
+
return generationError("Error: this pi runtime does not expose models.generateImages() (needs pi ≥ 1.0). Update pi.");
|
|
1892
|
+
}
|
|
1893
|
+
let imageModel: unknown;
|
|
1894
|
+
if (typeof reg.getModelsOfType === "function") {
|
|
1895
|
+
const all = reg.getModelsOfType("image") as ReadonlyArray<{ provider?: unknown; id?: unknown }>;
|
|
1896
|
+
imageModel = all.find(
|
|
1897
|
+
(m) =>
|
|
1898
|
+
typeof m.provider === "string" &&
|
|
1899
|
+
typeof m.id === "string" &&
|
|
1900
|
+
m.provider === choice.provider &&
|
|
1901
|
+
m.id === choice.modelId,
|
|
1902
|
+
);
|
|
1903
|
+
}
|
|
1904
|
+
if (!imageModel && typeof reg.findOfType === "function") {
|
|
1905
|
+
imageModel = reg.findOfType("image", choice.provider, choice.modelId);
|
|
1906
|
+
}
|
|
1907
|
+
if (!imageModel) {
|
|
1908
|
+
return generationError(
|
|
1909
|
+
`Error: image model "${choice.provider}/${choice.modelId}" could not be resolved in the registry.`,
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
let response: GenerateImagesResponseLike;
|
|
1914
|
+
try {
|
|
1915
|
+
response = await reg.generateImages(imageModel, { input: [{ type: "text", text: prompt }] }, { signal: ctx.signal });
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
return generationError(`Error: image generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1918
|
+
}
|
|
1919
|
+
if (response.stopReason === "aborted") return generationError("Error: generation was cancelled.");
|
|
1920
|
+
if (response.stopReason === "error") {
|
|
1921
|
+
return generationError(
|
|
1922
|
+
`Error: image generation failed: ${typeof response.errorMessage === "string" ? response.errorMessage : "unknown provider error"}`,
|
|
1923
|
+
);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const blocks = (response.output ?? []).filter(
|
|
1927
|
+
(b): b is { type: "image"; data: string; mimeType: string } =>
|
|
1928
|
+
b.type === "image" && typeof b.data === "string" && typeof b.mimeType === "string",
|
|
1929
|
+
);
|
|
1930
|
+
if (blocks.length === 0) {
|
|
1931
|
+
return generationError(
|
|
1932
|
+
"Error: the model returned no images (text-only response). Try a different prompt or model.",
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
// Persist recall data so analyze_image (and # recall, once a description
|
|
1937
|
+
// exists) work on generated images even when the base model sees the
|
|
1938
|
+
// pixels directly and the tool_result auto-description path never fires.
|
|
1939
|
+
const { imageMeta, imageData } = getSessionState(ctx);
|
|
1940
|
+
const structuredImages: Array<Record<string, unknown>> = [];
|
|
1941
|
+
for (const b of blocks) {
|
|
1942
|
+
const hash = hashImageData(b.data);
|
|
1943
|
+
const ext = b.mimeType.split("/")[1]?.replace(/[^a-z0-9]/gi, "") || "png";
|
|
1944
|
+
storeImageMeta(imageMeta, hash, b.data, `generated-${hash.slice(0, 8)}.${ext}`);
|
|
1945
|
+
storeImageData(imageData, hash, b.data, b.mimeType);
|
|
1946
|
+
const dims = imageMeta.get(hash);
|
|
1947
|
+
structuredImages.push({
|
|
1948
|
+
id: hash,
|
|
1949
|
+
mimeType: b.mimeType,
|
|
1950
|
+
...(dims ? { width: dims.width, height: dims.height } : {}),
|
|
1951
|
+
});
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
const costUSD = typeof response.usage?.costUSD === "number" ? response.usage.costUSD : undefined;
|
|
1955
|
+
const ids = structuredImages.map((i) => `image="${i.id}"`).join(", ");
|
|
1956
|
+
const text =
|
|
1957
|
+
`[generate_image] ${pluralImages(blocks.length)} generated via ${choice.provider}/${choice.modelId}` +
|
|
1958
|
+
(ids ? ` (${ids})` : "") +
|
|
1959
|
+
(costUSD !== undefined ? ` — cost $${costUSD.toFixed(4)}` : "") +
|
|
1960
|
+
". The images are attached as image blocks; their ids can be re-analyzed or cropped with analyze_image.";
|
|
1961
|
+
|
|
1962
|
+
return {
|
|
1963
|
+
content: [{ type: "text", text }, ...blocks],
|
|
1964
|
+
structured: {
|
|
1965
|
+
ok: true,
|
|
1966
|
+
provider: choice.provider,
|
|
1967
|
+
model: choice.modelId,
|
|
1968
|
+
images: structuredImages,
|
|
1969
|
+
...(costUSD !== undefined ? { costUSD } : {}),
|
|
1970
|
+
},
|
|
1971
|
+
...(response.usage ? { usage: response.usage } : {}),
|
|
1972
|
+
};
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1649
1975
|
/**
|
|
1650
1976
|
* Answer a targeted analyze_image question about one or more images: resolve
|
|
1651
1977
|
* references (session recall ids or file paths), validate and apply crops,
|
|
@@ -1709,7 +2035,12 @@ async function handleAnalyzeImage(
|
|
|
1709
2035
|
// Verify model exists and supports images
|
|
1710
2036
|
const visionModel = ctx.modelRegistry.find(visionProvider, visionModelId);
|
|
1711
2037
|
if (!visionModel) {
|
|
1712
|
-
|
|
2038
|
+
const suggestions = registryVisionSuggestions(ctx, `${visionProvider}/${visionModelId}`);
|
|
2039
|
+
return analysisError(
|
|
2040
|
+
`Error: model "${visionProvider}/${visionModelId}" not found in registry.` +
|
|
2041
|
+
(suggestions.length > 0 ? ` Close matches: ${suggestions.join(", ")}.` : "") +
|
|
2042
|
+
" Use /multimodal-proxy pick to choose a vision model.",
|
|
2043
|
+
);
|
|
1713
2044
|
}
|
|
1714
2045
|
if (!visionModel.input.includes("image")) {
|
|
1715
2046
|
return analysisError(`Error: model "${visionModel.name ?? visionModelId}" does not support image input.`);
|
|
@@ -1731,7 +2062,12 @@ async function handleAnalyzeImage(
|
|
|
1731
2062
|
if (recallHash) {
|
|
1732
2063
|
const stored = getImageData(imageData, recallHash);
|
|
1733
2064
|
if (!stored) {
|
|
1734
|
-
|
|
2065
|
+
const suggestions = recallSuggestions(imageData, imageMeta, recallHash);
|
|
2066
|
+
return analysisError(
|
|
2067
|
+
`Error: image "${recallHash}" is not available for recall — it may have expired from the session cache or was never analyzed.` +
|
|
2068
|
+
(suggestions.length > 0 ? ` Close ids still retained: ${suggestions.join(", ")}.` : "") +
|
|
2069
|
+
" Ask the user to re-attach it, or pass a file path.",
|
|
2070
|
+
);
|
|
1735
2071
|
}
|
|
1736
2072
|
const image: PiAiImage = { type: "image", data: stored.data, mimeType: stored.mimeType };
|
|
1737
2073
|
// Backfill dimensions if metadata was evicted, so crops still work on recall.
|
|
@@ -2000,6 +2336,7 @@ interface EditorAutocompleteProvider {
|
|
|
2000
2336
|
|
|
2001
2337
|
export default function (pi: ExtensionAPI) {
|
|
2002
2338
|
let _toolRegistered = false;
|
|
2339
|
+
let _generateToolRegistered = false;
|
|
2003
2340
|
let _autocompleteRegistered = false;
|
|
2004
2341
|
|
|
2005
2342
|
/**
|
|
@@ -2235,7 +2572,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2235
2572
|
});
|
|
2236
2573
|
|
|
2237
2574
|
/** Register or unregister the analyze_image tool based on config. */
|
|
2238
|
-
function syncToolRegistration(config: VisionConfig) {
|
|
2575
|
+
function syncToolRegistration(config: VisionConfig, runtimeCtx?: ExtensionContext) {
|
|
2239
2576
|
const shouldHaveTool = config.mode !== "off" && config.tool === "on";
|
|
2240
2577
|
if (shouldHaveTool && !_toolRegistered) {
|
|
2241
2578
|
pi.registerTool({
|
|
@@ -2298,6 +2635,82 @@ export default function (pi: ExtensionAPI) {
|
|
|
2298
2635
|
});
|
|
2299
2636
|
_toolRegistered = true;
|
|
2300
2637
|
}
|
|
2638
|
+
// generate_image (Pi ≥ 1.0 only): registered alongside analyze_image
|
|
2639
|
+
// when the runtime exposes ModelRegistry.generateImages; silently
|
|
2640
|
+
// skipped on older hosts so the tool never appears there.
|
|
2641
|
+
if (
|
|
2642
|
+
shouldHaveTool &&
|
|
2643
|
+
!_generateToolRegistered &&
|
|
2644
|
+
runtimeCtx &&
|
|
2645
|
+
typeof (runtimeCtx.modelRegistry as ModelRegistry & { generateImages?: unknown }).generateImages ===
|
|
2646
|
+
"function"
|
|
2647
|
+
) {
|
|
2648
|
+
pi.registerTool({
|
|
2649
|
+
name: "generate_image",
|
|
2650
|
+
label: "Generate Image",
|
|
2651
|
+
description: GENERATE_IMAGE_DESCRIPTION,
|
|
2652
|
+
promptSnippet: "Generate images with the session's image-generation models",
|
|
2653
|
+
promptGuidelines: [
|
|
2654
|
+
"Use generate_image when the user explicitly asks to create an image; each call costs money.",
|
|
2655
|
+
'Generated images carry image="<id>" ids and can be re-analyzed or cropped with analyze_image like any other image.',
|
|
2656
|
+
],
|
|
2657
|
+
// Cost-incurring content creation: not read-only. openWorldHint — the
|
|
2658
|
+
// prompt leaves for the configured image provider.
|
|
2659
|
+
annotations: {
|
|
2660
|
+
readOnlyHint: false,
|
|
2661
|
+
destructiveHint: false,
|
|
2662
|
+
idempotentHint: false,
|
|
2663
|
+
openWorldHint: true,
|
|
2664
|
+
},
|
|
2665
|
+
namespace: {
|
|
2666
|
+
name: "multimodal-proxy",
|
|
2667
|
+
description: "pi-multimodal-proxy media analysis (targeted image re-analysis with crop and grounding)",
|
|
2668
|
+
},
|
|
2669
|
+
outputSchema: GenerateImageOutput,
|
|
2670
|
+
parameters: GenerateImageParams,
|
|
2671
|
+
execute: async (_toolCallId, params, _signal, _onUpdate, extCtx) => {
|
|
2672
|
+
const entries = extCtx.sessionManager.getEntries();
|
|
2673
|
+
const config = await withModelFallback(resolveConfig(entries, process.env, _fileConfig), extCtx);
|
|
2674
|
+
|
|
2675
|
+
// Runtime check - tool may have been disabled mid-session
|
|
2676
|
+
if (config.tool !== "on" || config.mode === "off") {
|
|
2677
|
+
return {
|
|
2678
|
+
content: [
|
|
2679
|
+
{
|
|
2680
|
+
type: "text" as const,
|
|
2681
|
+
text: "Error: generate_image tool is currently disabled. Use /multimodal-proxy tool on to enable.",
|
|
2682
|
+
},
|
|
2683
|
+
],
|
|
2684
|
+
isError: true,
|
|
2685
|
+
};
|
|
2686
|
+
}
|
|
2687
|
+
|
|
2688
|
+
// Share the per-turn cost-runaway budget with analyze_image.
|
|
2689
|
+
const state = getSessionState(extCtx);
|
|
2690
|
+
state.toolCallCount++;
|
|
2691
|
+
if (state.toolCallCount > MAX_TOOL_CALLS_PER_TURN) {
|
|
2692
|
+
return {
|
|
2693
|
+
content: [
|
|
2694
|
+
{
|
|
2695
|
+
type: "text" as const,
|
|
2696
|
+
text: `Error: tool call limit reached (${MAX_TOOL_CALLS_PER_TURN} per turn). Try again in the next turn.`,
|
|
2697
|
+
},
|
|
2698
|
+
],
|
|
2699
|
+
isError: true,
|
|
2700
|
+
};
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
const outcome = await handleGenerateImage(params, extCtx, config);
|
|
2704
|
+
return {
|
|
2705
|
+
content: outcome.content,
|
|
2706
|
+
...(outcome.isError ? { isError: true } : {}),
|
|
2707
|
+
...(outcome.structured ? { structuredContent: outcome.structured } : {}),
|
|
2708
|
+
...(outcome.usage !== undefined ? { usage: outcome.usage } : {}),
|
|
2709
|
+
};
|
|
2710
|
+
},
|
|
2711
|
+
});
|
|
2712
|
+
_generateToolRegistered = true;
|
|
2713
|
+
}
|
|
2301
2714
|
// Note: Pi's extension API doesn't have unregisterTool - tool registration
|
|
2302
2715
|
// persists for the session. The tool's execute handler checks the current
|
|
2303
2716
|
// config at runtime and returns an error if disabled.
|
|
@@ -2313,6 +2726,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
2313
2726
|
clearImageData(state.imageData);
|
|
2314
2727
|
state.compaction = undefined;
|
|
2315
2728
|
state.keyProbe = undefined;
|
|
2729
|
+
state.dynamicVisionProbe = undefined;
|
|
2730
|
+
state.imageGenModels = undefined;
|
|
2316
2731
|
state.notifiedDefaultSub = undefined;
|
|
2317
2732
|
|
|
2318
2733
|
_fileConfig = await readPersistentFile();
|
|
@@ -2322,7 +2737,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2322
2737
|
ctx.ui.setStatus("multimodal-proxy", steadyStatusText(config, ctx.modelRegistry));
|
|
2323
2738
|
|
|
2324
2739
|
// Register tool if enabled
|
|
2325
|
-
syncToolRegistration(config);
|
|
2740
|
+
syncToolRegistration(config, ctx);
|
|
2326
2741
|
|
|
2327
2742
|
// Register the multimodal-proxy/auto virtual model (pi ≥ 0.99)
|
|
2328
2743
|
syncVirtualModelRegistration(config);
|
|
@@ -3214,7 +3629,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3214
3629
|
next.mode === "off" ? "warning" : "info",
|
|
3215
3630
|
);
|
|
3216
3631
|
// Sync tool registration on mode change
|
|
3217
|
-
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
|
|
3632
|
+
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
|
|
3218
3633
|
return;
|
|
3219
3634
|
}
|
|
3220
3635
|
|
|
@@ -3626,7 +4041,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
3626
4041
|
}
|
|
3627
4042
|
if (valueLower === "on") {
|
|
3628
4043
|
const next = writePersisted({ ...persisted, tool: "on" });
|
|
3629
|
-
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
|
|
4044
|
+
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
|
|
3630
4045
|
ctx.ui.notify(`[multimodal-proxy] analyze_image tool: ON`, "info");
|
|
3631
4046
|
return;
|
|
3632
4047
|
}
|
|
@@ -4367,7 +4782,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
4367
4782
|
if (modeChoice !== "fallback" && modeChoice !== "always" && modeChoice !== "off") return;
|
|
4368
4783
|
const next = writePersisted({ ...persisted, mode: modeChoice });
|
|
4369
4784
|
ctx.ui.notify(`Mode set to: ${next.mode}`, "info");
|
|
4370
|
-
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
|
|
4785
|
+
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
|
|
4371
4786
|
return;
|
|
4372
4787
|
}
|
|
4373
4788
|
|
|
@@ -4396,7 +4811,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
|
|
|
4396
4811
|
}
|
|
4397
4812
|
const nextTool = effective.tool === "on" ? "off" : "on";
|
|
4398
4813
|
writePersisted({ ...persisted, tool: nextTool });
|
|
4399
|
-
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
|
|
4814
|
+
syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
|
|
4400
4815
|
ctx.ui.notify(`Tool: ${nextTool}`, nextTool === "on" ? "info" : "warning");
|
|
4401
4816
|
return;
|
|
4402
4817
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-multimodal-proxy",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.20.0",
|
|
4
4
|
"description": "Automatic image, video and audio description for any model in Pi. Routes media to a multimodal model and injects descriptions into context.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|