From 9bae0bc66f14d73924ceb4564dcf93f181d5d4d6 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:29:14 -0500 Subject: [PATCH 1/2] Skip unusable model IDs and create the image directory before the request Skip catalog entries whose ID requests cannot carry, so one such alias no longer fails discovery. Create the image output directory before the request and fail without generating when it cannot be created. Reject data URLs and overlong values as edit inputs, refresh live Claude quota less often while the reset is far away, and report the specific /cli:model failure. Share the alias lookup and the test server helper. --- CHANGELOG.md | 8 +++ package.json | 2 +- src/catalog.ts | 31 ++++++--- src/claude-quota.ts | 10 +-- src/config.ts | 2 +- src/media-defaults.ts | 13 +--- src/media.ts | 119 +++++++++++++++++++--------------- src/picker.ts | 7 +- test/media.test.ts | 82 ++++++++++++++++++------ test/picker.test.ts | 6 ++ test/provider.test.ts | 146 +++++++++++++++++++++++++++++++++++------- test/server.ts | 20 ++++++ 12 files changed, 325 insertions(+), 121 deletions(-) create mode 100644 test/server.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a72137..df314de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ For releases before 0.3.6, see the [GitHub releases](https://github.com/hawkff/pi-cliproxyapi-native/releases). +## [0.3.8] - 2026-11-10 + +- Model discovery skips an ID it cannot use and keeps the rest of the catalog. CLIProxyAPI accepts any text as a model alias, and one ID with a space, a non-ASCII character, or more than 256 characters made discovery fail. A catalog with no usable ID still fails, so it cannot replace a working model list. +- `cliproxyapi_generate_image` creates its output directory before the request. If `.pi/` cannot be created, the call fails before anything is generated. Before, the extension generated the image and then discarded it. +- Image edits reject `data:` URLs and paths over 4096 characters as image data. Before, the file error repeated the whole value. +- Live Claude quota lookups slow down while the reset is far away, to one every ten minutes. Within 20 minutes of the reset they run every minute, as before. +- `/cli:model` reports the reason for a failure, such as the HTTP status or a rejected login. Before, most failures showed one generic message. + ## [0.3.7] - 2026-11-10 - Model discovery accepts catalogs where a provider's name is empty or contains spaces. CLIProxyAPI reports the configured name of an OpenAI-compatible provider as `owned_by`, and one such entry made login, `/cliproxyapi-refresh`, `/cli:model`, and the media tools fail. diff --git a/package.json b/package.json index 9f4d034..9662ed3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-cliproxyapi-native", - "version": "0.3.7", + "version": "0.3.8", "description": "CLIProxyAPI chat discovery, image generation and editing, and video generation with Pi's native adapters", "publishConfig": { "access": "public" diff --git a/src/catalog.ts b/src/catalog.ts index 50dab6c..f11346b 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -22,18 +22,26 @@ export function parseCatalog(value: unknown) { if (!isRecord(value) || !Array.isArray(value.data) || value.data.length > 10000) { throw new Error("Invalid CLIProxyAPI model catalog: expected a data array."); } - return value.data.map((entry: unknown) => { + const entries = value.data.flatMap((entry: unknown) => { if ( !isRecord(entry) || - !isModelId(entry.id) || + typeof entry.id !== "string" || (entry.owned_by !== undefined && typeof entry.owned_by !== "string") ) { throw new Error("Invalid CLIProxyAPI model catalog entry."); } + // The proxy accepts any text as a model alias. An ID with spaces or other characters that requests and + // the terminal cannot carry safely is skipped, so the rest of the catalog stays available. + if (!isModelId(entry.id)) return []; // The proxy reports a custom provider's configured name as the owner; it can be empty or contain spaces. const owner = isModelId(entry.owned_by) ? entry.owned_by : undefined; - return { id: entry.id, owner, hidden: entry.visibility === "hide" }; + return [{ id: entry.id, owner, hidden: entry.visibility === "hide" }]; }); + // A response with entries but no usable ID is not an empty catalog, and must not replace a working list. + if (value.data.length && !entries.length) { + throw new Error("Invalid CLIProxyAPI model catalog: no usable model ID."); + } + return entries; } export async function fetchCatalog(config: Config, key: string, signal: AbortSignal) { @@ -79,6 +87,13 @@ export function modelRoute(id: string) { }; } +// An alias for the full ID wins. A recognized route prefix can reuse its unprefixed ID's alias. +export function configuredAlias(config: Config, id: string) { + const { metadataId, prefix } = modelRoute(id); + if (Object.hasOwn(config.aliases, id)) return config.aliases[id]; + return prefix && Object.hasOwn(config.aliases, metadataId) ? config.aliases[metadataId] : undefined; +} + export function backendLabel(id: string) { const { backend, authType } = modelRoute(id); if (authType) return authType === "api-key" ? "API key" : "OAuth"; @@ -308,13 +323,9 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model if (entry.hidden || models.has(entry.id) || mediaPurpose(entry.id)) continue; const { metadataId, prefix } = modelRoute(entry.id); const routed = !entry.id.includes("/") || prefix; - const alias = Object.hasOwn(config.aliases, entry.id) - ? config.aliases[entry.id] - : prefix && Object.hasOwn(config.aliases, metadataId) - ? config.aliases[metadataId] - : routed && !byId.has(metadataId) - ? metadataFallbacks.get(metadataId) - : undefined; + const alias = + configuredAlias(config, entry.id) ?? + (routed && !byId.has(metadataId) ? metadataFallbacks.get(metadataId) : undefined); const candidates = (alias ? byReference.get(alias) : routed ? byId.get(metadataId) : undefined) ?? []; const families = new Set( candidates.map((model) => (model.provider === "openai-codex" ? "openai" : model.provider)), diff --git a/src/claude-quota.ts b/src/claude-quota.ts index 7709946..564f513 100644 --- a/src/claude-quota.ts +++ b/src/claude-quota.ts @@ -1,5 +1,5 @@ import { setTimeout as sleep } from "node:timers/promises"; -import { modelRoute } from "./catalog.ts"; +import { configuredAlias, modelRoute } from "./catalog.ts"; import { type Config, deadline, isRecord, readJson } from "./config.ts"; export type QuotaWaitHandler = (id: symbol, remainingSeconds: number | undefined) => void; @@ -139,9 +139,7 @@ export async function fetchClaudeQuota( // ponytail: probe at most 100 accounts; batch larger pools before raising this limit. if (accounts.length > 100) throw new Error("Live quota lookup supports at most 100 active Claude accounts."); - const { metadataId, prefix } = modelRoute(modelId); - const reference = - config.aliases[modelId] ?? (prefix ? config.aliases[metadataId] : undefined) ?? metadataId; + const reference = configuredAlias(config, modelId) ?? modelRoute(modelId).metadataId; const results = await Promise.allSettled( accounts.map(async (account: unknown) => { if ( @@ -252,7 +250,9 @@ export async function waitForClaudeQuota( ); if (quota.state === "ready") return retryAt !== undefined; retryAt = quota.retryAt; - const refreshAt = Math.min(quota.retryAt, Date.now() + 60000); + // Every waiting session repeats this lookup, so it slows to every ten minutes far from the reset. + const wait = quota.retryAt - Date.now(); + const refreshAt = Date.now() + Math.min(wait, Math.max(60000, Math.min(Math.floor(wait / 20), 600000))); while (Date.now() < refreshAt) { signal?.throwIfAborted(); onWait?.(id, Math.max(0, Math.ceil((quota.retryAt - Date.now()) / 1000))); diff --git a/src/config.ts b/src/config.ts index b87c1f2..a20850c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,5 +1,5 @@ export const PROVIDER_ID = "cliproxyapi"; -export const DEFAULT_BASE_URL = "http://localhost:8317"; +const DEFAULT_BASE_URL = "http://localhost:8317"; export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/src/media-defaults.ts b/src/media-defaults.ts index e656e0b..5f0fc88 100644 --- a/src/media-defaults.ts +++ b/src/media-defaults.ts @@ -1,5 +1,5 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { type MediaPurpose, mediaCapability, mediaPurpose, modelRoute } from "./catalog.ts"; +import { configuredAlias, type MediaPurpose, mediaCapability, mediaPurpose, modelRoute } from "./catalog.ts"; import { type Config, isModelId, isRecord, PROVIDER_ID } from "./config.ts"; export const MEDIA_DEFAULTS_ENTRY = "cliproxyapi-media-defaults"; @@ -34,15 +34,8 @@ export function readMediaDefaults(config: Config, ctx: DefaultsContext) { export function automaticImageDefault(config: Config, ctx: DefaultsContext) { const model = ctx.model; if (!model || mediaPurpose(model.id)) return undefined; - const { metadataId, prefix } = modelRoute(model.id); - const alias = - model.provider === PROVIDER_ID - ? Object.hasOwn(config.aliases, model.id) - ? config.aliases[model.id] - : prefix && Object.hasOwn(config.aliases, metadataId) - ? config.aliases[metadataId] - : undefined - : undefined; + const { metadataId } = modelRoute(model.id); + const alias = model.provider === PROVIDER_ID ? configuredAlias(config, model.id) : undefined; const openai = alias ? /^(openai|openai-codex)\//.test(alias) : model.provider === "openai" || diff --git a/src/media.ts b/src/media.ts index 7189b10..459fa2c 100644 --- a/src/media.ts +++ b/src/media.ts @@ -1,5 +1,5 @@ import { constants } from "node:fs"; -import { mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, open, rm, rmdir, writeFile } from "node:fs/promises"; import * as http from "node:http"; import * as https from "node:https"; import { join, resolve } from "node:path"; @@ -315,15 +315,19 @@ async function imageInputs( let total = 0; for (const [index, path] of [...params.images, ...(layer === undefined ? [] : [layer])].entries()) { signal.throwIfAborted(); + const location = typeof path === "string" ? resolve(cwd, path) : ""; if ( - typeof path !== "string" || + !location || !path.trim() || path.includes("\0") || - /^[a-z][a-z\d+.-]*:\/\//i.test(path) + // No file system takes a longer path, so a longer value is image data. + location.length > 4096 || + // A data URL has a comma after its media type; a file name that starts with "data:" need not. + /^(?:data:[^,]*,|[a-z][a-z\d+.-]*:\/\/)/i.test(path) ) - throw new Error("CLIProxyAPI editing accepts local image paths, not URLs."); + throw new Error("CLIProxyAPI editing accepts local image paths, not URLs or image data."); // Nonblocking open prevents FIFOs from hanging before the regular-file check. - const file = await open(resolve(cwd, path), constants.O_RDONLY | constants.O_NONBLOCK); + const file = await open(location, constants.O_RDONLY | constants.O_NONBLOCK); let bytes: Buffer; try { const info = await file.stat(); @@ -529,7 +533,7 @@ function errorText(value: unknown, key: string) { } // Server-sent events: partial previews go to the callback, other events are returned in order. -async function readEvents( +export async function readEvents( reader: StreamReader, maxBytes: number, onPartial: (event: Record) => void, @@ -1083,56 +1087,71 @@ export async function generateImage( const inputs = await imageInputs(model, params, ctx.cwd, bounded); const key = await generationKey(config, { ...params, model }, ctx, bounded); bounded.throwIfAborted(); - onProgress?.(inputs.images ? "Editing image..." : "Generating image..."); - bounded.throwIfAborted(); - const { images, usage } = await requestImages( - config, - key, - model, - { prompt: params.prompt, output, ...inputs }, - bounded, - onPartial, - ); - bounded.throwIfAborted(); - onProgress?.("Saving image..."); - bounded.throwIfAborted(); + // A generated image cannot be regenerated for free, so its directory exists before the request. const artifacts = join(ctx.cwd, ".pi"); - await mkdir(artifacts, { recursive: true, mode: 0o700 }); - const directory = await mkdtemp(join(artifacts, "cliproxyapi-image-")); - const files = images.map(({ extension, image }, index) => ({ - path: join(directory, `${index === 0 ? "image" : `image-${index + 1}`}.${extension}`), - mimeType: image.mimeType, - })); + let created: string | undefined; + let directory = ""; try { - for (const [index, { bytes }] of images.entries()) { - await writeFile(files[index].path, bytes, { flag: "wx", mode: 0o600, signal: bounded }); + try { + created = await mkdir(artifacts, { recursive: true, mode: 0o700 }); + directory = await mkdtemp(join(artifacts, "cliproxyapi-image-")); + } catch { + throw new Error( + "CLIProxyAPI cannot create .pi/ in the working directory to save images. Nothing was generated.", + ); } + onProgress?.(inputs.images ? "Editing image..." : "Generating image..."); bounded.throwIfAborted(); - } catch { - await rm(directory, { recursive: true, force: true }); - throw new Error("CLIProxyAPI generated images but could not save the artifacts."); - } - let previewBudget = MAX_INLINE_IMAGE_BASE64_BYTES; - const content: (TextContent | ImageContent)[] = []; - for (const [index, { image }] of images.entries()) { - // Chat providers disagree on animated GIF input and reject SVG, so both skip inline previews. - const previewOmitted = ["image/gif", "image/svg+xml"].includes(image.mimeType) - ? "Preview omitted: GIF and SVG results are saved without an inline preview." - : !ctx.model?.input.includes("image") - ? "Preview omitted: the current chat model does not support images." - : image.data.length > previewBudget - ? "Preview omitted: image base64 exceeds the 4 MiB inline budget. Use read on the saved file." - : undefined; - content.push({ - type: "text", - text: `Saved image: ${files[index].path}${previewOmitted ? `\n${previewOmitted}` : ""}`, - }); - if (!previewOmitted) { - content.push(image); - previewBudget -= image.data.length; + const { images, usage } = await requestImages( + config, + key, + model, + { prompt: params.prompt, output, ...inputs }, + bounded, + onPartial, + ); + bounded.throwIfAborted(); + onProgress?.("Saving image..."); + bounded.throwIfAborted(); + const files = images.map(({ extension, image }, index) => ({ + path: join(directory, `${index === 0 ? "image" : `image-${index + 1}`}.${extension}`), + mimeType: image.mimeType, + })); + try { + for (const [index, { bytes }] of images.entries()) { + await writeFile(files[index].path, bytes, { flag: "wx", mode: 0o600, signal: bounded }); + } + bounded.throwIfAborted(); + } catch { + throw new Error("CLIProxyAPI generated images but could not save the artifacts."); + } + let previewBudget = MAX_INLINE_IMAGE_BASE64_BYTES; + const content: (TextContent | ImageContent)[] = []; + for (const [index, { image }] of images.entries()) { + // Chat providers disagree on animated GIF input and reject SVG, so both skip inline previews. + const previewOmitted = ["image/gif", "image/svg+xml"].includes(image.mimeType) + ? "Preview omitted: GIF and SVG results are saved without an inline preview." + : !ctx.model?.input.includes("image") + ? "Preview omitted: the current chat model does not support images." + : image.data.length > previewBudget + ? "Preview omitted: image base64 exceeds the 4 MiB inline budget. Use read on the saved file." + : undefined; + content.push({ + type: "text", + text: `Saved image: ${files[index].path}${previewOmitted ? `\n${previewOmitted}` : ""}`, + }); + if (!previewOmitted) { + content.push(image); + previewBudget -= image.data.length; + } } + return { content, details: { model, files }, usage }; + } catch (error) { + // A failed call leaves no files, and no .pi/ that it created. + if (directory) await rm(directory, { recursive: true, force: true }).catch(() => {}); + if (created) await rmdir(artifacts).catch(() => {}); + throw error; } - return { content, details: { model, files }, usage }; } function requestId(value: unknown) { diff --git a/src/picker.ts b/src/picker.ts index 73427d5..632e5ae 100644 --- a/src/picker.ts +++ b/src/picker.ts @@ -316,9 +316,12 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) { if (!clearedPurpose(choice) && !rows.find((row) => row.id === choice)?.supported) throw new Error("Model unavailable or unsupported. Use /cli:model to list supported IDs."); save(choice); - } catch { + } catch (error) { + // Each failure explains itself. A cancelled or timed-out request carries only its signal's reason. report( - "CLIProxyAPI model selection failed: unavailable/unsupported model, invalid command, authentication, or connection error. Check /login cliproxyapi. Use /cli:model list, select , or clear image|video.", + error instanceof Error && error.name !== "AbortError" && error.name !== "TimeoutError" + ? error.message + : "CLIProxyAPI model selection was cancelled or timed out.", true, ); } diff --git a/test/media.test.ts b/test/media.test.ts index 6959128..5a0a76f 100644 --- a/test/media.test.ts +++ b/test/media.test.ts @@ -26,11 +26,13 @@ import { generateImage, generateVideo, listMediaModels, + readEvents, registerMediaTools, videoStatus, } from "../src/media.ts"; import { MEDIA_DEFAULTS_ENTRY } from "../src/media-defaults.ts"; import { builtinCatalog } from "../src/provider.ts"; +import { server } from "./server.ts"; const key = "fixture-media-key"; const imageModel = "grok-imagine-image"; @@ -78,21 +80,6 @@ function context(cwd: string) { }; } -async function server(t: TestContext, handler: (req: IncomingMessage, res: ServerResponse) => void) { - const instance = createServer(handler); - instance.listen(0, "127.0.0.1"); - await once(instance, "listening"); - t.after(async () => { - const closed = once(instance, "close"); - instance.close(); - instance.closeAllConnections(); - await closed; - }); - const address = instance.address(); - assert.ok(address && typeof address !== "string"); - return `http://127.0.0.1:${address.port}`; -} - async function body(req: IncomingMessage) { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(Buffer.from(chunk)); @@ -1748,9 +1735,11 @@ test("OpenAI editing uploads bounded local files and a mask to the exact edit ro const ctx = context(await home(t)); // Phone cameras append data after the JPEG end marker. const photo = Buffer.concat([Buffer.from(jpeg, "base64"), Buffer.from("SEFT")]).toString("base64"); + // A file name may start with "data:"; only a data URL is refused as image data. const files = [ ["source.png", png], ["reference.jpg", photo], + ["data:extra.png", png], ["mask.png", png], ]; for (const [name, data] of files) await writeFile(join(ctx.cwd, name), Buffer.from(data, "base64")); @@ -1760,7 +1749,7 @@ test("OpenAI editing uploads bounded local files and a mask to the exact edit ro { model, prompt: "fixture", - images: ["source.png", join(ctx.cwd, "reference.jpg")], + images: ["source.png", join(ctx.cwd, "reference.jpg"), "data:extra.png"], mask: "mask.png", input_fidelity: "high", quality: "high", @@ -1775,7 +1764,11 @@ test("OpenAI editing uploads bounded local files and a mask to the exact edit ro n: 1, quality: "high", input_fidelity: "high", - images: [{ image_url: `data:image/png;base64,${png}` }, { image_url: `data:image/jpeg;base64,${photo}` }], + images: [ + { image_url: `data:image/png;base64,${png}` }, + { image_url: `data:image/jpeg;base64,${photo}` }, + { image_url: `data:image/png;base64,${png}` }, + ], mask: { image_url: `data:image/png;base64,${png}` }, }); assert.deepEqual(progress, ["Editing image...", "Saving image..."]); @@ -1935,6 +1928,13 @@ test("invalid OpenAI controls and edit inputs fail before authentication or netw ), /limits/, ); + // Image data passed where a path belongs is rejected without repeating it. + for (const input of [`data:image/png;base64,${png}`, "A".repeat(4097)]) + await assert.rejects( + generateImage(config, { model: "api/gpt-image-2", prompt: "fixture", images: [input] }, ctx), + (error: unknown) => + error instanceof Error && /local image paths/.test(error.message) && !error.message.includes(input), + ); assert.equal(ctx.modelRegistry.getProviderAuth.mock.callCount(), 0); assert.equal(network.mock.callCount(), 0); assert.ok(!(await readdir(ctx.cwd)).includes(".pi")); @@ -2008,7 +2008,7 @@ test("multi-image output cleans up a partial save and isolates concurrent genera try { await assert.rejects(generateImage(config, params, ctx), /could not save/); assert.equal(writes, 2); - assert.deepEqual(await readdir(join(ctx.cwd, ".pi")), []); + assert.deepEqual(await readdir(ctx.cwd), []); } finally { mocked.mock.restore(); syncBuiltinESMExports(); @@ -2020,6 +2020,52 @@ test("multi-image output cleans up a partial save and isolates concurrent genera for (const file of files) assert.equal((await readFile(file.path)).toString("base64"), png); }); +test("an output directory that cannot be created fails the call before the image request", async (t) => { + let posts = 0; + const config = parseConfig({ + baseUrl: await server(t, (req, res) => { + if (req.method === "POST") posts++; + res.end(JSON.stringify(req.method === "GET" ? catalog : { data: [{ b64_json: png }] })); + }), + }); + const ctx = context(await home(t)); + // A file named .pi blocks the directory. + await writeFile(join(ctx.cwd, ".pi"), "keep"); + await assert.rejects( + generateImage(config, { model: imageModel, prompt: "fixture" }, ctx), + /cannot create \.pi\/.*Nothing was generated/, + ); + assert.equal(posts, 0); + assert.equal(await readFile(join(ctx.cwd, ".pi"), "utf8"), "keep"); +}); + +test("the image event reader stays fast when a large frame arrives in small chunks", async () => { + const image = "A".repeat(4 * 1024 * 1024); + const bytes = Buffer.from( + `data: ${JSON.stringify({ type: "image_generation.completed", b64_json: image })}\n\n`, + ); + let offset = 0; + const started = performance.now(); + const events = await readEvents( + { + read: async () => { + const value = bytes.subarray(offset, offset + 256); + offset += 256; + return value.length ? { done: false, value } : { done: true }; + }, + cancel: async () => {}, + }, + bytes.length, + () => assert.fail("The stream has no preview frames"), + ); + assert.deepEqual( + events.map((event) => [event.type, event.b64_json === image]), + [["image_generation.completed", true]], + ); + // Searching the pending frame after every chunk copies it each time, which takes several seconds here. + assert.ok(performance.now() - started < 2000); +}); + test("OpenAI caller cancellation and overall timeout destroy an incoming body after partial data", { timeout: 15000, }, async (t) => { diff --git a/test/picker.test.ts b/test/picker.test.ts index 1bfcdf0..116b896 100644 --- a/test/picker.test.ts +++ b/test/picker.test.ts @@ -428,6 +428,9 @@ test("media selection uses only GETs, restores branch defaults, and never change } await command.handler("clear image", ctx); assert.deepEqual(readMediaDefaults(config, ctx), { video }); + // Each failure reports its own reason. + await command.handler("clear both", ctx); + assert.equal(notifications.at(-1), "Usage: /cli:model clear image|video"); sessionManager.branch(imageLeaf); await command.handler("list", ctx); assert.match(notifications.at(-1) ?? "", /Image: gemini-3.1-flash-image.*Video: none/); @@ -557,11 +560,14 @@ test("picker saves follow successful command order per purpose across deferred d if (!immediate) gates[1].response.resolve(scenario.failure ? new Response("failed", { status: 503 }) : response()); await later; + if (scenario.failure) assert.equal(notifications.at(-1), "CLIProxyAPI discovery failed (HTTP 503)."); if (!scenario.olderFirst) { if (scenario.abort) controller.abort(); gates[0].response.resolve(response()); await older; } + if (scenario.abort) + assert.equal(notifications.at(-1), "CLIProxyAPI model selection was cancelled or timed out."); assert.deepEqual(readMediaDefaults(config, ctx), scenario.expected); assert.equal( notifications.some((text) => text.includes("superseded")), diff --git a/test/provider.test.ts b/test/provider.test.ts index fd69372..1ab38f0 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -3,7 +3,7 @@ import { execFile, spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { once } from "node:events"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; -import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { ServerResponse } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { type TestContext, test } from "node:test"; @@ -22,10 +22,11 @@ import { type Model, normalizeContext, } from "@earendil-works/pi-ai"; -import { ModelRuntime, resolveCliModel } from "@earendil-works/pi-coding-agent"; +import { DefaultResourceLoader, ModelRuntime, resolveCliModel } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; +import extension from "../extensions/index.ts"; import { IMAGE_API, mapCatalog, parseCatalog, routedName } from "../src/catalog.ts"; -import { claudeQuotaState, fetchClaudeQuota } from "../src/claude-quota.ts"; +import { claudeQuotaState, fetchClaudeQuota, waitForClaudeQuota } from "../src/claude-quota.ts"; import { isRecord, normalizeBaseUrl, PROVIDER_ID, parseConfig } from "../src/config.ts"; import { builtinCatalog, @@ -35,6 +36,7 @@ import { nonStrictTools, quotaRetryAt, } from "../src/provider.ts"; +import { server } from "./server.ts"; const key = "fixture-api-key"; const known: Model[] = [ @@ -66,21 +68,6 @@ const known: Model[] = [ const catalog = { data: known.map(({ id, provider }) => ({ id, owned_by: provider })) }; const noEnvironment = { env: async () => undefined, fileExists: async () => false }; -async function server(t: TestContext, handler: (req: IncomingMessage, res: ServerResponse) => void) { - const instance = createServer(handler); - instance.listen(0, "127.0.0.1"); - await once(instance, "listening"); - t.after(async () => { - const closed = once(instance, "close"); - instance.close(); - instance.closeAllConnections(); - await closed; - }); - const address = instance.address(); - assert.ok(address && typeof address !== "string"); - return `http://127.0.0.1:${address.port}`; -} - async function collection(baseUrl: string, store = new InMemoryModelsStore(), authenticated = true) { const credentials = new InMemoryCredentialStore(); if (authenticated) await credentials.modify(PROVIDER_ID, async () => ({ type: "api_key", key })); @@ -320,13 +307,14 @@ test("Claude routes keep message threads off and report one-hour caches except o } }); -test("malformed catalogs fail rather than replacing a working list", () => { +test("malformed catalogs fail rather than replacing a working list, and unusable IDs are skipped", () => { for (const value of [ null, {}, { data: null }, { data: [null] }, { data: [{}] }, + { data: [{ id: 7 }] }, { data: [{ id: "bad\nvalue" }] }, { data: [{ id: "valid", owned_by: {} }] }, { data: Array(10001).fill({ id: "fixture" }) }, @@ -341,6 +329,12 @@ test("malformed catalogs fail rather than replacing a working list", () => { [undefined, undefined, "claude"], ); assert.equal(mapCatalog(named, parseConfig({}), known).models[0].api, "anthropic-messages"); + // The proxy accepts any text as a model alias. One that requests cannot carry must not hide the rest. + const ids = ["bad\nvalue", "two words", "", "モデル", "x".repeat(257), known[0].id]; + assert.deepEqual( + parseCatalog({ data: ids.map((id) => ({ id })) }).map(({ id }) => id), + [known[0].id], + ); }); test("native refresh persists metadata, restores offline, keeps failures, and accepts an empty catalog", async (t) => { @@ -371,9 +365,12 @@ test("native refresh persists metadata, restores offline, keeps failures, and ac assert.ok(!failed.errors.get(PROVIDER_ID)?.message.includes(key)); assert.equal(second.provider.getModels().length, 5); status = 200; - body = { broken: true }; - assert.equal((await second.models.refresh()).errors.size, 1); - assert.equal(second.provider.getModels().length, 5); + // A catalog without one usable ID is broken, not empty. + for (const broken of [{ broken: true }, { data: [{ id: "" }] }]) { + body = broken; + assert.equal((await second.models.refresh()).errors.size, 1); + assert.equal(second.provider.getModels().length, 5); + } body = { data: [] }; await second.models.refresh(); assert.equal(second.provider.getModels().length, 0); @@ -1106,7 +1103,7 @@ test("live Claude windows distinguish five-hour, weekly, and model-scoped exhaus assert.equal(claudeQuotaState(scoped, "claude-opus-fixture", now).weeklyResetAt, undefined); assert.equal(claudeQuotaState(scoped, "claude-sonnet-fixture", now).weeklyResetAt, now + 2 * 86400000); assert.equal(claudeQuotaState(usage, "claude-opus-fixture", now + 30000, now).retryAt, now + 931000); - // Fable has its own weekly window. An active scoped limit for the model replaces it. + // Fable has its own weekly window. An active scoped limit, which Claude reports as "Fable", replaces it. const reset = new Date(now + 2 * 86400000).toISOString(); const fable = { ...claudeUsage(now + 900000, 50), iguana_necktie: { utilization: 100, resets_at: reset } }; const limit = (percent: number, is_active = true) => ({ @@ -1114,7 +1111,7 @@ test("live Claude windows distinguish five-hour, weekly, and model-scoped exhaus is_active, percent, resets_at: reset, - scope: { model: { display_name: "Fable 5" } }, + scope: { model: { display_name: "Fable" }, surface: null }, }); assert.equal(claudeQuotaState(fable, "claude-opus-fixture", now).state, "ready"); assert.equal(claudeQuotaState(fable, "claude-fable-fixture", now).weeklyResetAt, now + 2 * 86400000); @@ -1565,6 +1562,107 @@ test("OAuth alias quota lookup uses canonical metadata and prefers the full alia assert.equal((await fetchClaudeQuota(config, "fixture-management-key", id)).state, "blocked"); config.aliases[id] = "anthropic/claude-sonnet-fixture"; assert.equal((await fetchClaudeQuota(config, "fixture-management-key", id)).state, "ready"); + // An ID that matches a built-in object property name has no alias. + seen.modelIds = ["constructor"]; + assert.equal((await fetchClaudeQuota(config, "fixture-management-key", "constructor")).state, "ready"); +}); + +test("live quota lookups slow to every ten minutes while the reset is far away", { + timeout: 10000, +}, async (t) => { + const now = Date.parse("2030-01-01T00:00:00Z"); + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now }); + const config = parseConfig({ baseUrl: "https://proxy.example" }); + const waiting = Promise.withResolvers(); + const refreshed = Promise.withResolvers(); + const seen = mockClaudeManagement(t, config.baseUrl, () => { + if (seen.calls === 2) refreshed.resolve(); + return claudeUsage(now + 5 * 3600000); + }); + const controller = new AbortController(); + const wait = waitForClaudeQuota( + config, + "fixture-management-key", + known[0].id, + now + 7 * 86400000, + controller.signal, + (_id, seconds) => { + if (seconds !== undefined) waiting.resolve(); + }, + ); + await waiting.promise; + t.mock.timers.tick(599999); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(seen.calls, 1); + t.mock.timers.tick(1); + await refreshed.promise; + controller.abort(); + await assert.rejects(wait); +}); + +test("a quota wait shows a countdown in the session and clears it when the wait ends", { + timeout: 10000, +}, async (t) => { + const baseUrl = await server(t, (req, res) => { + req.resume(); + res.writeHead(429, { "retry-after": "3180" }).end(); + }); + const directory = await mkdtemp(join(tmpdir(), "pi-cliproxyapi-quota-test-")); + t.after(() => rm(directory, { recursive: true, force: true })); + for (const [name, value] of Object.entries({ + PI_CODING_AGENT_DIR: directory, + CLIPROXYAPI_BASE_URL: baseUrl, + })) { + const previous = process.env[name]; + process.env[name] = value; + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); + } + const loader = new DefaultResourceLoader({ + cwd: directory, + agentDir: directory, + noExtensions: true, + noSkills: true, + noContextFiles: true, + extensionFactories: [extension], + }); + await loader.reload(); + const loaded = loader.getExtensions(); + assert.deepEqual(loaded.errors, []); + const waiting = Promise.withResolvers(); + const status: unknown[][] = []; + const notices: string[] = []; + const ctx = { + mode: "rpc", + ui: { + setStatus(...update: unknown[]) { + status.push(update); + waiting.resolve(); + }, + notify: (text: string) => notices.push(text), + }, + }; + for (const handler of loaded.extensions[0].handlers.get("session_start") ?? []) + await handler({ type: "session_start" }, ctx); + // A frozen clock keeps the countdown at the full delay. + t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: Date.parse("2030-01-01T00:00:00Z") }); + const controller = new AbortController(); + const result = loaded.runtime.pendingNativeProviderRegistrations[0].provider + .streamSimple( + mapCatalog(catalog, parseConfig({ baseUrl }), known).models[1], + normalizeContext({ messages: [{ role: "user", content: "Return ok.", timestamp: 0 }] }), + { apiKey: key, maxRetries: 0, signal: controller.signal }, + ) + .result(); + await waiting.promise; + assert.deepEqual(status, [["cliproxyapi-quota", "CLIProxyAPI quota: retry in 0h 53m 1s"]]); + assert.equal(notices.length, 1); + assert.match(notices[0], /^CLIProxyAPI quota limit\. Retrying at .+\. Cancel the current request to stop/); + controller.abort(); + assert.equal((await result).stopReason, "aborted"); + assert.deepEqual(status.at(-1), ["cliproxyapi-quota", undefined]); }); test("repeated header cooldowns cannot extend the original weekly waiting deadline", { diff --git a/test/server.ts b/test/server.ts new file mode 100644 index 0000000..8438ab6 --- /dev/null +++ b/test/server.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { TestContext } from "node:test"; + +// Starts a loopback HTTP server that closes with the test, and returns its base URL. +export async function server(t: TestContext, handler: (req: IncomingMessage, res: ServerResponse) => void) { + const instance = createServer(handler); + instance.listen(0, "127.0.0.1"); + await once(instance, "listening"); + t.after(async () => { + const closed = once(instance, "close"); + instance.close(); + instance.closeAllConnections(); + await closed; + }); + const address = instance.address(); + assert.ok(address && typeof address !== "string"); + return `http://127.0.0.1:${address.port}`; +} From 494e4ae205bd43c9a5ee10407031d1f6920aad3d Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:04:55 -0500 Subject: [PATCH 2/2] Wait on a real timer in the quota refresh test Named imports from node:timers/promises are not mocked, so the refresh-interval assertion ran while the wait was still asleep and passed with a fixed one-minute interval. The test now mocks only the clock and waits past the sleep. The changelog states that the interval grows with the remaining wait. --- CHANGELOG.md | 2 +- test/provider.test.ts | 34 +++++++++++++++++++++------------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df314de..3581be7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ For releases before 0.3.6, see the [GitHub releases](https://github.com/hawkff/p - Model discovery skips an ID it cannot use and keeps the rest of the catalog. CLIProxyAPI accepts any text as a model alias, and one ID with a space, a non-ASCII character, or more than 256 characters made discovery fail. A catalog with no usable ID still fails, so it cannot replace a working model list. - `cliproxyapi_generate_image` creates its output directory before the request. If `.pi/` cannot be created, the call fails before anything is generated. Before, the extension generated the image and then discarded it. - Image edits reject `data:` URLs and paths over 4096 characters as image data. Before, the file error repeated the whole value. -- Live Claude quota lookups slow down while the reset is far away, to one every ten minutes. Within 20 minutes of the reset they run every minute, as before. +- Live Claude quota lookups run less often while the reset is far away. The interval grows with the remaining wait, up to ten minutes. Within 20 minutes of the reset they run every minute, as before. - `/cli:model` reports the reason for a failure, such as the HTTP status or a rejected login. Before, most failures showed one generic message. ## [0.3.7] - 2026-11-10 diff --git a/test/provider.test.ts b/test/provider.test.ts index 1ab38f0..d26e298 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -7,6 +7,7 @@ import type { ServerResponse } from "node:http"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { type TestContext, test } from "node:test"; +import { setTimeout as sleep } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; import { @@ -1571,7 +1572,8 @@ test("live quota lookups slow to every ten minutes while the reset is far away", timeout: 10000, }, async (t) => { const now = Date.parse("2030-01-01T00:00:00Z"); - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now }); + // Only the clock is mocked. The wait sleeps on real timers, one second at a time. + t.mock.timers.enable({ apis: ["Date"], now }); const config = parseConfig({ baseUrl: "https://proxy.example" }); const waiting = Promise.withResolvers(); const refreshed = Promise.withResolvers(); @@ -1580,24 +1582,29 @@ test("live quota lookups slow to every ten minutes while the reset is far away", return claudeUsage(now + 5 * 3600000); }); const controller = new AbortController(); - const wait = waitForClaudeQuota( - config, - "fixture-management-key", - known[0].id, - now + 7 * 86400000, - controller.signal, - (_id, seconds) => { - if (seconds !== undefined) waiting.resolve(); - }, + // The wait ends with the test, also when an assertion fails first. + t.after(() => controller.abort()); + const stopped = assert.rejects( + waitForClaudeQuota( + config, + "fixture-management-key", + known[0].id, + now + 7 * 86400000, + controller.signal, + (_id, seconds) => { + if (seconds !== undefined) waiting.resolve(); + }, + ), ); await waiting.promise; + // Just under ten minutes later the wait wakes from its sleep and must not look up again. t.mock.timers.tick(599999); - await new Promise((resolve) => setImmediate(resolve)); + await sleep(1200); assert.equal(seen.calls, 1); t.mock.timers.tick(1); await refreshed.promise; controller.abort(); - await assert.rejects(wait); + await stopped; }); test("a quota wait shows a countdown in the session and clears it when the wait ends", { @@ -1647,8 +1654,9 @@ test("a quota wait shows a countdown in the session and clears it when the wait for (const handler of loaded.extensions[0].handlers.get("session_start") ?? []) await handler({ type: "session_start" }, ctx); // A frozen clock keeps the countdown at the full delay. - t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: Date.parse("2030-01-01T00:00:00Z") }); + t.mock.timers.enable({ apis: ["Date"], now: Date.parse("2030-01-01T00:00:00Z") }); const controller = new AbortController(); + t.after(() => controller.abort()); const result = loaded.runtime.pendingNativeProviderRegistrations[0].provider .streamSimple( mapCatalog(catalog, parseConfig({ baseUrl }), known).models[1],