@bacnh85/pi-web 0.9.0 → 0.9.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 (2026-09-13)
4
+
5
+ ### Fixed
6
+
7
+ Review hardening of the `web_image` fallback chain (3 review rounds):
8
+
9
+ - **Cancellation semantics**: an aborted call now surfaces `AbortError`
10
+ immediately — before any provider client construction or fetch — instead of
11
+ walking the chain and reporting "all providers failed". An abort landing
12
+ mid-generation normalizes to `AbortError` with the in-flight provider error
13
+ preserved as `cause`; foreign abort-named errors from upstreams are recorded
14
+ as provider notes and the chain continues.
15
+ - **`n` transparency**: when the gemini web tier returns fewer images than the
16
+ requested `n`, the result states it explicitly (`n` applies to the
17
+ `zai`/`custom` API providers; the gemini web tier returns its own count).
18
+ `out_dir` now resolves against the session cwd, not the process cwd.
19
+
20
+ ## 0.9.1 (2026-09-13)
21
+
22
+ ### Fixed
23
+
24
+ - **`zai` default model is `glm-image`** (live-verified against
25
+ `api.z.ai/api/paas/v4/images/generations` — the endpoint rejects
26
+ `cogview-4`/`cogview-3-flash` with "Unknown Model"; GLM-Image returns
27
+ URL results).
28
+ - **Failed image downloads no longer waste the generation** — some upstreams
29
+ return URL-only results and the CDN can be unreachable from the local
30
+ network (e.g. `mfile.z.ai` DNS-sinkholed). The tool now reports
31
+ "Not saved (image host unreachable…)" with the URL instead of failing the
32
+ provider; inline image blocks are still attached for saved files.
33
+
3
34
  ## 0.9.0 (2026-09-13)
4
35
 
5
36
  ### Added
@@ -7,7 +38,7 @@
7
38
  - **`web_image` tool** — image generation from text via free upstream
8
39
  providers, all direct-to-upstream (no self-host services), with automatic
9
40
  fallback: `gemini` (gemini.google.com web tier via `gemini-reverse`, guest
10
- or cookie auth) → `zai` (official `api.z.ai` CogView-4 via `ZAI_API_KEY`)
41
+ or cookie auth) → `zai` (official `api.z.ai` GLM-Image via `ZAI_API_KEY`)
11
42
  → `custom` (any OpenAI-compatible `/images/generations` endpoint via
12
43
  `WEB_IMAGE_API_BASE_URL`). `provider: "auto"` walks the chain and the
13
44
  result reports every fallback attempt.
package/README.md CHANGED
@@ -32,7 +32,7 @@ Variables:
32
32
  | `CRAWL4AI_API_TOKEN` | No (3) | — | Required if Crawl4AI auth enabled |
33
33
  | `GEMINI_WEB_SECURE_1PSID` | No (4) | — | `__Secure-1PSID` cookie from gemini.google.com — enables authed `web_research` (Deep Research) |
34
34
  | `GEMINI_WEB_PROXY` | No | — | Proxy URL for Gemini web calls (escape hatch if Google blocks the IP) |
35
- | `ZAI_API_KEY` | No (5) | — | Z.ai API key — enables the `web_image` `zai` provider (CogView-4 via the official `api.z.ai`); `Z_AI_API_KEY` also accepted |
35
+ | `ZAI_API_KEY` | No (5) | — | Z.ai API key — enables the `web_image` `zai` provider (GLM-Image via the official `api.z.ai`); `Z_AI_API_KEY` also accepted |
36
36
  | `WEB_IMAGE_API_BASE_URL` | No | — | `web_image` `custom` provider: any OpenAI-compatible images endpoint (e.g. `https://api.openai.com/v1`) |
37
37
  | `WEB_IMAGE_API_KEY` | No | — | Bearer key for the `custom` endpoint |
38
38
  | `WEB_IMAGE_API_LABEL` | No | — | Display label for the `custom` endpoint (default: host name) |
@@ -273,8 +273,8 @@ self-host services):
273
273
 
274
274
  ```
275
275
  web_image(prompt="isometric cutaway of a container ship, technical illustration")
276
- web_image(prompt="...", provider="zai") # pin CogView-4 via api.z.ai
277
- web_image(prompt="...", model="cogview-4", n=2, out_dir="/tmp/imgs")
276
+ web_image(prompt="...", provider="zai") # pin GLM-Image via api.z.ai
277
+ web_image(prompt="...", model="glm-image", n=2, out_dir="/tmp/imgs")
278
278
  ```
279
279
 
280
280
  **Provider chain** (`provider: "auto"` tries in order; pin one to skip):
@@ -282,7 +282,7 @@ web_image(prompt="...", model="cogview-4", n=2, out_dir="/tmp/imgs")
282
282
  | Provider | Upstream | Auth | Notes |
283
283
  |---|---|---|---|
284
284
  | `gemini` (default) | gemini.google.com web tier | none (guest) or `GEMINI_WEB_SECURE_1PSID` | free ≈ 20 images/day; availability varies by region/account |
285
- | `zai` | `https://api.z.ai/api/paas/v4` (official API) | `ZAI_API_KEY` | CogView-4 (`model` default), fully ToS-compliant |
285
+ | `zai` | `https://api.z.ai/api/paas/v4` (official API) | `ZAI_API_KEY` | GLM-Image (`model` default), fully ToS-compliant |
286
286
  | `custom` | any OpenAI-compatible `/images/generations` endpoint | `WEB_IMAGE_API_KEY` | e.g. official OpenAI `https://api.openai.com/v1` |
287
287
 
288
288
  Results are saved to `out_dir` (default: fresh temp dir) and returned as file
@@ -290,6 +290,9 @@ paths **plus inline image blocks** (multimodal models see the render
290
290
  immediately). `details` reports the winning provider, model, and fallback
291
291
  attempts.
292
292
 
293
+ `n` (1–4) applies to the API providers (`zai`/`custom`); the Gemini web tier
294
+ returns its own image count (surfaced as a provider note when fewer than `n`).
295
+
293
296
  **Guardrails** (soft, in-memory): per-provider `WEB_IMAGE_MIN_INTERVAL_MS`
294
297
  (default 5 s) and a `WEB_IMAGE_DAILY_CAP` (default 20/day, applied to the
295
298
  Gemini web tier only — keyed APIs are billed upstream and stay uncapped).
@@ -108,7 +108,7 @@ const WEB_ROUTING_GUIDANCE = `## Web Tool Routing (pi-web)
108
108
  - **web_crawl** — multi-page crawl: \`mode: "light"\` (Firecrawl, url) or \`mode: "full"\` (Crawl4AI, urls[]).
109
109
  - **web_screenshot** / **web_pdf** — page capture (Crawl4AI).
110
110
  - **web_research** — AI-synthesized research via Gemini web (mode "ask" = grounded answer, guest OK; mode "research" = Deep Research report, needs cookie + Gemini Advanced, takes minutes).
111
- - **web_image** — text→image generation via free upstreams (auto: Gemini web → Z.ai CogView-4 → custom OpenAI-images endpoint; \`model\`/\`n\` params).
111
+ - **web_image** — text→image generation via free upstreams (auto: Gemini web → Z.ai GLM-Image → custom OpenAI-images endpoint; \`model\`/\`n\` params).
112
112
  - **web_status** — provider config + health.
113
113
 
114
114
  Rules: Firecrawl Search is weak on domain-specific queries — prefer SearXNG/Brave; Firecrawl Scrape fails on bot-protected sites — use Crawl4AI (\`mode: "full"\`) then agy (\`mode: "agy"\`); cite source URLs.`;
@@ -529,8 +529,8 @@ export default function piWebExtension(pi: ExtensionAPI) {
529
529
  name: "web_image",
530
530
  label: "Web Image Generation",
531
531
  description:
532
- "Generate images from text via free upstream providers, with fallback: Gemini web (gemini.google.com, guest or cookie auth), Z.ai official API (CogView-4 via ZAI_API_KEY), or any custom OpenAI-compatible images endpoint (WEB_IMAGE_API_BASE_URL). Returns saved file paths plus the images inline.",
533
- promptSnippet: "Generate images via free upstreams (Gemini web, Z.ai CogView)",
532
+ "Generate images from text via free upstream providers, with fallback: Gemini web (gemini.google.com, guest or cookie auth), Z.ai official API (GLM-Image via ZAI_API_KEY), or any custom OpenAI-compatible images endpoint (WEB_IMAGE_API_BASE_URL). Returns saved file paths plus the images inline.",
533
+ promptSnippet: "Generate images via free upstreams (Gemini web, Z.ai GLM-Image)",
534
534
  promptGuidelines: [
535
535
  "Use for image GENERATION from a text prompt. provider auto falls back gemini → zai → custom. Capturing an EXISTING page is web_screenshot, not this.",
536
536
  ],
@@ -540,8 +540,8 @@ export default function piWebExtension(pi: ExtensionAPI) {
540
540
  [Type.Literal("auto"), Type.Literal("gemini"), Type.Literal("zai"), Type.Literal("custom")],
541
541
  { default: "auto", description: "auto = gemini → zai (if ZAI_API_KEY) → custom (if WEB_IMAGE_API_BASE_URL); pin one to skip fallback." },
542
542
  )),
543
- model: Type.Optional(Type.String({ description: "Provider-specific model (e.g. cogview-4, or a Gemini image-capable model id). Omit for the provider default." })),
544
- n: Type.Optional(Type.Number({ default: 1, description: "Number of images, 1-4." })),
543
+ model: Type.Optional(Type.String({ description: "Provider-specific model (e.g. glm-image, or a Gemini image-capable model id). Omit for the provider default." })),
544
+ n: Type.Optional(Type.Number({ default: 1, description: "Number of images, 1-4 (applies to zai/custom; the gemini web tier returns its own count)." })),
545
545
  out_dir: Type.Optional(Type.String({ description: "Directory for saved images (default: fresh temp dir)." })),
546
546
  ...sharedControlSchema,
547
547
  }),
@@ -552,7 +552,7 @@ export default function piWebExtension(pi: ExtensionAPI) {
552
552
  const n = Math.min(Math.max(Math.trunc((params.n as number) ?? 1) || 1, 1), 4);
553
553
  const timeoutMs = Math.min(Math.max((params.timeout_ms as number) ?? 180_000, 10_000), 600_000);
554
554
  const outDir = params.out_dir
555
- ? path.resolve(String(params.out_dir))
555
+ ? path.resolve(cwd, String(params.out_dir))
556
556
  : await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-web-image-"));
557
557
  const result = await generateImageWithFallback({
558
558
  prompt,
@@ -571,13 +571,16 @@ export default function piWebExtension(pi: ExtensionAPI) {
571
571
  `Provider: ${result.provider}${result.model ? ` (${result.model})` : ""}`,
572
572
  `Saved: ${result.paths.length} image(s)`,
573
573
  ...result.paths.map((p) => ` ${p}`),
574
- result.attempts.length ? `Fallback attempts: ${result.attempts.join(" | ")}` : null,
574
+ ...(result.urls.length
575
+ ? [`Not saved (image host unreachable from this machine — open directly):`, ...result.urls.map((u) => ` ${u}`)]
576
+ : []),
577
+ result.attempts.length ? `Provider notes: ${result.attempts.join(" | ")}` : null,
575
578
  ].filter(Boolean).join("\n");
576
579
  const content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> = [
577
580
  { type: "text" as const, text },
578
581
  ...blocks,
579
582
  ];
580
- return { content, details: { provider: result.provider, model: result.model, paths: result.paths, attempts: result.attempts } };
583
+ return { content, details: { provider: result.provider, model: result.model, paths: result.paths, urls: result.urls, attempts: result.attempts } };
581
584
  },
582
585
  });
583
586
 
@@ -1,5 +1,5 @@
1
1
  // Generic OpenAI-compatible images client + fallback chain for web_image.
2
- // Serves the `zai` preset (official api.z.ai, CogView-4) and any `custom`
2
+ // Serves the `zai` preset (official api.z.ai, GLM-Image) and any `custom`
3
3
  // OpenAI-images endpoint — direct-to-upstream plain fetch, no self-host.
4
4
 
5
5
  import fs from "node:fs";
@@ -18,7 +18,7 @@ import {
18
18
  // Config
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
- export const ZAI_PRESET = { baseUrl: "https://api.z.ai/api/paas/v4", defaultModel: "cogview-4" } as const;
21
+ export const ZAI_PRESET = { baseUrl: "https://api.z.ai/api/paas/v4", defaultModel: "glm-image" } as const;
22
22
 
23
23
  export interface ImageApiConfig {
24
24
  zai?: { apiKey: string; source: string };
@@ -155,6 +155,8 @@ export interface FetchLike {
155
155
 
156
156
  export interface ApiImageResult {
157
157
  paths: string[];
158
+ /** Image URLs that could not be downloaded (e.g. CDN unreachable) — the generation still happened. */
159
+ urls: string[];
158
160
  model?: string;
159
161
  }
160
162
 
@@ -212,17 +214,25 @@ export async function apiGenerateImage(opts: {
212
214
  if (!items.length) throw new Error(`upstream returned no image data (model ${opts.model ?? "default"})`);
213
215
  fs.mkdirSync(opts.outDir, { recursive: true });
214
216
  const paths: string[] = [];
217
+ const urls: string[] = [];
215
218
  for (let i = 0; i < items.length; i++) {
216
219
  const item = items[i];
217
220
  if (typeof item?.b64_json === "string" && item.b64_json) {
218
221
  paths.push(writeB64(opts.outDir, item.b64_json, i));
219
222
  } else if (typeof item?.url === "string" && item.url) {
220
- paths.push(await downloadImage(fetchImpl, item.url, opts.outDir, i, opts.signal, opts.timeoutMs));
223
+ // A failed download must not waste the generation: surface the URL.
224
+ try {
225
+ paths.push(await downloadImage(fetchImpl, item.url, opts.outDir, i, opts.signal, opts.timeoutMs));
226
+ } catch (err) {
227
+ urls.push(item.url);
228
+ void err;
229
+ }
221
230
  } else {
222
231
  throw new Error(`image item ${i} had neither b64_json nor url`);
223
232
  }
224
233
  }
225
- return { paths, model: typeof payload?.model === "string" ? payload.model : opts.model };
234
+ if (!paths.length && !urls.length) throw new Error(`upstream returned no image data (model ${opts.model ?? "default"})`);
235
+ return { paths, urls, model: typeof payload?.model === "string" ? payload.model : opts.model };
226
236
  }
227
237
 
228
238
  function writeB64(outDir: string, b64: string, i: number): string {
@@ -267,6 +277,7 @@ export interface ImageChainResult {
267
277
  provider: ImageProvider;
268
278
  model?: string;
269
279
  paths: string[];
280
+ urls: string[];
270
281
  attempts: string[];
271
282
  }
272
283
 
@@ -297,6 +308,13 @@ export async function generateImageWithFallback(params: ImageChainParams): Promi
297
308
  const chain = chainFor(params.provider);
298
309
  const attempts: string[] = [];
299
310
  for (const provider of chain) {
311
+ // Cancelled calls skip fallback entirely — before any provider client
312
+ // construction or fetch invocation.
313
+ if (params.signal?.aborted) {
314
+ const abortErr = new Error("web_image aborted");
315
+ abortErr.name = "AbortError";
316
+ throw abortErr;
317
+ }
300
318
  const configured =
301
319
  provider === "gemini" ? true : provider === "zai" ? Boolean(params.apiConfig.zai) : Boolean(params.apiConfig.custom);
302
320
  if (!configured) {
@@ -309,7 +327,7 @@ export async function generateImageWithFallback(params: ImageChainParams): Promi
309
327
  continue;
310
328
  }
311
329
  try {
312
- let result: { paths: string[]; model?: string };
330
+ let result: { paths: string[]; urls?: string[]; model?: string };
313
331
  if (provider === "gemini") {
314
332
  result = await geminiGenerateImage(params.prompt, {
315
333
  config: params.geminiConfig,
@@ -345,8 +363,24 @@ export async function generateImageWithFallback(params: ImageChainParams): Promi
345
363
  });
346
364
  }
347
365
  imageRateRecord(provider);
348
- return { provider, model: result.model, paths: result.paths, attempts };
366
+ if (provider === "gemini" && params.n && params.n > 1 && result.paths.length < params.n) {
367
+ 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
+ }
369
+ return { provider, model: result.model, paths: result.paths, urls: result.urls ?? [], attempts };
349
370
  } catch (err) {
371
+ // Cancellation is not a provider failure: rethrow so aborted tool calls
372
+ // surface as AbortError instead of an "all providers failed" listing —
373
+ // even when a genuine provider error (AuthError, a failed save, …) was
374
+ // the error in flight when the abort landed.
375
+ if (params.signal?.aborted) {
376
+ if ((err as Error)?.name === "AbortError") throw err;
377
+ // cause keeps the in-flight provider error for diagnostics.
378
+ const abortErr = new Error("web_image aborted", { cause: err });
379
+ abortErr.name = "AbortError";
380
+ throw abortErr;
381
+ }
382
+ // A foreign AbortError-named error (not from the caller's signal) is a
383
+ // provider failure like any other — record it and keep the chain going.
350
384
  attempts.push(`${provider}: ${provider === "gemini" ? describeGeminiError(err) : describeImageApiError(err)}`);
351
385
  }
352
386
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-web",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "Pi extension for web search, page extraction, Firecrawl scraping/crawling, Crawl4AI headless browser crawling, Gemini web-tier research, and free upstream image generation.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pi-web
3
- description: Web search, content extraction, site crawling, page capture, Gemini web-tier research, and free upstream image generation via the pi-web extension. Use when the user needs current web search results, documentation lookup, factual research, AI-synthesized research with sources (Gemini Deep Research), image generation from text (Gemini web, Z.ai CogView-4), source discovery, URL-to-markdown extraction, JSON extraction from websites, site URL discovery, site crawling, or page screenshots/PDFs. Use when the user mentions searching the web, finding docs, looking something up, researching deeply, generating/creating an image, scraping/extracting content from a URL, or capturing a page.
3
+ description: Web search, content extraction, site crawling, page capture, Gemini web-tier research, and free upstream image generation via the pi-web extension. Use when the user needs current web search results, documentation lookup, factual research, AI-synthesized research with sources (Gemini Deep Research), image generation from text (Gemini web, Z.ai GLM-Image), source discovery, URL-to-markdown extraction, JSON extraction from websites, site URL discovery, site crawling, or page screenshots/PDFs. Use when the user mentions searching the web, finding docs, looking something up, researching deeply, generating/creating an image, scraping/extracting content from a URL, or capturing a page.
4
4
  ---
5
5
 
6
6
  # pi-web — Unified Web Tools
@@ -18,7 +18,7 @@ Use the **9 unified tools** from the `pi-web` extension for all web-related task
18
18
  | `web_screenshot` | Capture page screenshot as PNG | Crawl4AI daemon (public URLs) or local headless Chrome (localhost/LAN/file URLs — auto-detected) |
19
19
  | `web_pdf` | Generate page PDF | Crawl4AI daemon (public URLs) or local headless Chrome (localhost/LAN/file URLs — auto-detected) |
20
20
  | `web_research` | AI-synthesized research with sources | Gemini web tier: ask = grounded answer (guest OK); research = Deep Research report (cookie + Gemini Advanced) |
21
- | `web_image` | Generate images from a text prompt | Gemini web (guest/cookie) → Z.ai CogView-4 (`ZAI_API_KEY`) → custom OpenAI-images endpoint |
21
+ | `web_image` | Generate images from a text prompt | Gemini web (guest/cookie) → Z.ai GLM-Image (`ZAI_API_KEY`) → custom OpenAI-images endpoint |
22
22
  | `web_status` | Check provider configuration and health | — |
23
23
 
24
24
  ## Decision Tree
@@ -62,8 +62,8 @@ What do you need?
62
62
 
63
63
  ├── Generate an image from a text prompt (NOT capturing an existing page)
64
64
  │ → web_image
65
- │ ├─ default: provider=auto (Gemini web → Z.ai CogView-4 → custom endpoint)
66
- │ └─ pin/model: provider=zai model=cogview-4, or any custom OpenAI-images endpoint
65
+ │ ├─ default: provider=auto (Gemini web → Z.ai GLM-Image → custom endpoint)
66
+ │ └─ pin/model: provider=zai model=glm-image, or any custom OpenAI-images endpoint
67
67
 
68
68
  └── Check what web tools are configured
69
69
  → web_status