@wuyaos/pi-model-config 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -122,6 +122,12 @@ Chat 写入 Pi 标准 `models.json`。Classifier 和 image 通过 Pi 的原生 m
122
122
  支持的 image API:
123
123
 
124
124
  - `openrouter-images`
125
+ - `openai-images`(OpenAI Images;默认 `https://api.openai.com/v1`)
126
+ - `gemini-images`(Gemini GenerateContent;默认 `https://generativelanguage.googleapis.com`)
127
+ - `xai-images`(xAI Images;默认 `https://api.x.ai/v1`;当前将 base64 结果标为 `image/jpeg`,待真实服务商响应验证)
128
+ - `dashscope-images`(DashScope 同步 multimodal-generation;默认 `https://dashscope.aliyuncs.com/api/v1`)
129
+
130
+ 这四个新增协议由本插件直接实现请求级 `generateImages()`,**不经过宿主图像管线**。模型级 Base URL 优先,其次服务商 Base URL,最后才采用上列协议默认值;`openrouter-images` 与未知/自定义协议没有默认 URL,必须由用户明确填写。图像响应不计算 token/cost usage(保持为空)。OpenAI 兼容网关的 `data[].url`(包括 `b64_json: null`)及 DashScope 的 `output.choices[0].message.content[].image`(URL 字符串或 `{ url }`)都会下载并转为 data URL;下载不携带认证头,逐跳重验私网/CGNAT/mapped-IPv6,允许公开对象存储跨源跳转,并有 30 秒和 10 MiB 限制。DashScope 不发送 `X-DashScope-Async`。
125
131
 
126
132
  支持的 chat API:
127
133
 
@@ -188,6 +194,7 @@ Chat 写入 Pi 标准 `models.json`。Classifier 和 image 通过 Pi 的原生 m
188
194
 
189
195
  - 行为调研参考了 @indexyz/pi-provider-sub2api(MIT)对 Codex manifest、slug/display_name 和保守失败策略的说明,以及 @router-for-me/pi-cliproxyapi-provider(MIT)公开的 endpoint 约定。
190
196
  - 同时查看了 @keywaytech/pi-provider-manager 的发布包行为;其 package 标记 UNLICENSED,因此本项目没有复制或改写其源码,只独立实现了协议 profile、认证头和安全读取逻辑。
197
+ - 图像协议行为还参考本机 `img-research` 中 `@amaster.ai/pi-image-gen` 发布包的 Apache-2.0 `dist/providers/{openai,gemini,dashscope}.js`,以及其依赖 `pi-image-gen-core/src/providers/xai.ts`;仅用于核实公开 endpoint、body 和响应形状,没有复制源码、宿主管线或 UI。
191
198
  - 本实现没有照搬上述包的缓存、client_version、名称正则推理、provider 注册或 UI 代码;尤其不会仅凭 CLIProxy 名称添加查询参数。
192
199
 
193
200
  ## 已知限制
@@ -199,3 +206,4 @@ Chat 写入 Pi 标准 `models.json`。Classifier 和 image 通过 Pi 的原生 m
199
206
  - 插件自有 provider 的“复用 Pi auth”仅处理同 ID 的 API-key credential;OAuth 只适用于未被插件接管、继续使用原生认证实现的 provider,不能移植到另一个自定义 provider ID。
200
207
  - 第三方 extension provider 若不由本插件接管,只能管理 chat overlay,不能启用本插件代理或匿名 transport。
201
208
  - 保存模型配置后需要 `/reload` 才能更新 extension transport 与 classifier/image 注册;仅切换界面语言不需要 reload。
209
+ - 图像 API 只覆盖文本提示词和各协议的最小一图请求;没有接入宿主图像管线、编辑/多图输入或 usage/cost 计费统计。
@@ -162,7 +162,13 @@
162
162
  "type": "string"
163
163
  },
164
164
  "imageApi": {
165
- "type": "string"
165
+ "enum": [
166
+ "openrouter-images",
167
+ "openai-images",
168
+ "gemini-images",
169
+ "xai-images",
170
+ "dashscope-images"
171
+ ]
166
172
  },
167
173
  "defaultType": {
168
174
  "enum": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wuyaos/pi-model-config",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Pi provider 连接、远程发现与 chat/classifier/image 模型配置 TUI",
6
6
  "keywords": [
package/src/command.ts CHANGED
@@ -24,6 +24,8 @@ import {
24
24
  } from "./panel.ts";
25
25
  import { candidateFromExternal, candidateFromInstalled, candidateFromManaged, mergeCandidates, selectModels, type ModelPanelState } from "./selector.ts";
26
26
  import { CHAT_APIS, CLASSIFIER_APIS, IMAGE_APIS, PROXY_CHAT_APIS } from "./transport.ts";
27
+ import { resolveImageBaseUrl } from "./image-apis/common.ts";
28
+ import { SELF_IMAGE_APIS } from "./image-apis/index.ts";
27
29
  import type {
28
30
  CandidateModel,
29
31
  ManagedModel,
@@ -102,6 +104,14 @@ function modelTypeLabel(language: UiLanguage, type: ManagedModel["type"]): strin
102
104
  : t(language, "selectorTypeChat");
103
105
  }
104
106
 
107
+ function imageApiLabel(language: UiLanguage, api: string): string {
108
+ const key = api === "openai-images" ? "imageApiOpenai"
109
+ : api === "gemini-images" ? "imageApiGemini"
110
+ : api === "xai-images" ? "imageApiXai"
111
+ : api === "dashscope-images" ? "imageApiDashscope" : "imageApiOpenrouter";
112
+ return `${t(language, key)} (${api})`;
113
+ }
114
+
105
115
  export function parseManagedChat(raw: Record<string, unknown>, provider: ModelsJsonProvider, config: ProviderConnectionConfig, applyDefaults = false): ManagedModel | undefined {
106
116
  if (typeof raw.id !== "string") return undefined;
107
117
  const api = typeof raw.api === "string" ? raw.api : (provider.api ?? config.chatApi ?? "openai-completions");
@@ -286,8 +296,10 @@ async function editConnectionPanel(ctx: ExtensionCommandContext, language: UiLan
286
296
  break;
287
297
  }
288
298
  case "imageApi": {
289
- const value = await ctx.ui.select("Image API", [...IMAGE_APIS]);
290
- if (value) draft.imageApi = value;
299
+ const choices = [...IMAGE_APIS].map((api) => ({ api, label: imageApiLabel(language, api) }));
300
+ const value = await ctx.ui.select("Image API", choices.map((choice) => choice.label));
301
+ const selected = choices.find((choice) => choice.label === value);
302
+ if (selected) draft.imageApi = selected.api;
291
303
  break;
292
304
  }
293
305
  }
@@ -326,10 +338,13 @@ function validateSelection(
326
338
  if (draft.proxyUrl) validateHttpUrl(draft.proxyUrl, "Provider proxy URL");
327
339
  if (draft.owned) {
328
340
  const missingExistingUrl = (existingProvider?.models ?? []).some((model) => typeof model.baseUrl !== "string" && !draft.baseUrl);
329
- const missingSelectedUrl = selected.some((model) => !model.baseUrl && !draft.baseUrl);
341
+ const effectiveModelUrl = (model: ManagedModel) => model.type === "image"
342
+ ? resolveImageBaseUrl(model.api, model.baseUrl, draft.baseUrl)
343
+ : model.baseUrl ?? draft.baseUrl;
344
+ const missingSelectedUrl = selected.some((model) => !effectiveModelUrl(model));
330
345
  if (missingExistingUrl || missingSelectedUrl) throw mcError("MCFG_E_BASE_URL_REQUIRED");
331
346
  for (const model of existingProvider?.models ?? []) validateHttpUrl(String(model.baseUrl ?? draft.baseUrl), `${draft.id}/${String(model.id)} Base URL`);
332
- for (const model of selected) validateHttpUrl(model.baseUrl ?? draft.baseUrl!, `${draft.id}/${model.id} Base URL`);
347
+ for (const model of selected) validateHttpUrl(effectiveModelUrl(model)!, `${draft.id}/${model.id} Base URL`);
333
348
  }
334
349
  if (!draft.owned && selected.some((model) => model.type !== "chat")) throw mcError("MCFG_E_NOT_OWNED", { provider: draft.id });
335
350
  if (!draft.owned && (draft.proxyUrl || draft.authMode === "anonymous") && !new Set<string>(getBuiltinProviders()).has(draft.id)) {
@@ -351,7 +366,8 @@ function validateSelection(
351
366
  const supported = model.type === "chat" ? CHAT_APIS : model.type === "classifier" ? CLASSIFIER_APIS : IMAGE_APIS;
352
367
  if (!supported.has(model.api)) throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${draft.id}/${model.type}/${model.id}`, api: model.api });
353
368
  if (draft.proxyUrl && model.type === "chat" && !PROXY_CHAT_APIS.has(model.api)) throw mcError("MCFG_E_PROXY_UNSUPPORTED", { identity: `${draft.id}/${model.type}/${model.id}` });
354
- if (draft.authMode === "anonymous" && ["pi-messages", "bedrock-converse-stream", "google-generative-ai", "google-vertex"].includes(model.api)) {
369
+ if (draft.authMode === "anonymous" && (["pi-messages", "bedrock-converse-stream", "google-generative-ai", "google-vertex"].includes(model.api)
370
+ || model.type === "image" && SELF_IMAGE_APIS.has(model.api))) {
355
371
  throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${draft.id}/${model.type}/${model.id}` });
356
372
  }
357
373
  }
package/src/config.ts CHANGED
@@ -21,6 +21,7 @@ import type {
21
21
  ModelsJsonProvider,
22
22
  ProviderConnectionConfig,
23
23
  } from "./types.ts";
24
+ import { IMAGE_API_NAMES } from "./types.ts";
24
25
 
25
26
  export const MODELS_SCHEMA = "https://pi.dev/schemas/models.schema.json";
26
27
  export const MODEL_CONFIG_SCHEMA = "https://raw.githubusercontent.com/wuyaos/pi-packages/main/pi-model-config/model-config.schema.json";
@@ -68,6 +69,9 @@ export function parseModelConfig(source: ConfigSnapshot): ModelConfigFile {
68
69
  if (provider.discoveryAuthHeader !== undefined && !new Set(["auto", "bearer", "x-api-key", "api-key", "x-goog-api-key", "anthropic", "none"]).has(String(provider.discoveryAuthHeader))) {
69
70
  throw mcError("MCFG_E_DISCOVERY_AUTH_HEADER", { file: "model-config.json", provider: id });
70
71
  }
72
+ if (provider.imageApi !== undefined && !IMAGE_API_NAMES.includes(String(provider.imageApi) as typeof IMAGE_API_NAMES[number])) {
73
+ throw mcError("MCFG_E_UNSUPPORTED_API", { identity: `${id}/image`, api: String(provider.imageApi) });
74
+ }
71
75
  if (provider.models !== undefined && !Array.isArray(provider.models)) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
72
76
  if (Array.isArray(provider.models) && provider.models.some((model) => !isRecord(model))) throw mcError("MCFG_E_MODELS_ARRAY", { file: "model-config.json", provider: id });
73
77
  for (const model of Array.isArray(provider.models) ? provider.models : []) {
package/src/i18n.ts CHANGED
@@ -191,6 +191,11 @@ const ZH = {
191
191
  panelDescChatApi: "聊天模型调用协议;API 名保持原文。",
192
192
  panelDescClassifierApi: "判定模型调用协议;API 名保持原文。",
193
193
  panelDescImageApi: "图像生成模型调用协议;API 名保持原文。",
194
+ imageApiOpenrouter: "OpenRouter 图像",
195
+ imageApiOpenai: "OpenAI 图像生成",
196
+ imageApiGemini: "Gemini 图像生成",
197
+ imageApiXai: "xAI 图像生成",
198
+ imageApiDashscope: "DashScope 图像生成",
194
199
  panelDescContext: "远程模型缺少 context window 时使用的默认值。",
195
200
  panelDescMaxTokens: "远程聊天模型缺少 max tokens 时使用的默认值。",
196
201
  panelModelName: "模型名称:{value}",
@@ -523,6 +528,11 @@ const EN = {
523
528
  panelDescChatApi: "Chat model protocol; API names remain unchanged.",
524
529
  panelDescClassifierApi: "Classification model protocol; API names remain unchanged.",
525
530
  panelDescImageApi: "Image generation protocol; API names remain unchanged.",
531
+ imageApiOpenrouter: "OpenRouter Images",
532
+ imageApiOpenai: "OpenAI Images",
533
+ imageApiGemini: "Gemini Images",
534
+ imageApiXai: "xAI Images",
535
+ imageApiDashscope: "DashScope Images",
526
536
  panelDescContext: "Default used when a remote model omits context window.",
527
537
  panelDescMaxTokens: "Default used when a remote chat model omits max tokens.",
528
538
  panelModelName: "Model name: {value}",
@@ -0,0 +1,233 @@
1
+ import type { AssistantImages, FetchFunction, ImageModel, ImagesContext, ImagesOptions, ProviderHeaders } from "@earendil-works/pi-ai";
2
+ import { sanitizeError, validateHttpUrl } from "../config.ts";
3
+
4
+ export const IMAGE_API_DEFAULT_BASE_URLS = {
5
+ "openai-images": "https://api.openai.com/v1",
6
+ "gemini-images": "https://generativelanguage.googleapis.com",
7
+ "xai-images": "https://api.x.ai/v1",
8
+ "dashscope-images": "https://dashscope.aliyuncs.com/api/v1",
9
+ } as const;
10
+
11
+ export type BuiltinImageApi = keyof typeof IMAGE_API_DEFAULT_BASE_URLS;
12
+
13
+ const ANONYMOUS_SENTINEL = "pi-model-config-anonymous";
14
+
15
+ export function imageDefaultBaseUrl(api: string): string | undefined {
16
+ return IMAGE_API_DEFAULT_BASE_URLS[api as BuiltinImageApi];
17
+ }
18
+
19
+ /** Model URLs override provider URLs; protocol defaults only apply to the four self-owned APIs. */
20
+ export function resolveImageBaseUrl(api: string, modelBaseUrl?: string, providerBaseUrl?: string): string | undefined {
21
+ return modelBaseUrl || providerBaseUrl || imageDefaultBaseUrl(api);
22
+ }
23
+
24
+ export function normalizeBaseUrl(value: string, requiredPath?: string): string {
25
+ const url = new URL(validateHttpUrl(value, "Image Base URL"));
26
+ url.search = "";
27
+ url.hash = "";
28
+ url.pathname = url.pathname.replace(/\/+$/u, "") || "/";
29
+ if (requiredPath && !url.pathname.endsWith(requiredPath)) {
30
+ url.pathname = `${url.pathname === "/" ? "" : url.pathname}${requiredPath}`;
31
+ }
32
+ return url.toString().replace(/\/$/u, "");
33
+ }
34
+
35
+ export function promptFromContext(context: ImagesContext): string {
36
+ return context.input.filter((item) => item.type === "text").map((item) => item.text).join("\n").trim();
37
+ }
38
+
39
+ function asHeaders(value: ProviderHeaders | undefined): Record<string, string | null | undefined> {
40
+ return { ...(value as Record<string, string | null | undefined> | undefined) };
41
+ }
42
+
43
+ function hasHeader(headers: Record<string, string | null | undefined>, name: string): boolean {
44
+ return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
45
+ }
46
+
47
+ export function requestHeaders(
48
+ model: ImageModel<string>,
49
+ options: ImagesOptions | undefined,
50
+ authentication: Record<string, string | undefined>,
51
+ ): Record<string, string> {
52
+ const merged = {
53
+ ...asHeaders(model.headers),
54
+ ...asHeaders(options?.headers),
55
+ };
56
+ for (const [name, value] of Object.entries(authentication)) {
57
+ if (value && !hasHeader(merged, name)) merged[name] = value;
58
+ }
59
+ if (!hasHeader(merged, "content-type")) merged["content-type"] = "application/json";
60
+ return Object.fromEntries(Object.entries(merged).filter((entry): entry is [string, string] => typeof entry[1] === "string"));
61
+ }
62
+
63
+ export function apiKey(options: ImagesOptions | undefined): string | undefined {
64
+ const key = options?.apiKey;
65
+ return key && key !== ANONYMOUS_SENTINEL ? key : undefined;
66
+ }
67
+
68
+ export function fetchFor(options: ImagesOptions | undefined): FetchFunction {
69
+ return options?.fetch ?? (globalThis.fetch as FetchFunction);
70
+ }
71
+
72
+ export function imageResult(model: ImageModel<string>): AssistantImages {
73
+ return {
74
+ api: model.api,
75
+ provider: model.provider,
76
+ model: model.id,
77
+ output: [],
78
+ stopReason: "stop",
79
+ timestamp: Date.now(),
80
+ };
81
+ }
82
+
83
+ export function activeSecrets(options: ImagesOptions | undefined, extra: readonly string[] = []): string[] {
84
+ const metadata = options?.metadata as { secrets?: unknown } | undefined;
85
+ const supplied = Array.isArray(metadata?.secrets) ? metadata.secrets.filter((value): value is string => typeof value === "string") : [];
86
+ return [...extra, ...supplied, options?.apiKey].filter((value): value is string => !!value && value !== ANONYMOUS_SENTINEL);
87
+ }
88
+
89
+ export function failedImageResult(
90
+ model: ImageModel<string>,
91
+ error: unknown,
92
+ options: ImagesOptions | undefined,
93
+ extraSecrets: readonly string[] = [],
94
+ ): AssistantImages {
95
+ const result = imageResult(model);
96
+ result.stopReason = options?.signal?.aborted ? "aborted" : "error";
97
+ result.errorMessage = sanitizeError(error, activeSecrets(options, extraSecrets));
98
+ return result;
99
+ }
100
+
101
+ export async function responseJson(response: Response, options?: ImagesOptions, extraSecrets: readonly string[] = []): Promise<unknown> {
102
+ if (!response.ok) {
103
+ let detail = "";
104
+ try { detail = (await response.text()).slice(0, 200); } catch { /* status remains useful without a readable body */ }
105
+ const safeDetail = detail ? `: ${sanitizeError(detail, activeSecrets(options, extraSecrets))}` : "";
106
+ throw new Error(`Image API request failed: HTTP ${response.status}${safeDetail}`);
107
+ }
108
+ try {
109
+ return await response.json();
110
+ } catch {
111
+ throw new Error("Image API returned invalid JSON");
112
+ }
113
+ }
114
+
115
+ export function records(value: unknown): Record<string, unknown>[] {
116
+ return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => !!item && typeof item === "object" && !Array.isArray(item)) : [];
117
+ }
118
+
119
+ export function dataUrl(mimeType: string, base64: string): string {
120
+ return `data:${mimeType};base64,${base64}`;
121
+ }
122
+
123
+ function privateHostname(hostname: string): boolean {
124
+ let normalized = hostname.toLowerCase().replace(/^\[|\]$/gu, "");
125
+ const mappedIpv4 = normalized.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/u);
126
+ const mappedHex = normalized.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/u);
127
+ if (mappedIpv4) normalized = mappedIpv4[1]!;
128
+ else if (mappedHex) {
129
+ const high = Number.parseInt(mappedHex[1]!, 16);
130
+ const low = Number.parseInt(mappedHex[2]!, 16);
131
+ normalized = `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}`;
132
+ }
133
+ if (normalized === "localhost" || normalized.endsWith(".localhost") || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1") return true;
134
+ const ipv4 = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/u)?.slice(1).map(Number);
135
+ if (ipv4 && ipv4.every((part) => part >= 0 && part <= 255)) {
136
+ const [a, b] = ipv4;
137
+ return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
138
+ }
139
+ return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:");
140
+ }
141
+
142
+ export function validatePublicImageUrl(value: string): URL {
143
+ const normalized = validateHttpUrl(value, "DashScope image URL");
144
+ const url = new URL(normalized);
145
+ if (privateHostname(url.hostname)) throw new Error("DashScope image URL targets a private network address");
146
+ return url;
147
+ }
148
+
149
+ function redirectStatus(status: number): boolean {
150
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
151
+ }
152
+
153
+ function timedSignal(signal: AbortSignal | undefined, timeoutMs: number): { signal: AbortSignal; dispose: () => void } {
154
+ const controller = new AbortController();
155
+ const abort = () => controller.abort(signal?.reason);
156
+ if (signal?.aborted) abort();
157
+ else signal?.addEventListener("abort", abort, { once: true });
158
+ const timer = setTimeout(() => controller.abort(new Error("Image download timed out")), timeoutMs);
159
+ return { signal: controller.signal, dispose: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); } };
160
+ }
161
+
162
+ async function readLimitedBody(response: Response, limit: number): Promise<Uint8Array> {
163
+ const contentLength = Number(response.headers.get("content-length"));
164
+ if (Number.isFinite(contentLength) && contentLength > limit) throw new Error(`Image download exceeds ${limit} byte limit`);
165
+ if (!response.body) {
166
+ const bytes = new Uint8Array(await response.arrayBuffer());
167
+ if (bytes.byteLength > limit) throw new Error(`Image download exceeds ${limit} byte limit`);
168
+ return bytes;
169
+ }
170
+ const reader = response.body.getReader();
171
+ const chunks: Uint8Array[] = [];
172
+ let size = 0;
173
+ try {
174
+ while (true) {
175
+ const next = await reader.read();
176
+ if (next.done) break;
177
+ size += next.value.byteLength;
178
+ if (size > limit) { await reader.cancel(); throw new Error(`Image download exceeds ${limit} byte limit`); }
179
+ chunks.push(next.value);
180
+ }
181
+ } finally {
182
+ reader.releaseLock();
183
+ }
184
+ const bytes = new Uint8Array(size);
185
+ let offset = 0;
186
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
187
+ return bytes;
188
+ }
189
+
190
+ function sniffImageMime(bytes: Uint8Array): string | undefined {
191
+ if (bytes[0] === 0xff && bytes[1] === 0xd8) return "image/jpeg";
192
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
193
+ return undefined;
194
+ }
195
+
196
+ export interface DownloadImageUrlOptions {
197
+ fetch?: FetchFunction;
198
+ signal?: AbortSignal;
199
+ timeoutMs?: number;
200
+ sizeLimit?: number;
201
+ }
202
+
203
+ /** Downloads a public image without credentials. Every manually followed redirect is revalidated for SSRF. */
204
+ export async function downloadImageUrl(urlValue: string, options: DownloadImageUrlOptions = {}): Promise<{ mimeType: string; data: string }> {
205
+ const initial = validatePublicImageUrl(urlValue);
206
+ const fetcher = options.fetch ?? (globalThis.fetch as FetchFunction);
207
+ const timed = timedSignal(options.signal, options.timeoutMs ?? 30_000);
208
+ const sizeLimit = options.sizeLimit ?? 10 * 1024 * 1024;
209
+ try {
210
+ let current = initial;
211
+ for (let redirects = 0; redirects <= 5; redirects++) {
212
+ const response = await fetcher(current, { method: "GET", redirect: "manual", signal: timed.signal });
213
+ if (redirectStatus(response.status)) {
214
+ if (redirects === 5) throw new Error("Image download redirect limit exceeded");
215
+ const location = response.headers.get("location");
216
+ if (!location) throw new Error(`Image download redirect ${response.status} lacks Location`);
217
+ // Signed-image CDNs may change public origins; each redirect must still pass URL and SSRF validation.
218
+ const next = validatePublicImageUrl(new URL(location, current).toString());
219
+ await response.body?.cancel();
220
+ current = next;
221
+ continue;
222
+ }
223
+ if (!response.ok) throw new Error(`Image download failed: HTTP ${response.status}`);
224
+ const bytes = await readLimitedBody(response, sizeLimit);
225
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
226
+ const mimeType = contentType?.startsWith("image/") ? contentType : sniffImageMime(bytes) ?? "image/jpeg";
227
+ return { mimeType, data: dataUrl(mimeType, Buffer.from(bytes).toString("base64")) };
228
+ }
229
+ throw new Error("Image download redirect limit exceeded");
230
+ } finally {
231
+ timed.dispose();
232
+ }
233
+ }
@@ -0,0 +1,42 @@
1
+ import type { AssistantImages, ImageModel, ImagesContext, ImagesOptions } from "@earendil-works/pi-ai";
2
+ import { apiKey, downloadImageUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
+
4
+ /** DashScope multimodal-generation synchronous endpoint. Deliberately does not set X-DashScope-Async. */
5
+ export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
6
+ try {
7
+ const baseUrl = resolveImageBaseUrl(model.api, model.baseUrl);
8
+ if (!baseUrl) throw new Error("DashScope Images requires a Base URL");
9
+ const key = apiKey(options);
10
+ if (!key) throw new Error("DashScope Images requires an API key");
11
+ const fetcher = fetchFor(options);
12
+ const response = await fetcher(`${normalizeBaseUrl(baseUrl, "/api/v1")}/services/aigc/multimodal-generation/generation`, {
13
+ method: "POST",
14
+ headers: requestHeaders(model, options, { authorization: `Bearer ${key}` }),
15
+ body: JSON.stringify({
16
+ model: model.id,
17
+ input: { messages: [{ role: "user", content: [{ text: promptFromContext(context) }] }] },
18
+ }),
19
+ signal: options?.signal,
20
+ });
21
+ const json = await responseJson(response, options, activeSecrets) as Record<string, unknown>;
22
+ const output = json.output as Record<string, unknown> | undefined;
23
+ const firstChoice = records(output?.choices)[0];
24
+ const message = firstChoice?.message as Record<string, unknown> | undefined;
25
+ const result = imageResult(model);
26
+ for (const part of records(message?.content)) {
27
+ const imageField = part.image;
28
+ const url = typeof imageField === "string"
29
+ ? imageField
30
+ : imageField && typeof imageField === "object" && !Array.isArray(imageField) && typeof (imageField as Record<string, unknown>).url === "string"
31
+ ? String((imageField as Record<string, unknown>).url)
32
+ : undefined;
33
+ if (!url) continue;
34
+ const image = await downloadImageUrl(url, { fetch: fetcher, signal: options?.signal });
35
+ result.output.push({ type: "image", mimeType: image.mimeType, data: image.data });
36
+ }
37
+ if (!result.output.length) throw new Error("DashScope Images returned no downloadable image URLs");
38
+ return result;
39
+ } catch (error) {
40
+ return failedImageResult(model, error, options, activeSecrets);
41
+ }
42
+ }
@@ -0,0 +1,36 @@
1
+ import type { AssistantImages, ImageModel, ImagesContext, ImagesOptions } from "@earendil-works/pi-ai";
2
+ import { apiKey, dataUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
+
4
+ export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
5
+ try {
6
+ const baseUrl = resolveImageBaseUrl(model.api, model.baseUrl);
7
+ if (!baseUrl) throw new Error("Gemini Images requires a Base URL");
8
+ const key = apiKey(options);
9
+ if (!key) throw new Error("Gemini Images requires an API key");
10
+ const response = await fetchFor(options)(`${normalizeBaseUrl(baseUrl, "/v1beta")}/models/${encodeURIComponent(model.id)}:generateContent`, {
11
+ method: "POST",
12
+ headers: requestHeaders(model, options, { "x-goog-api-key": key }),
13
+ body: JSON.stringify({
14
+ contents: [{ role: "user", parts: [{ text: promptFromContext(context) }] }],
15
+ generationConfig: { responseModalities: ["IMAGE"], imageConfig: { imageSize: "1K" } },
16
+ }),
17
+ signal: options?.signal,
18
+ });
19
+ const json = await responseJson(response, options, activeSecrets) as Record<string, unknown>;
20
+ const result = imageResult(model);
21
+ for (const candidate of records(json.candidates)) {
22
+ const content = candidate.content as Record<string, unknown> | undefined;
23
+ for (const part of records(content?.parts)) {
24
+ const inline = (part.inline_data ?? part.inlineData) as Record<string, unknown> | undefined;
25
+ const encoded = typeof inline?.data === "string" ? inline.data : undefined;
26
+ const mime = inline?.mime_type ?? inline?.mimeType;
27
+ const mimeType = typeof mime === "string" ? mime : "image/png";
28
+ if (encoded) result.output.push({ type: "image", data: dataUrl(mimeType, encoded), mimeType });
29
+ }
30
+ }
31
+ if (!result.output.length) throw new Error("Gemini Images returned no inline image data");
32
+ return result;
33
+ } catch (error) {
34
+ return failedImageResult(model, error, options, activeSecrets);
35
+ }
36
+ }
@@ -0,0 +1,15 @@
1
+ import type { ProviderImages } from "@earendil-works/pi-ai";
2
+ import { generateImages as generateDashscopeImages } from "./dashscope.ts";
3
+ import { generateImages as generateGeminiImages } from "./gemini.ts";
4
+ import { generateImages as generateOpenaiImages } from "./openai.ts";
5
+ import { generateImages as generateXaiImages } from "./xai.ts";
6
+
7
+ /** Extension-owned APIs absent from Pi 1.2.0's host image catalog. */
8
+ export const SELF_IMAGE_IMPLEMENTATIONS: Readonly<Record<string, ProviderImages>> = {
9
+ "openai-images": { generateImages: generateOpenaiImages },
10
+ "gemini-images": { generateImages: generateGeminiImages },
11
+ "xai-images": { generateImages: generateXaiImages },
12
+ "dashscope-images": { generateImages: generateDashscopeImages },
13
+ };
14
+
15
+ export const SELF_IMAGE_APIS: ReadonlySet<string> = new Set(Object.keys(SELF_IMAGE_IMPLEMENTATIONS));
@@ -0,0 +1,40 @@
1
+ import type { AssistantImages, ImageModel, ImagesContext, ImagesOptions } from "@earendil-works/pi-ai";
2
+ import { apiKey, dataUrl, downloadImageUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
+
4
+ /** OpenAI Images API; this is a small request-local adapter, not Pi's host image pipeline. */
5
+ export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
6
+ try {
7
+ const baseUrl = resolveImageBaseUrl(model.api, model.baseUrl);
8
+ if (!baseUrl) throw new Error("OpenAI Images requires a Base URL");
9
+ const key = apiKey(options);
10
+ if (!key) throw new Error("OpenAI Images requires an API key");
11
+ const isGptImage = model.id.toLowerCase().startsWith("gpt-image");
12
+ const body: Record<string, unknown> = { model: model.id, prompt: promptFromContext(context), n: 1 };
13
+ if (isGptImage) body.output_format = "png";
14
+ else body.response_format = "b64_json";
15
+ const response = await fetchFor(options)(`${normalizeBaseUrl(baseUrl, "/v1")}/images/generations`, {
16
+ method: "POST",
17
+ headers: requestHeaders(model, options, { authorization: `Bearer ${key}` }),
18
+ body: JSON.stringify(body),
19
+ signal: options?.signal,
20
+ });
21
+ const json = await responseJson(response, options, activeSecrets) as Record<string, unknown>;
22
+ const result = imageResult(model);
23
+ const fetcher = fetchFor(options);
24
+ for (const item of records(json.data)) {
25
+ const encoded = typeof item.b64_json === "string" && item.b64_json ? item.b64_json : undefined;
26
+ if (encoded) {
27
+ result.output.push({ type: "image", data: dataUrl("image/png", encoded), mimeType: "image/png" });
28
+ continue;
29
+ }
30
+ if (typeof item.url === "string" && item.url) {
31
+ const image = await downloadImageUrl(item.url, { fetch: fetcher, signal: options?.signal });
32
+ result.output.push({ type: "image", data: image.data, mimeType: image.mimeType });
33
+ }
34
+ }
35
+ if (!result.output.length) throw new Error("OpenAI Images returned no usable b64_json or url image data");
36
+ return result;
37
+ } catch (error) {
38
+ return failedImageResult(model, error, options, activeSecrets);
39
+ }
40
+ }
@@ -0,0 +1,27 @@
1
+ import type { AssistantImages, ImageModel, ImagesContext, ImagesOptions } from "@earendil-works/pi-ai";
2
+ import { apiKey, dataUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
+
4
+ export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
5
+ try {
6
+ const baseUrl = resolveImageBaseUrl(model.api, model.baseUrl);
7
+ if (!baseUrl) throw new Error("xAI Images requires a Base URL");
8
+ const key = apiKey(options);
9
+ if (!key) throw new Error("xAI Images requires an API key");
10
+ const response = await fetchFor(options)(`${normalizeBaseUrl(baseUrl, "/v1")}/images/generations`, {
11
+ method: "POST",
12
+ headers: requestHeaders(model, options, { authorization: `Bearer ${key}` }),
13
+ body: JSON.stringify({ model: model.id, prompt: promptFromContext(context), n: 1, response_format: "b64_json" }),
14
+ signal: options?.signal,
15
+ });
16
+ const json = await responseJson(response, options, activeSecrets) as Record<string, unknown>;
17
+ const result = imageResult(model);
18
+ for (const item of records(json.data)) {
19
+ const encoded = typeof item.b64_json === "string" ? item.b64_json : undefined;
20
+ if (encoded) result.output.push({ type: "image", data: dataUrl("image/jpeg", encoded), mimeType: "image/jpeg" });
21
+ }
22
+ if (!result.output.length) throw new Error("xAI Images returned no base64 image data");
23
+ return result;
24
+ } catch (error) {
25
+ return failedImageResult(model, error, options, activeSecrets);
26
+ }
27
+ }
package/src/runtime.ts CHANGED
@@ -13,6 +13,8 @@ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
  import type { ModelConfigFile, ModelsJsonFile, ModelsJsonProvider, ProviderConnectionConfig } from "./types.ts";
15
15
  import { validateHttpUrl } from "./config.ts";
16
+ import { resolveImageBaseUrl } from "./image-apis/common.ts";
17
+ import { SELF_IMAGE_APIS, SELF_IMAGE_IMPLEMENTATIONS } from "./image-apis/index.ts";
16
18
  import { mcError, t, type UiLanguage } from "./i18n.ts";
17
19
  import { ANONYMOUS_SENTINEL, applyProviderTransport, CHAT_APIS, CLASSIFIER_APIS, IMAGE_APIS, PROXY_CHAT_APIS, type TransportOptions } from "./transport.ts";
18
20
 
@@ -105,7 +107,9 @@ function wrapClassifier(api: string, config: ProviderConnectionConfig): Provider
105
107
  }
106
108
 
107
109
  function wrapImages(api: string, config: ProviderConnectionConfig): ProviderImages {
108
- const base = requiredImplementation(HOST_APIS.image, api, "image");
110
+ // Pi 1.2.0's ImageApi remains open to custom strings, while its host catalog only
111
+ // knows openrouter-images. The four entries below are extension-owned adapters.
112
+ const base = SELF_IMAGE_APIS.has(api) ? SELF_IMAGE_IMPLEMENTATIONS[api]! : requiredImplementation(HOST_APIS.image, api, "image");
109
113
  return {
110
114
  generateImages: (model, context, options) => base.generateImages(model, context, applyProviderTransport(options, {
111
115
  proxyUrl: config.proxyUrl,
@@ -174,7 +178,14 @@ function validateRuntimeConfig(providerId: string, config: ProviderConnectionCon
174
178
  const supported = model.type === "classifier" ? CLASSIFIER_APIS : model.type === "image" ? IMAGE_APIS : undefined;
175
179
  if (!supported) throw mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId });
176
180
  if (!supported.has(model.api)) throw mcError("MCFG_E_TYPE_API", { identity: `${providerId}/${model.type}/${model.id}`, api: model.api });
177
- if (config.owned) validateHttpUrl(model.baseUrl ?? modelsProvider?.baseUrl ?? "", `${providerId}/${model.id} Base URL`);
181
+ const effectiveBaseUrl = model.type === "image"
182
+ ? resolveImageBaseUrl(model.api, model.baseUrl, modelsProvider?.baseUrl)
183
+ : model.baseUrl ?? modelsProvider?.baseUrl;
184
+ if (config.owned && !effectiveBaseUrl) throw mcError("MCFG_E_BASE_URL_REQUIRED");
185
+ if (config.owned && effectiveBaseUrl) validateHttpUrl(effectiveBaseUrl, `${providerId}/${model.id} Base URL`);
186
+ if (config.authMode === "anonymous" && model.type === "image" && SELF_IMAGE_APIS.has(model.api)) {
187
+ throw mcError("MCFG_E_ANONYMOUS_UNSUPPORTED", { identity: `${providerId}/image/${model.id}` });
188
+ }
178
189
  if (config.owned && model.type === "classifier" && typeof model.contextWindow !== "number") throw mcError("MCFG_E_MODEL_SHAPE", { file: "model-config.json", provider: providerId });
179
190
  }
180
191
  if (!config.owned && (config.models?.length ?? 0) > 0) {
@@ -192,7 +203,13 @@ function validateRuntimeConfig(providerId: string, config: ProviderConnectionCon
192
203
 
193
204
  function registerOwnedProvider(pi: ExtensionAPI, providerId: string, config: ProviderConnectionConfig, modelsProvider: ModelsJsonProvider, language: UiLanguage): void {
194
205
  const chatModels = normalizeChatModels(providerId, modelsProvider, config);
195
- const extraModels = (config.models ?? []).map((model) => ({ ...model, provider: providerId, baseUrl: model.baseUrl ?? modelsProvider.baseUrl ?? "" }));
206
+ const extraModels = (config.models ?? []).map((model) => ({
207
+ ...model,
208
+ provider: providerId,
209
+ baseUrl: model.type === "image"
210
+ ? resolveImageBaseUrl(model.api, model.baseUrl, modelsProvider.baseUrl) ?? ""
211
+ : model.baseUrl ?? modelsProvider.baseUrl ?? "",
212
+ }));
196
213
  const allModels = [...chatModels, ...extraModels];
197
214
  if (allModels.length === 0) return;
198
215
  const chatApis = new Set(chatModels.map((model) => model.api));
package/src/transport.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ProxyAgent, fetch as undiciFetch } from "undici";
2
2
  import type { FetchFunction, ProviderEnv, ProviderHeaders, Transport } from "@earendil-works/pi-ai";
3
- import type { ProviderDraft } from "./types.ts";
3
+ import { IMAGE_API_NAMES, type ProviderDraft } from "./types.ts";
4
4
  import { validateHttpUrl } from "./config.ts";
5
5
  import { mcError } from "./i18n.ts";
6
6
 
@@ -26,7 +26,7 @@ export const CLASSIFIER_APIS = new Set([
26
26
  "cloudflare-workers-ai-system-one",
27
27
  "openai-decisions",
28
28
  ]);
29
- export const IMAGE_APIS = new Set(["openrouter-images"]);
29
+ export const IMAGE_APIS = new Set(IMAGE_API_NAMES);
30
30
 
31
31
  export function assertSupportedProtocol(draft: ProviderDraft, type: "chat" | "classifier" | "image"): void {
32
32
  const api = type === "chat" ? draft.chatApi : type === "classifier" ? draft.classifierApi : draft.imageApi;
@@ -87,6 +87,7 @@ export function applyProviderTransport<T extends TransportOptions>(
87
87
  ...next.headers,
88
88
  authorization: null,
89
89
  "x-api-key": null,
90
+ "x-goog-api-key": null,
90
91
  "cf-aig-authorization": null,
91
92
  };
92
93
  }
package/src/types.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { AnyModel } from "@earendil-works/pi-ai";
2
2
 
3
3
  export const MODEL_TYPES = ["chat", "classifier", "image"] as const;
4
+ export const IMAGE_API_NAMES = ["openrouter-images", "openai-images", "gemini-images", "xai-images", "dashscope-images"] as const;
4
5
  export type ManagedModelType = (typeof MODEL_TYPES)[number];
6
+ export type ImageApiName = (typeof IMAGE_API_NAMES)[number];
5
7
  export type ModelIdentity = `${string}\u0000${ManagedModelType}\u0000${string}`;
6
8
 
7
9
  export type AuthMode = "models-json" | "reuse-pi" | "anonymous";
@@ -64,7 +66,7 @@ export interface ProviderConnectionConfig {
64
66
  proxyUrl?: string;
65
67
  chatApi?: string;
66
68
  classifierApi?: string;
67
- imageApi?: string;
69
+ imageApi?: ImageApiName;
68
70
  defaultType?: ManagedModelType;
69
71
  defaultContextWindow?: number;
70
72
  defaultMaxTokens?: number;
@@ -111,7 +113,7 @@ export interface ProviderDraft {
111
113
  proxyUrl?: string;
112
114
  chatApi: string;
113
115
  classifierApi: string;
114
- imageApi: string;
116
+ imageApi: ImageApiName;
115
117
  defaultType: ManagedModelType;
116
118
  }
117
119