@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 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
@@ -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.0",
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",