pi-multimodal-proxy 1.19.1 → 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 CHANGED
@@ -4,6 +4,13 @@ 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
+
7
14
  ## [1.19.1] - 2026-10-02
8
15
 
9
16
  ### Changed
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.
@@ -81,6 +81,7 @@ import {
81
81
  selectVisionModels,
82
82
  closeMatches,
83
83
  pickDynamicVisionCandidate,
84
+ resolveImageGenModelChoice,
84
85
  splitSubcommand,
85
86
  stripImagePaths,
86
87
  stripMediaPaths,
@@ -436,6 +437,56 @@ describe("pickDynamicVisionCandidate", () => {
436
437
  });
437
438
  });
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
+
439
490
  describe("sanitizeProviderHeaders", () => {
440
491
  it("drops null deletion markers", () => {
441
492
  assert.deepEqual(
@@ -2435,6 +2435,50 @@ export function pickDynamicVisionCandidate<T extends DynamicCandidateLike>(model
2435
2435
  return vision.find((m) => CHEAP_MODEL_RE.test(m.modelId)) ?? vision[0];
2436
2436
  }
2437
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
+
2438
2482
  export function shouldStripImages(config: VisionConfig, modelInput: readonly string[] | undefined): boolean {
2439
2483
  if (config.mode === "off") return false;
2440
2484
  if (config.mode === "always") return true;
@@ -284,6 +284,7 @@ import {
284
284
  applyDefaultModelFallback,
285
285
  closeMatches,
286
286
  pickDynamicVisionCandidate,
287
+ resolveImageGenModelChoice,
287
288
  DEFAULT_MODEL_FALLBACKS,
288
289
  applyRecallCompletion,
289
290
  buildRecallItems,
@@ -469,6 +470,52 @@ const TOOL_DESCRIPTION = [
469
470
  /** Maximum analyze_image tool calls per agent turn. Prevents cost runaway. */
470
471
  const MAX_TOOL_CALLS_PER_TURN = 10;
471
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
+
472
519
  /** Default tool-result cache size; resized to config.cacheSize on first use. */
473
520
  const DEFAULT_TOOL_CACHE_SIZE = 50;
474
521
 
@@ -508,6 +555,8 @@ interface SessionState {
508
555
  keyProbe?: Map<string, boolean>;
509
556
  /** Cached dynamic (registry-keyed) vision candidate; null = none or runtime without the API (Pi ≥ 1.0). */
510
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
  /**
@@ -1741,6 +1790,188 @@ function recallSuggestions(imageData: ImageDataStore, imageMeta: ImageMetaStore,
1741
1790
  return closeMatches(query, candidates, limit);
1742
1791
  }
1743
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
+
1744
1975
  /**
1745
1976
  * Answer a targeted analyze_image question about one or more images: resolve
1746
1977
  * references (session recall ids or file paths), validate and apply crops,
@@ -2105,6 +2336,7 @@ interface EditorAutocompleteProvider {
2105
2336
 
2106
2337
  export default function (pi: ExtensionAPI) {
2107
2338
  let _toolRegistered = false;
2339
+ let _generateToolRegistered = false;
2108
2340
  let _autocompleteRegistered = false;
2109
2341
 
2110
2342
  /**
@@ -2340,7 +2572,7 @@ export default function (pi: ExtensionAPI) {
2340
2572
  });
2341
2573
 
2342
2574
  /** Register or unregister the analyze_image tool based on config. */
2343
- function syncToolRegistration(config: VisionConfig) {
2575
+ function syncToolRegistration(config: VisionConfig, runtimeCtx?: ExtensionContext) {
2344
2576
  const shouldHaveTool = config.mode !== "off" && config.tool === "on";
2345
2577
  if (shouldHaveTool && !_toolRegistered) {
2346
2578
  pi.registerTool({
@@ -2403,6 +2635,82 @@ export default function (pi: ExtensionAPI) {
2403
2635
  });
2404
2636
  _toolRegistered = true;
2405
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
+ }
2406
2714
  // Note: Pi's extension API doesn't have unregisterTool - tool registration
2407
2715
  // persists for the session. The tool's execute handler checks the current
2408
2716
  // config at runtime and returns an error if disabled.
@@ -2418,6 +2726,8 @@ export default function (pi: ExtensionAPI) {
2418
2726
  clearImageData(state.imageData);
2419
2727
  state.compaction = undefined;
2420
2728
  state.keyProbe = undefined;
2729
+ state.dynamicVisionProbe = undefined;
2730
+ state.imageGenModels = undefined;
2421
2731
  state.notifiedDefaultSub = undefined;
2422
2732
 
2423
2733
  _fileConfig = await readPersistentFile();
@@ -2427,7 +2737,7 @@ export default function (pi: ExtensionAPI) {
2427
2737
  ctx.ui.setStatus("multimodal-proxy", steadyStatusText(config, ctx.modelRegistry));
2428
2738
 
2429
2739
  // Register tool if enabled
2430
- syncToolRegistration(config);
2740
+ syncToolRegistration(config, ctx);
2431
2741
 
2432
2742
  // Register the multimodal-proxy/auto virtual model (pi ≥ 0.99)
2433
2743
  syncVirtualModelRegistration(config);
@@ -3319,7 +3629,7 @@ export default function (pi: ExtensionAPI) {
3319
3629
  next.mode === "off" ? "warning" : "info",
3320
3630
  );
3321
3631
  // Sync tool registration on mode change
3322
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
3632
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
3323
3633
  return;
3324
3634
  }
3325
3635
 
@@ -3731,7 +4041,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
3731
4041
  }
3732
4042
  if (valueLower === "on") {
3733
4043
  const next = writePersisted({ ...persisted, tool: "on" });
3734
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
4044
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
3735
4045
  ctx.ui.notify(`[multimodal-proxy] analyze_image tool: ON`, "info");
3736
4046
  return;
3737
4047
  }
@@ -4472,7 +4782,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
4472
4782
  if (modeChoice !== "fallback" && modeChoice !== "always" && modeChoice !== "off") return;
4473
4783
  const next = writePersisted({ ...persisted, mode: modeChoice });
4474
4784
  ctx.ui.notify(`Mode set to: ${next.mode}`, "info");
4475
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
4785
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
4476
4786
  return;
4477
4787
  }
4478
4788
 
@@ -4501,7 +4811,7 @@ Use "*" or "all" to grant consent for all providers globally.`,
4501
4811
  }
4502
4812
  const nextTool = effective.tool === "on" ? "off" : "on";
4503
4813
  writePersisted({ ...persisted, tool: nextTool });
4504
- syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig));
4814
+ syncToolRegistration(resolveConfig(ctx.sessionManager.getEntries(), process.env, _fileConfig), ctx);
4505
4815
  ctx.ui.notify(`Tool: ${nextTool}`, nextTool === "on" ? "info" : "warning");
4506
4816
  return;
4507
4817
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multimodal-proxy",
3
- "version": "1.19.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"