@bacnh85/pi-web 0.10.0 → 0.10.2
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/CHANGELOG.md +23 -0
- package/extensions/index.ts +3 -1
- package/extensions/lib/imageapi.ts +21 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.2 (2026-09-13)
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- `.gif` downloads inline as `image/gif` (was mislabeled `image/png`).
|
|
8
|
+
- **SSRF guard on gateway-supplied image URLs**: downloads to
|
|
9
|
+
loopback/private/link-local hosts (e.g. cloud metadata 169.254.169.254)
|
|
10
|
+
are refused before any request is made — the URL is surfaced instead.
|
|
11
|
+
- **25 MB download cap**: oversized image downloads are not written to disk;
|
|
12
|
+
the URL is surfaced instead.
|
|
13
|
+
- pi-hub catalog/README description updated to match 0.10.x features.
|
|
14
|
+
|
|
15
|
+
## 0.10.1 (2026-09-13)
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **Gemini image-refusal session skip** — after 2 consecutive "replied with
|
|
20
|
+
text but no images" refusals (observed on accounts where the chat path
|
|
21
|
+
refuses image generation), `provider: auto` skips gemini for the rest of
|
|
22
|
+
the session and goes straight to `zai`/`custom`; pinned
|
|
23
|
+
`provider=gemini` always retries, and any gemini success resets the
|
|
24
|
+
counter. Session-scoped (resets with pi).
|
|
25
|
+
|
|
3
26
|
## 0.10.0 (2026-09-13)
|
|
4
27
|
|
|
5
28
|
### Added
|
package/extensions/index.ts
CHANGED
|
@@ -87,13 +87,15 @@ const engineSchema = {
|
|
|
87
87
|
|
|
88
88
|
// Saved image file → inline image block (the 0.6.2 vision-loop lesson: the
|
|
89
89
|
// generating model should see its own output).
|
|
90
|
-
async function toImageBlock(file: string): Promise<{ type: "image"; data: string; mimeType: string }> {
|
|
90
|
+
export async function toImageBlock(file: string): Promise<{ type: "image"; data: string; mimeType: string }> {
|
|
91
91
|
const data = (await fs.promises.readFile(file)).toString("base64");
|
|
92
92
|
const lower = file.toLowerCase();
|
|
93
93
|
const mimeType = lower.endsWith(".jpg") || lower.endsWith(".jpeg")
|
|
94
94
|
? "image/jpeg"
|
|
95
95
|
: lower.endsWith(".webp")
|
|
96
96
|
? "image/webp"
|
|
97
|
+
: lower.endsWith(".gif")
|
|
98
|
+
? "image/gif"
|
|
97
99
|
: "image/png";
|
|
98
100
|
return { type: "image" as const, data, mimeType };
|
|
99
101
|
}
|
|
@@ -6,6 +6,7 @@ import fs from "node:fs";
|
|
|
6
6
|
import path from "node:path";
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import { findEnvValue } from "./config";
|
|
9
|
+
import { isLocalUrl } from "./chrome";
|
|
9
10
|
import {
|
|
10
11
|
describeGeminiError,
|
|
11
12
|
geminiGenerateImage,
|
|
@@ -82,6 +83,14 @@ interface RateState {
|
|
|
82
83
|
const rate = new Map<string, RateState>();
|
|
83
84
|
let nowMs = () => Date.now();
|
|
84
85
|
|
|
86
|
+
// Gemini chat-path image refusals ("replied with text but no images") are
|
|
87
|
+
// sticky on some accounts — the chat intent classifier refuses while the
|
|
88
|
+
// dedicated /images surface would route fine (hypothesis; wire-unverified).
|
|
89
|
+
// After 2 consecutive refusals, skip gemini in AUTO chains for the session.
|
|
90
|
+
// Pinned provider=gemini always attempts; a success resets the counter.
|
|
91
|
+
const GEMINI_REFUSAL_SKIP_THRESHOLD = 2;
|
|
92
|
+
let geminiRefusals = 0;
|
|
93
|
+
|
|
85
94
|
/** @internal test hooks */
|
|
86
95
|
export function __setImageRateClock(fn: () => number): void {
|
|
87
96
|
nowMs = fn;
|
|
@@ -90,6 +99,7 @@ export function __setImageRateClock(fn: () => number): void {
|
|
|
90
99
|
/** @internal test hooks */
|
|
91
100
|
export function __resetImageRate(): void {
|
|
92
101
|
rate.clear();
|
|
102
|
+
geminiRefusals = 0;
|
|
93
103
|
nowMs = () => Date.now();
|
|
94
104
|
}
|
|
95
105
|
|
|
@@ -160,6 +170,9 @@ export interface ApiImageResult {
|
|
|
160
170
|
model?: string;
|
|
161
171
|
}
|
|
162
172
|
|
|
173
|
+
/** Hard cap on downloaded image size — gateways can point at arbitrary URLs. */
|
|
174
|
+
export const MAX_DOWNLOAD_BYTES = 25 * 1024 * 1024;
|
|
175
|
+
|
|
163
176
|
export class ImageApiError extends Error {
|
|
164
177
|
constructor(
|
|
165
178
|
public status: number,
|
|
@@ -222,6 +235,7 @@ export async function apiGenerateImage(opts: {
|
|
|
222
235
|
} else if (typeof item?.url === "string" && item.url) {
|
|
223
236
|
// A failed download must not waste the generation: surface the URL.
|
|
224
237
|
try {
|
|
238
|
+
if (isLocalUrl(item.url)) throw new Error("image host is private/loopback (SSRF-guarded)");
|
|
225
239
|
paths.push(await downloadImage(fetchImpl, item.url, opts.outDir, i, opts.signal, opts.timeoutMs));
|
|
226
240
|
} catch (err) {
|
|
227
241
|
urls.push(item.url);
|
|
@@ -256,6 +270,7 @@ async function downloadImage(
|
|
|
256
270
|
});
|
|
257
271
|
if (!res.ok) throw new ImageApiError(res.status, `image download failed (HTTP ${res.status})`);
|
|
258
272
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
273
|
+
if (buf.length > MAX_DOWNLOAD_BYTES) throw new Error(`image exceeds the ${MAX_DOWNLOAD_BYTES}-byte download cap`);
|
|
259
274
|
const file = path.join(outDir, `pi-web-image-${randomUUID().slice(0, 8)}-${i}${extFor(url)}`);
|
|
260
275
|
fs.writeFileSync(file, buf);
|
|
261
276
|
return file;
|
|
@@ -315,6 +330,10 @@ export async function generateImageWithFallback(params: ImageChainParams): Promi
|
|
|
315
330
|
abortErr.name = "AbortError";
|
|
316
331
|
throw abortErr;
|
|
317
332
|
}
|
|
333
|
+
if (provider === "gemini" && params.provider === "auto" && geminiRefusals >= GEMINI_REFUSAL_SKIP_THRESHOLD) {
|
|
334
|
+
attempts.push(`gemini: skipped — refused image generation ${geminiRefusals}× consecutively this session (pin provider=gemini to retry)`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
318
337
|
const configured =
|
|
319
338
|
provider === "gemini" ? true : provider === "zai" ? Boolean(params.apiConfig.zai) : Boolean(params.apiConfig.custom);
|
|
320
339
|
if (!configured) {
|
|
@@ -363,6 +382,7 @@ export async function generateImageWithFallback(params: ImageChainParams): Promi
|
|
|
363
382
|
});
|
|
364
383
|
}
|
|
365
384
|
imageRateRecord(provider);
|
|
385
|
+
if (provider === "gemini") geminiRefusals = 0;
|
|
366
386
|
if (provider === "gemini" && params.n && params.n > 1 && result.paths.length < params.n) {
|
|
367
387
|
attempts.push(`gemini: n=${params.n} requested — the gemini web tier returns its own image count (${result.paths.length}); n applies to zai/custom`);
|
|
368
388
|
}
|
|
@@ -379,6 +399,7 @@ export async function generateImageWithFallback(params: ImageChainParams): Promi
|
|
|
379
399
|
abortErr.name = "AbortError";
|
|
380
400
|
throw abortErr;
|
|
381
401
|
}
|
|
402
|
+
if (provider === "gemini" && err instanceof Error && /no images/.test(err.message)) geminiRefusals++;
|
|
382
403
|
// A foreign AbortError-named error (not from the caller's signal) is a
|
|
383
404
|
// provider failure like any other — record it and keep the chain going.
|
|
384
405
|
attempts.push(`${provider}: ${provider === "gemini" ? describeGeminiError(err) : describeImageApiError(err)}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-web",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, Crawl4AI headless browser crawling, Gemini web-tier research, free upstream image generation, and one-off gateway chat.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|