@wuyaos/pi-model-config 0.1.4 → 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
@@ -127,7 +127,7 @@ Chat 写入 Pi 标准 `models.json`。Classifier 和 image 通过 Pi 的原生 m
127
127
  - `xai-images`(xAI Images;默认 `https://api.x.ai/v1`;当前将 base64 结果标为 `image/jpeg`,待真实服务商响应验证)
128
128
  - `dashscope-images`(DashScope 同步 multimodal-generation;默认 `https://dashscope.aliyuncs.com/api/v1`)
129
129
 
130
- 这四个新增协议由本插件直接实现请求级 `generateImages()`,**不经过宿主图像管线**。模型级 Base URL 优先,其次服务商 Base URL,最后才采用上列协议默认值;`openrouter-images` 与未知/自定义协议没有默认 URL,必须由用户明确填写。图像响应不计算 token/cost usage(保持为空)。DashScope 固定使用同步 `/services/aigc/multimodal-generation/generation`,解析 `output.choices[0].message.content[].image`(URL 字符串或 `{ url }`);不发送 `X-DashScope-Async`。返回 URL 会经同源重定向、内网地址、30 秒和 10 MiB 限制后下载。
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`。
131
131
 
132
132
  支持的 chat API:
133
133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wuyaos/pi-model-config",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Pi provider 连接、远程发现与 chat/classifier/image 模型配置 TUI",
6
6
  "keywords": [
@@ -155,16 +155,16 @@ function timedSignal(signal: AbortSignal | undefined, timeoutMs: number): { sign
155
155
  const abort = () => controller.abort(signal?.reason);
156
156
  if (signal?.aborted) abort();
157
157
  else signal?.addEventListener("abort", abort, { once: true });
158
- const timer = setTimeout(() => controller.abort(new Error("DashScope image download timed out")), timeoutMs);
158
+ const timer = setTimeout(() => controller.abort(new Error("Image download timed out")), timeoutMs);
159
159
  return { signal: controller.signal, dispose: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); } };
160
160
  }
161
161
 
162
162
  async function readLimitedBody(response: Response, limit: number): Promise<Uint8Array> {
163
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`);
164
+ if (Number.isFinite(contentLength) && contentLength > limit) throw new Error(`Image download exceeds ${limit} byte limit`);
165
165
  if (!response.body) {
166
166
  const bytes = new Uint8Array(await response.arrayBuffer());
167
- if (bytes.byteLength > limit) throw new Error(`DashScope image exceeds ${limit} byte limit`);
167
+ if (bytes.byteLength > limit) throw new Error(`Image download exceeds ${limit} byte limit`);
168
168
  return bytes;
169
169
  }
170
170
  const reader = response.body.getReader();
@@ -175,7 +175,7 @@ async function readLimitedBody(response: Response, limit: number): Promise<Uint8
175
175
  const next = await reader.read();
176
176
  if (next.done) break;
177
177
  size += next.value.byteLength;
178
- if (size > limit) { await reader.cancel(); throw new Error(`DashScope image exceeds ${limit} byte limit`); }
178
+ if (size > limit) { await reader.cancel(); throw new Error(`Image download exceeds ${limit} byte limit`); }
179
179
  chunks.push(next.value);
180
180
  }
181
181
  } finally {
@@ -187,32 +187,46 @@ async function readLimitedBody(response: Response, limit: number): Promise<Uint8
187
187
  return bytes;
188
188
  }
189
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 }> {
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 }> {
192
205
  const initial = validatePublicImageUrl(urlValue);
193
- const origin = initial.origin;
194
- const timed = timedSignal(signal, 30_000);
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;
195
209
  try {
196
210
  let current = initial;
197
211
  for (let redirects = 0; redirects <= 5; redirects++) {
198
212
  const response = await fetcher(current, { method: "GET", redirect: "manual", signal: timed.signal });
199
213
  if (redirectStatus(response.status)) {
200
- if (redirects === 5) throw new Error("DashScope image redirect limit exceeded");
214
+ if (redirects === 5) throw new Error("Image download redirect limit exceeded");
201
215
  const location = response.headers.get("location");
202
- if (!location) throw new Error(`DashScope image redirect ${response.status} lacks 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.
203
218
  const next = validatePublicImageUrl(new URL(location, current).toString());
204
- if (next.origin !== origin) throw new Error("DashScope image redirect changed origin");
205
219
  await response.body?.cancel();
206
220
  current = next;
207
221
  continue;
208
222
  }
209
- if (!response.ok) throw new Error(`DashScope image download failed: HTTP ${response.status}`);
210
- const bytes = await readLimitedBody(response, 10 * 1024 * 1024);
223
+ if (!response.ok) throw new Error(`Image download failed: HTTP ${response.status}`);
224
+ const bytes = await readLimitedBody(response, sizeLimit);
211
225
  const contentType = response.headers.get("content-type")?.split(";", 1)[0]?.trim();
212
- const mimeType = contentType?.startsWith("image/") ? contentType : "image/png";
226
+ const mimeType = contentType?.startsWith("image/") ? contentType : sniffImageMime(bytes) ?? "image/jpeg";
213
227
  return { mimeType, data: dataUrl(mimeType, Buffer.from(bytes).toString("base64")) };
214
228
  }
215
- throw new Error("DashScope image redirect limit exceeded");
229
+ throw new Error("Image download redirect limit exceeded");
216
230
  } finally {
217
231
  timed.dispose();
218
232
  }
@@ -1,5 +1,5 @@
1
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";
2
+ import { apiKey, downloadImageUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
3
 
4
4
  /** DashScope multimodal-generation synchronous endpoint. Deliberately does not set X-DashScope-Async. */
5
5
  export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
@@ -31,7 +31,7 @@ export async function generateImages(model: ImageModel<string>, context: ImagesC
31
31
  ? String((imageField as Record<string, unknown>).url)
32
32
  : undefined;
33
33
  if (!url) continue;
34
- const image = await downloadDashScopeImage(url, fetcher, options?.signal);
34
+ const image = await downloadImageUrl(url, { fetch: fetcher, signal: options?.signal });
35
35
  result.output.push({ type: "image", mimeType: image.mimeType, data: image.data });
36
36
  }
37
37
  if (!result.output.length) throw new Error("DashScope Images returned no downloadable image URLs");
@@ -1,5 +1,5 @@
1
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";
2
+ import { apiKey, dataUrl, downloadImageUrl, failedImageResult, fetchFor, imageResult, normalizeBaseUrl, promptFromContext, records, requestHeaders, resolveImageBaseUrl, responseJson } from "./common.ts";
3
3
 
4
4
  /** OpenAI Images API; this is a small request-local adapter, not Pi's host image pipeline. */
5
5
  export async function generateImages(model: ImageModel<string>, context: ImagesContext, options?: ImagesOptions, activeSecrets: readonly string[] = []): Promise<AssistantImages> {
@@ -20,11 +20,19 @@ export async function generateImages(model: ImageModel<string>, context: ImagesC
20
20
  });
21
21
  const json = await responseJson(response, options, activeSecrets) as Record<string, unknown>;
22
22
  const result = imageResult(model);
23
+ const fetcher = fetchFor(options);
23
24
  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" });
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
+ }
26
34
  }
27
- if (!result.output.length) throw new Error("OpenAI Images returned no base64 image data");
35
+ if (!result.output.length) throw new Error("OpenAI Images returned no usable b64_json or url image data");
28
36
  return result;
29
37
  } catch (error) {
30
38
  return failedImageResult(model, error, options, activeSecrets);