@pi-unipi/image 2.2.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/src/index.ts ADDED
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @pi-unipi/image — Extension entry
3
+ *
4
+ * Provides the `image_generate` and `image_recognize` agent tools plus the
5
+ * `/unipi:image-settings` command.
6
+ */
7
+
8
+ import { dirname } from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
+ import {
12
+ IMAGE_COMMANDS,
13
+ IMAGE_TOOLS,
14
+ MODULES,
15
+ UNIPI_EVENTS,
16
+ UNIPI_PREFIX,
17
+ emitEvent,
18
+ getPackageVersion,
19
+ } from "@pi-unipi/core";
20
+
21
+ import { registerImageCommands } from "./commands.js";
22
+ import { registerImageTools } from "./tools.js";
23
+ import { listImageGenModels, listVisionModels, type ChatModelRegistry } from "./models.js";
24
+ import { loadConfig } from "./settings.js";
25
+
26
+ const VERSION = getPackageVersion(dirname(fileURLToPath(import.meta.url)));
27
+
28
+ /** Info-screen registry, read off the global to avoid load-order coupling. */
29
+ function getInfoRegistry() {
30
+ return (
31
+ globalThis as {
32
+ __unipi_info_registry?: {
33
+ registerGroup(group: unknown): void;
34
+ };
35
+ }
36
+ ).__unipi_info_registry;
37
+ }
38
+
39
+ export default function (pi: ExtensionAPI) {
40
+ registerImageTools(pi);
41
+ registerImageCommands(pi);
42
+
43
+ pi.on("session_start", async (_event, ctx) => {
44
+ const config = loadConfig();
45
+
46
+ const tools: string[] = [];
47
+ if (config.generate.enabled) tools.push(IMAGE_TOOLS.GENERATE);
48
+ if (config.recognize.enabled) tools.push(IMAGE_TOOLS.RECOGNIZE);
49
+
50
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
51
+ name: MODULES.IMAGE,
52
+ version: VERSION,
53
+ commands: [`${UNIPI_PREFIX}${IMAGE_COMMANDS.SETTINGS}`],
54
+ tools,
55
+ });
56
+
57
+ const registry = getInfoRegistry();
58
+ if (!registry) return;
59
+
60
+ registry.registerGroup({
61
+ id: "image",
62
+ name: "Image",
63
+ icon: "🎨",
64
+ priority: 55,
65
+ config: {
66
+ showByDefault: true,
67
+ stats: [
68
+ { id: "generate", label: "Generate", show: true },
69
+ { id: "recognize", label: "Recognize", show: true },
70
+ { id: "visionModels", label: "Vision Models", show: true },
71
+ ],
72
+ },
73
+ dataProvider: async () => {
74
+ const current = loadConfig();
75
+
76
+ const genModels = await listImageGenModels();
77
+ const generate = current.generate.enabled
78
+ ? genModels.length > 0
79
+ ? current.generate.model
80
+ : "No image models available"
81
+ : "Disabled";
82
+
83
+ const chatRegistry = (ctx as unknown as { modelRegistry?: ChatModelRegistry })
84
+ .modelRegistry;
85
+ const vision = chatRegistry ? listVisionModels(chatRegistry) : [];
86
+
87
+ const recognize = current.recognize.enabled
88
+ ? current.recognize.model || "Session model"
89
+ : "Disabled";
90
+
91
+ return {
92
+ generate: { value: generate },
93
+ recognize: { value: recognize },
94
+ visionModels: { value: String(vision.length) },
95
+ };
96
+ },
97
+ });
98
+ });
99
+ }
package/src/models.ts ADDED
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @pi-unipi/image — Model discovery and resolution
3
+ *
4
+ * Two different model families are involved:
5
+ *
6
+ * - **Image generation** uses pi-ai's `ImagesModel` catalog, which
7
+ * pi-coding-agent does not expose on `ExtensionContext` — it is reached
8
+ * through the pi-ai subpath exports.
9
+ * - **Image recognition** uses ordinary chat models filtered to those whose
10
+ * `input` modality includes `"image"` (`ctx.modelRegistry`).
11
+ */
12
+
13
+ /** A generation model, kept structural to avoid deep pi-ai type coupling. */
14
+ export interface ImageGenModel {
15
+ id: string;
16
+ name?: string;
17
+ provider: string;
18
+ api: string;
19
+ baseUrl?: string;
20
+ input?: string[];
21
+ output?: string[];
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ /** A vision-capable chat model. */
26
+ export interface VisionModel {
27
+ id: string;
28
+ name?: string;
29
+ provider: string;
30
+ input?: string[];
31
+ }
32
+
33
+ /** Minimal chat-model registry surface (pi's ModelRegistry). */
34
+ export interface ChatModelRegistry {
35
+ find(provider: string, modelId: string): unknown;
36
+ getAll(): unknown[];
37
+ getAvailable?(): unknown[];
38
+ getApiKeyForProvider?(provider: string): Promise<string | undefined>;
39
+ }
40
+
41
+ /** Split "provider/model-id" — the model id may itself contain slashes. */
42
+ export function splitModelRef(ref: string): { provider: string; id: string } | null {
43
+ const trimmed = ref.trim();
44
+ const slash = trimmed.indexOf("/");
45
+ if (slash <= 0 || slash === trimmed.length - 1) return null;
46
+ return { provider: trimmed.slice(0, slash), id: trimmed.slice(slash + 1) };
47
+ }
48
+
49
+ /** Format a model as "provider/model-id". */
50
+ export function formatModelRef(model: { provider: string; id: string }): string {
51
+ return `${model.provider}/${model.id}`;
52
+ }
53
+
54
+ /**
55
+ * pi-ai's runtime image-model collection: model catalog, auth resolution and
56
+ * generation in one object. Only the parts used here are typed.
57
+ */
58
+ export interface ImagesModelsLike {
59
+ getModels(provider?: string): readonly ImageGenModel[];
60
+ getModel(provider: string, id: string): ImageGenModel | undefined;
61
+ getAuth(model: ImageGenModel): Promise<{ apiKey?: string } | undefined>;
62
+ generateImages(
63
+ model: ImageGenModel,
64
+ context: { input: Array<{ type: string; text?: string }> },
65
+ options?: { apiKey?: string; signal?: AbortSignal },
66
+ ): Promise<unknown>;
67
+ }
68
+
69
+ let cachedImagesModels: ImagesModelsLike | null = null;
70
+ let imagesModelsAttempted = false;
71
+
72
+ /**
73
+ * Load pi-ai's built-in images collection.
74
+ *
75
+ * `getImageModels`/`generateImages` are not re-exported from the pi-ai package
76
+ * root, but `providers/all` exports `builtinImagesModels()`, which is the
77
+ * supported entry point and also resolves auth. A failure is not fatal: it
78
+ * degrades to null and callers report that no models are available.
79
+ */
80
+ export async function getImagesModels(): Promise<ImagesModelsLike | null> {
81
+ if (cachedImagesModels || imagesModelsAttempted) return cachedImagesModels;
82
+ imagesModelsAttempted = true;
83
+
84
+ try {
85
+ const mod = (await import("@earendil-works/pi-ai/providers/all")) as unknown as {
86
+ builtinImagesModels?: () => ImagesModelsLike;
87
+ };
88
+ if (typeof mod.builtinImagesModels === "function") {
89
+ cachedImagesModels = mod.builtinImagesModels();
90
+ }
91
+ } catch {
92
+ cachedImagesModels = null;
93
+ }
94
+
95
+ return cachedImagesModels;
96
+ }
97
+
98
+ /** List available image-generation models. Empty when unavailable. */
99
+ export async function listImageGenModels(): Promise<ImageGenModel[]> {
100
+ const images = await getImagesModels();
101
+ if (!images) return [];
102
+ try {
103
+ return [...images.getModels()];
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ /** Inject a stub images collection. Test-only. */
110
+ export function __setImagesModelsForTests(models: ImagesModelsLike | null): void {
111
+ cachedImagesModels = models;
112
+ imagesModelsAttempted = models !== null;
113
+ }
114
+
115
+ /** Reset the model cache. Test-only. */
116
+ export function __resetModelCacheForTests(): void {
117
+ cachedImagesModels = null;
118
+ imagesModelsAttempted = false;
119
+ }
120
+
121
+ /**
122
+ * Resolve a generation-model reference against the catalog.
123
+ *
124
+ * Exact "provider/id" first, then a scored fuzzy match so "flux" or
125
+ * "gemini-3-pro" work. Returns an error string (not a throw) so the tool can
126
+ * surface it as a normal tool error listing the alternatives.
127
+ */
128
+ export function resolveImageGenModel(
129
+ input: string,
130
+ models: ImageGenModel[],
131
+ ): ImageGenModel | string {
132
+ const query = input.trim().toLowerCase();
133
+ if (!query) return "No image model specified.";
134
+ if (models.length === 0) {
135
+ return (
136
+ "No image generation models are available.\n" +
137
+ "→ Image generation requires an OpenRouter account: https://openrouter.ai/keys"
138
+ );
139
+ }
140
+
141
+ // 1. Exact "provider/id"
142
+ const exact = models.find((m) => formatModelRef(m).toLowerCase() === query);
143
+ if (exact) return exact;
144
+
145
+ // 2. Exact id, ignoring the provider
146
+ const byId = models.find((m) => m.id.toLowerCase() === query);
147
+ if (byId) return byId;
148
+
149
+ // 3. Fuzzy
150
+ let best: ImageGenModel | undefined;
151
+ let bestScore = 0;
152
+
153
+ for (const model of models) {
154
+ const id = model.id.toLowerCase();
155
+ const full = formatModelRef(model).toLowerCase();
156
+ const name = (model.name ?? model.id).toLowerCase();
157
+
158
+ let score = 0;
159
+ if (id.includes(query) || full.includes(query)) {
160
+ score = 60 + (query.length / id.length) * 30;
161
+ } else if (name.includes(query)) {
162
+ score = 40 + (query.length / name.length) * 20;
163
+ }
164
+
165
+ if (score > bestScore) {
166
+ bestScore = score;
167
+ best = model;
168
+ }
169
+ }
170
+
171
+ if (best && bestScore > 0) return best;
172
+
173
+ const sample = models.slice(0, 10).map((m) => ` ${formatModelRef(m)}`).join("\n");
174
+ return (
175
+ `Unknown image model "${input}".\n` +
176
+ `Available models (${models.length} total):\n${sample}` +
177
+ (models.length > 10 ? "\n …run /unipi:image-settings to browse all" : "")
178
+ );
179
+ }
180
+
181
+ /**
182
+ * List vision-capable chat models — those accepting image input.
183
+ *
184
+ * `Model.input` is `("text" | "image")[]` in pi-ai. Models that do not declare
185
+ * the field are excluded rather than assumed capable, so a bad guess never
186
+ * produces a confusing API error.
187
+ */
188
+ export function listVisionModels(registry: ChatModelRegistry): VisionModel[] {
189
+ let models: unknown[];
190
+ try {
191
+ models = registry.getAvailable?.() ?? registry.getAll();
192
+ } catch {
193
+ return [];
194
+ }
195
+
196
+ return models.filter(isVisionModel);
197
+ }
198
+
199
+ function isVisionModel(model: unknown): model is VisionModel {
200
+ if (model === null || typeof model !== "object") return false;
201
+ const candidate = model as Partial<VisionModel>;
202
+ if (typeof candidate.id !== "string" || typeof candidate.provider !== "string") {
203
+ return false;
204
+ }
205
+ return Array.isArray(candidate.input) && candidate.input.includes("image");
206
+ }
207
+
208
+ /**
209
+ * Resolve a vision-model reference, restricted to image-capable models.
210
+ *
211
+ * Rejecting a text-only model here gives a much clearer message than letting
212
+ * the provider fail on an unexpected image part.
213
+ */
214
+ export function resolveVisionModel(
215
+ input: string,
216
+ registry: ChatModelRegistry,
217
+ ): VisionModel | string {
218
+ const vision = listVisionModels(registry);
219
+
220
+ if (vision.length === 0) {
221
+ return (
222
+ "No vision-capable models are configured.\n" +
223
+ "→ image_recognize needs a model that accepts image input " +
224
+ "(e.g. anthropic/claude-sonnet, openai/gpt-5, google/gemini-3-pro).\n" +
225
+ "→ Configure one with /model or /unipi:image-settings."
226
+ );
227
+ }
228
+
229
+ const query = input.trim().toLowerCase();
230
+ if (!query) return "No model specified.";
231
+
232
+ const exact = vision.find((m) => formatModelRef(m).toLowerCase() === query);
233
+ if (exact) return exact;
234
+
235
+ const byId = vision.find((m) => m.id.toLowerCase() === query);
236
+ if (byId) return byId;
237
+
238
+ let best: VisionModel | undefined;
239
+ let bestScore = 0;
240
+
241
+ for (const model of vision) {
242
+ const id = model.id.toLowerCase();
243
+ const full = formatModelRef(model).toLowerCase();
244
+ const name = (model.name ?? model.id).toLowerCase();
245
+
246
+ let score = 0;
247
+ if (id.includes(query) || full.includes(query)) {
248
+ score = 60 + (query.length / id.length) * 30;
249
+ } else if (name.includes(query)) {
250
+ score = 40 + (query.length / name.length) * 20;
251
+ }
252
+
253
+ if (score > bestScore) {
254
+ bestScore = score;
255
+ best = model;
256
+ }
257
+ }
258
+
259
+ if (best && bestScore > 0) return best;
260
+
261
+ // A known model that simply cannot see gets a targeted message.
262
+ let all: unknown[] = [];
263
+ try {
264
+ all = registry.getAvailable?.() ?? registry.getAll();
265
+ } catch {
266
+ all = [];
267
+ }
268
+ const knownButBlind = all.some((m) => {
269
+ const candidate = m as Partial<VisionModel>;
270
+ if (typeof candidate.id !== "string" || typeof candidate.provider !== "string") {
271
+ return false;
272
+ }
273
+ return (
274
+ formatModelRef(candidate as VisionModel).toLowerCase() === query ||
275
+ candidate.id.toLowerCase() === query
276
+ );
277
+ });
278
+
279
+ if (knownButBlind) {
280
+ return `Model "${input}" does not accept image input. Vision-capable models: ${vision
281
+ .slice(0, 5)
282
+ .map(formatModelRef)
283
+ .join(", ")}`;
284
+ }
285
+
286
+ return (
287
+ `Unknown model "${input}".\n` +
288
+ `Vision-capable models: ${vision.map(formatModelRef).join(", ")}`
289
+ );
290
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * @pi-unipi/image — Image recognition
3
+ *
4
+ * Sends an image plus a question to a vision-capable chat model. Providers
5
+ * differ in how image parts are encoded, so the request is built per API
6
+ * family (mirroring `packages/notify/summarize.ts`).
7
+ */
8
+
9
+ import type { LoadedImage } from "./image-source.js";
10
+
11
+ /** How long to wait for a vision response. Images are slow. */
12
+ const DEFAULT_TIMEOUT_MS = 120_000;
13
+
14
+ /** Cap the reply so a verbose model cannot flood the context. */
15
+ const DEFAULT_MAX_TOKENS = 2048;
16
+
17
+ export interface RecognizeOptions {
18
+ image: LoadedImage;
19
+ /** The question to ask about the image. */
20
+ prompt: string;
21
+ /** System prompt steering the analysis. */
22
+ systemPrompt: string;
23
+ apiKey: string;
24
+ baseUrl: string;
25
+ /** pi-ai `Model.api`, e.g. "anthropic-messages". */
26
+ api: string;
27
+ modelId: string;
28
+ signal?: AbortSignal;
29
+ timeoutMs?: number;
30
+ maxTokens?: number;
31
+ /** Injectable fetch, for tests. */
32
+ fetchImpl?: typeof fetch;
33
+ }
34
+
35
+ export interface RecognizeResult {
36
+ text: string;
37
+ model: string;
38
+ }
39
+
40
+ /** Combine an external abort signal with an internal timeout. */
41
+ function createSignal(
42
+ timeoutMs: number,
43
+ external?: AbortSignal,
44
+ ): { signal: AbortSignal; cleanup: () => void } {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
47
+
48
+ const onAbort = () => controller.abort();
49
+ external?.addEventListener("abort", onAbort, { once: true });
50
+
51
+ return {
52
+ signal: controller.signal,
53
+ cleanup: () => {
54
+ clearTimeout(timer);
55
+ external?.removeEventListener("abort", onAbort);
56
+ },
57
+ };
58
+ }
59
+
60
+ /** Extract the useful part of a provider error body. */
61
+ async function describeHttpError(response: Response): Promise<string> {
62
+ let detail = "";
63
+ try {
64
+ const body = await response.text();
65
+ try {
66
+ const parsed = JSON.parse(body) as { error?: { message?: string } | string };
67
+ detail =
68
+ typeof parsed.error === "string"
69
+ ? parsed.error
70
+ : parsed.error?.message ?? body.slice(0, 300);
71
+ } catch {
72
+ detail = body.slice(0, 300);
73
+ }
74
+ } catch {
75
+ // Body unavailable — status alone will have to do.
76
+ }
77
+
78
+ const base = `Vision request failed: ${response.status} ${response.statusText}`;
79
+ return detail ? `${base}\n${detail}` : base;
80
+ }
81
+
82
+ /** Anthropic Messages API — image parts use a nested `source` object. */
83
+ async function callAnthropic(options: RecognizeOptions): Promise<string> {
84
+ const {
85
+ image, prompt, systemPrompt, apiKey, baseUrl, modelId,
86
+ timeoutMs = DEFAULT_TIMEOUT_MS, maxTokens = DEFAULT_MAX_TOKENS,
87
+ signal: external, fetchImpl = fetch,
88
+ } = options;
89
+
90
+ const { signal, cleanup } = createSignal(timeoutMs, external);
91
+
92
+ try {
93
+ const response = await fetchImpl(`${baseUrl.replace(/\/$/, "")}/messages`, {
94
+ method: "POST",
95
+ headers: {
96
+ "x-api-key": apiKey,
97
+ "anthropic-version": "2023-06-01",
98
+ "Content-Type": "application/json",
99
+ },
100
+ body: JSON.stringify({
101
+ model: modelId,
102
+ max_tokens: maxTokens,
103
+ system: systemPrompt,
104
+ messages: [
105
+ {
106
+ role: "user",
107
+ content: [
108
+ {
109
+ type: "image",
110
+ source: {
111
+ type: "base64",
112
+ media_type: image.mimeType,
113
+ data: image.data,
114
+ },
115
+ },
116
+ { type: "text", text: prompt },
117
+ ],
118
+ },
119
+ ],
120
+ }),
121
+ signal,
122
+ });
123
+
124
+ if (!response.ok) throw new Error(await describeHttpError(response));
125
+
126
+ const data = (await response.json()) as {
127
+ content?: Array<{ type?: string; text?: string }>;
128
+ };
129
+
130
+ return (data.content ?? [])
131
+ .filter((block) => block.type === "text" && typeof block.text === "string")
132
+ .map((block) => block.text as string)
133
+ .join("\n")
134
+ .trim();
135
+ } finally {
136
+ cleanup();
137
+ }
138
+ }
139
+
140
+ /** OpenAI-compatible chat completions — image parts use a data: URL. */
141
+ async function callOpenAICompatible(options: RecognizeOptions): Promise<string> {
142
+ const {
143
+ image, prompt, systemPrompt, apiKey, baseUrl, modelId,
144
+ timeoutMs = DEFAULT_TIMEOUT_MS, maxTokens = DEFAULT_MAX_TOKENS,
145
+ signal: external, fetchImpl = fetch,
146
+ } = options;
147
+
148
+ const { signal, cleanup } = createSignal(timeoutMs, external);
149
+
150
+ try {
151
+ const response = await fetchImpl(
152
+ `${baseUrl.replace(/\/$/, "")}/chat/completions`,
153
+ {
154
+ method: "POST",
155
+ headers: {
156
+ Authorization: `Bearer ${apiKey}`,
157
+ "Content-Type": "application/json",
158
+ },
159
+ body: JSON.stringify({
160
+ model: modelId,
161
+ max_tokens: maxTokens,
162
+ messages: [
163
+ { role: "system", content: systemPrompt },
164
+ {
165
+ role: "user",
166
+ content: [
167
+ { type: "text", text: prompt },
168
+ {
169
+ type: "image_url",
170
+ image_url: {
171
+ url: `data:${image.mimeType};base64,${image.data}`,
172
+ },
173
+ },
174
+ ],
175
+ },
176
+ ],
177
+ }),
178
+ signal,
179
+ },
180
+ );
181
+
182
+ if (!response.ok) throw new Error(await describeHttpError(response));
183
+
184
+ const data = (await response.json()) as {
185
+ choices?: Array<{ message?: { content?: string | Array<{ text?: string }> } }>;
186
+ };
187
+
188
+ const content = data.choices?.[0]?.message?.content;
189
+ if (typeof content === "string") return content.trim();
190
+ if (Array.isArray(content)) {
191
+ return content.map((part) => part?.text ?? "").join("").trim();
192
+ }
193
+ return "";
194
+ } finally {
195
+ cleanup();
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Analyze an image with a vision model.
201
+ * @throws {Error} with an actionable message on failure.
202
+ */
203
+ export async function recognizeImage(
204
+ options: RecognizeOptions,
205
+ ): Promise<RecognizeResult> {
206
+ if (!options.apiKey) {
207
+ throw new Error(
208
+ "No API key available for the selected vision model.\n" +
209
+ "→ Sign in with /login, or set the provider's API key environment variable.",
210
+ );
211
+ }
212
+
213
+ const text =
214
+ options.api === "anthropic-messages"
215
+ ? await callAnthropic(options)
216
+ : await callOpenAICompatible(options);
217
+
218
+ if (!text) {
219
+ throw new Error("The model returned an empty response for this image.");
220
+ }
221
+
222
+ return { text, model: options.modelId };
223
+ }