@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 +1 -1
- package/package.json +1 -1
- package/src/image-apis/common.ts +29 -15
- package/src/image-apis/dashscope.ts +2 -2
- package/src/image-apis/openai.ts +12 -4
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(保持为空)。
|
|
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
package/src/image-apis/common.ts
CHANGED
|
@@ -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("
|
|
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(`
|
|
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(`
|
|
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(`
|
|
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
|
-
|
|
191
|
-
|
|
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
|
|
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("
|
|
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(`
|
|
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(`
|
|
210
|
-
const bytes = await readLimitedBody(response,
|
|
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/
|
|
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("
|
|
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,
|
|
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
|
|
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");
|
package/src/image-apis/openai.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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);
|