@wuyaos/pi-model-config 0.1.2 → 0.1.4

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/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,219 @@
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("DashScope 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(`DashScope image exceeds ${limit} byte limit`);
165
+ if (!response.body) {
166
+ const bytes = new Uint8Array(await response.arrayBuffer());
167
+ if (bytes.byteLength > limit) throw new Error(`DashScope image 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(`DashScope image 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
+ /** Downloads only public HTTP(S) URLs, preserves origin across at most five manual redirects, and sends no API headers. */
191
+ export async function downloadDashScopeImage(urlValue: string, fetcher: FetchFunction, signal?: AbortSignal): Promise<{ mimeType: string; data: string }> {
192
+ const initial = validatePublicImageUrl(urlValue);
193
+ const origin = initial.origin;
194
+ const timed = timedSignal(signal, 30_000);
195
+ try {
196
+ let current = initial;
197
+ for (let redirects = 0; redirects <= 5; redirects++) {
198
+ const response = await fetcher(current, { method: "GET", redirect: "manual", signal: timed.signal });
199
+ if (redirectStatus(response.status)) {
200
+ if (redirects === 5) throw new Error("DashScope image redirect limit exceeded");
201
+ const location = response.headers.get("location");
202
+ if (!location) throw new Error(`DashScope image redirect ${response.status} lacks Location`);
203
+ const next = validatePublicImageUrl(new URL(location, current).toString());
204
+ if (next.origin !== origin) throw new Error("DashScope image redirect changed origin");
205
+ await response.body?.cancel();
206
+ current = next;
207
+ continue;
208
+ }
209
+ if (!response.ok) throw new Error(`DashScope image download failed: HTTP ${response.status}`);
210
+ const bytes = await readLimitedBody(response, 10 * 1024 * 1024);
211
+ const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
212
+ const mimeType = contentType?.startsWith("image/") ? contentType : "image/png";
213
+ return { mimeType, data: dataUrl(mimeType, Buffer.from(bytes).toString("base64")) };
214
+ }
215
+ throw new Error("DashScope image redirect limit exceeded");
216
+ } finally {
217
+ timed.dispose();
218
+ }
219
+ }
@@ -0,0 +1,42 @@
1
+ import type { AssistantImages, ImageModel, ImagesContext, ImagesOptions } from "@earendil-works/pi-ai";
2
+ import { apiKey, downloadDashScopeImage, 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 downloadDashScopeImage(url, fetcher, 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,32 @@
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
+ /** 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
+ for (const item of records(json.data)) {
24
+ const encoded = typeof item.b64_json === "string" ? item.b64_json : undefined;
25
+ if (encoded) result.output.push({ type: "image", data: dataUrl("image/png", encoded), mimeType: "image/png" });
26
+ }
27
+ if (!result.output.length) throw new Error("OpenAI Images returned no base64 image data");
28
+ return result;
29
+ } catch (error) {
30
+ return failedImageResult(model, error, options, activeSecrets);
31
+ }
32
+ }
@@ -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));