From c7800bb585150394d51efe658abb7f4edcb0e3cc Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:02:23 -0500 Subject: [PATCH] Support Pi 1.5.1 and CLIProxyAPI 8.0.63 Skip quota waits for Pi's cache-warming replays, report a one-hour prompt-cache lifetime for Claude on routes other than api/, and keep Claude message threads off. Send the Claude Code version CLIProxyAPI 8.0.63 uses in live quota lookups, mark the media picker as waiting for input, ship a changelog that Pi shows after updates, and move development dependencies to Pi 1.5.1. --- CHANGELOG.md | 12 +++++ package.json | 9 ++-- pnpm-lock.yaml | 92 ++++++++++++++++++------------------ src/catalog.ts | 6 +++ src/claude-quota.ts | 3 +- src/picker.ts | 1 + src/provider.ts | 7 ++- test/package.test.ts | 7 ++- test/picker.test.ts | 16 +++++-- test/provider.test.ts | 106 +++++++++++++++++++++++++++++++++++++++++- 10 files changed, 200 insertions(+), 59 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..507c2d9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +For releases before 0.3.6, see the [GitHub releases](https://github.com/hawkff/pi-cliproxyapi-native/releases). + +## [0.3.6] - 2026-11-10 + +- Pi's background prompt-cache refreshes skip the quota wait. Before, a refresh that hit a quota limit showed a quota countdown while Pi sat idle, then sent the whole request again after the reset. +- Claude on unprefixed, `oauth/`, `vertex/`, and `antigravity/` routes reports a one-hour cache lifetime, so Pi stops refreshing those caches every five minutes. CLIProxyAPI keeps OAuth Claude caches for an hour. `api/` routes keep Pi's five-minute refreshes. +- Claude message threads stay off. A proxy route that drops the `thread` field would send only the newest messages. +- Live Claude quota lookups send the Claude Code version that CLIProxyAPI 8.0.63 uses. +- The `/cli:model` picker reports Pi as waiting for input in terminals that show program status. +- The extension targets Pi 1.5.1 and CLIProxyAPI 8.0.63. diff --git a/package.json b/package.json index e68f7a5..b6abcbd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-cliproxyapi-native", - "version": "0.3.5", + "version": "0.3.6", "description": "CLIProxyAPI chat discovery, image generation and editing, and video generation with Pi's native adapters", "publishConfig": { "access": "public" @@ -23,6 +23,7 @@ "extensions", "src", "README.md", + "CHANGELOG.md", "LICENSE" ], "pi": { @@ -39,9 +40,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.6.5", - "@earendil-works/pi-ai": "^1.5.0", - "@earendil-works/pi-coding-agent": "^1.5.0", - "@earendil-works/pi-tui": "^1.5.0", + "@earendil-works/pi-ai": "^1.5.1", + "@earendil-works/pi-coding-agent": "^1.5.1", + "@earendil-works/pi-tui": "^1.5.1", "@types/node": "^24.19.2", "typebox": "1.3.27", "typescript": "7.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6498c2e..565cd9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,14 @@ importers: specifier: ^2.6.5 version: 2.6.5 '@earendil-works/pi-ai': - specifier: ^1.5.0 - version: 1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) + specifier: ^1.5.1 + version: 1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) '@earendil-works/pi-coding-agent': - specifier: ^1.5.0 - version: 1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3) + specifier: ^1.5.1 + version: 1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3) '@earendil-works/pi-tui': - specifier: ^1.5.0 - version: 1.5.0 + specifier: ^1.5.1 + version: 1.5.1 '@types/node': specifier: ^24.19.2 version: 24.19.2 @@ -194,38 +194,38 @@ packages: cpu: [x64] os: [win32] - '@earendil-works/pi-agent-core@1.5.0': - resolution: {integrity: sha512-FOj+jXj48DbzMycR/ghNa2KcsqWmvOln24e0FnQOVgnd8vn5H79g335WdHaZUVdRo+zCeogb40/qPPlCWFuvmA==} + '@earendil-works/pi-agent-core@1.5.1': + resolution: {integrity: sha512-jD4qinlcwO3AoN/97v0AG3b0Umz/7hIBlEStmz6M3AQNicWOU+UK851fdDvzvCCukpQBSyOraEGRFMTgj+xGpA==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@1.5.0': - resolution: {integrity: sha512-VeWxNqTHSqRCf8AcY/6kvNwh+neRL6MPxZPePVztIQy6qqK9MR/YoCSN/ZgDnIyCD3pqEXgRyQbzk4KV4uvjbg==} + '@earendil-works/pi-ai@1.5.1': + resolution: {integrity: sha512-ukMQ78DE1ypirehoWOrg32dZNEQsY/VXRETPJf5qgdpaw8abq/jlALpQpiEpRDyCkCyX/9FPiQRIazw7FA1FCw==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-codemode@1.5.0': - resolution: {integrity: sha512-PLBJZ17AEWGzOlAAcFuOkw3PxkOti0JlFuZQyeF2GRTqbpuYo4k9t8YqUV2uOQVzrUnKdxahwaFUqc1eNS1WAg==} + '@earendil-works/pi-codemode@1.5.1': + resolution: {integrity: sha512-xVuCpni7AuLPhvrN+oCP9Kcq0I2kY8SdtJRNMevQMmC2DcuQcgSWgOH2KOtuG0/Ihe1BZgPCahY4q5oiFMHw4g==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-coding-agent@1.5.0': - resolution: {integrity: sha512-xNDZ5kqowO6Z03FS2gwAkPEGk1ssxVcYKQ6qZId84mFEvwACvvyc2l0vAMOS+c5zvxLRMLBE9r4y44IGYkMJaQ==} + '@earendil-works/pi-coding-agent@1.5.1': + resolution: {integrity: sha512-rdu4pRvpQba2ep83nqHaN8L1pvY1OQ3i/2FucogA/eyRxwoUXMUXdk1Z9JnZFJR+9ck9Gs1ajuSb9m7Hz7ejYw==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-mcp@1.5.0': - resolution: {integrity: sha512-DURw3IwHCTMHV8CzUJWVp9css27NowsCWfXt4xg2r09IZ+P1O/Uq58hp0hb9WJO0dbkaKGXKBIkV4LVXcu2nlg==} + '@earendil-works/pi-mcp@1.5.1': + resolution: {integrity: sha512-cUFhLnanLjBf8+NpHU+shJTzvcfLBjjDI02tI7hSmYnYqNfhqXhFPEkqT8G+/e+PX6r46DX1ZU54BTC5+p+MRA==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-schema@1.5.0': - resolution: {integrity: sha512-RW0MbT2tR65OY6xMsgB/yVBb7m/RTNwA+9hurSN1vVXfHZRVmVnXbruBPrx/PzbrQnW5yLVlMRWxErGDQtF1Hg==} + '@earendil-works/pi-schema@1.5.1': + resolution: {integrity: sha512-QmvyJYVTva2ZT6/R2rFL64nmaiRmc6hCHv9ZoVNeyDhvLbRHIQLU8mI140vuX6JOnT7tCEd74GOyui/MIRF7ww==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-telemetry@1.5.0': - resolution: {integrity: sha512-VW3tcVdnNfl/Vk8wHPTzZdKkxcsCOM12ppiO4DXnd9wMxUCmOfLt/wvUnWeKWbTVlVSRg0uqKtnnhE2UCsG2Bw==} + '@earendil-works/pi-telemetry@1.5.1': + resolution: {integrity: sha512-uFi8XzG10gMgz9LggOlQW4CvEe/t1wZ6bwLChx36rmyZE7ogg/jHKTGekG73o6Rkwxi0bqE2AnTk+RZ4GmYQ2w==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@1.5.0': - resolution: {integrity: sha512-56KqrDVeLF18k+syppVbT35Ai1opbM44ot+sL7O3K40CaWwXCiwOpqEFugDBAILtAhseZh6uYIuYxPPOJHLFAA==} + '@earendil-works/pi-tui@1.5.1': + resolution: {integrity: sha512-UZkaMYLuJrk2anJhZf06dNYdLWL8toqWNL807fhAorc8Lzy5vkxWNq/pC1f0CIM2EVdlctuRibWuIQGKYOgx+A==} engines: {node: '>=22.19.0'} '@google/genai@2.21.0': @@ -517,10 +517,6 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - grok-mermaid@0.2.3: - resolution: {integrity: sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==} - engines: {node: '>=18'} - highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -567,6 +563,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lovely-mermaid@0.3.3: + resolution: {integrity: sha512-EMLJS934qwt2MLO/a5o+T/89HGbuXZPwmCpHIhHO+DVrm+y5tHlEHgpZzlC2o4yW/rFPHRFO9xSMafzqH4wH5A==} + engines: {node: '>=18'} + lru-cache@11.5.2: resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} engines: {node: 20 || >=22} @@ -963,10 +963,10 @@ snapshots: '@biomejs/cli-win32-x64@2.6.5': optional: true - '@earendil-works/pi-agent-core@1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3)': + '@earendil-works/pi-agent-core@1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3)': dependencies: - '@earendil-works/pi-ai': 1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) - '@earendil-works/pi-schema': 1.5.0 + '@earendil-works/pi-ai': 1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) + '@earendil-works/pi-schema': 1.5.1 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' @@ -980,12 +980,12 @@ snapshots: - ws - zod - '@earendil-works/pi-ai@1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3)': + '@earendil-works/pi-ai@1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3)': dependencies: '@anthropic-ai/sdk': 0.129.0 '@aws-sdk/client-bedrock-runtime': 3.1127.0 - '@earendil-works/pi-schema': 1.5.0 - '@earendil-works/pi-telemetry': 1.5.0 + '@earendil-works/pi-schema': 1.5.1 + '@earendil-works/pi-telemetry': 1.5.1 '@google/genai': 2.21.0 '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0 @@ -1005,27 +1005,27 @@ snapshots: - ws - zod - '@earendil-works/pi-codemode@1.5.0': + '@earendil-works/pi-codemode@1.5.1': dependencies: quickjs-wasi: 3.6.2 - '@earendil-works/pi-coding-agent@1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)': + '@earendil-works/pi-coding-agent@1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3)': dependencies: - '@earendil-works/pi-agent-core': 1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) - '@earendil-works/pi-ai': 1.5.0(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) - '@earendil-works/pi-codemode': 1.5.0 - '@earendil-works/pi-mcp': 1.5.0 - '@earendil-works/pi-tui': 1.5.0 + '@earendil-works/pi-agent-core': 1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) + '@earendil-works/pi-ai': 1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(undici@8.12.3)(ws@8.21.3) + '@earendil-works/pi-codemode': 1.5.1 + '@earendil-works/pi-mcp': 1.5.1 + '@earendil-works/pi-tui': 1.5.1 '@silvia-odwyer/photon-node': 0.3.4 brace-expansion: 5.0.12 chalk: 6.0.0 cross-spawn: 7.0.6 diff: 8.0.4 - grok-mermaid: 0.2.3 highlight.js: 10.7.3 hosted-git-info: 9.0.3 ignore: 7.0.8 jiti: 2.7.0 + lovely-mermaid: 0.3.3 minimatch: 11.0.3 proper-lockfile: 4.1.2 quickjs-wasi: 3.6.2 @@ -1045,15 +1045,15 @@ snapshots: - ws - zod - '@earendil-works/pi-mcp@1.5.0': + '@earendil-works/pi-mcp@1.5.1': dependencies: cross-spawn: 7.0.6 - '@earendil-works/pi-schema@1.5.0': {} + '@earendil-works/pi-schema@1.5.1': {} - '@earendil-works/pi-telemetry@1.5.0': {} + '@earendil-works/pi-telemetry@1.5.1': {} - '@earendil-works/pi-tui@1.5.0': + '@earendil-works/pi-tui@1.5.1': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.11 @@ -1279,8 +1279,6 @@ snapshots: graceful-fs@4.2.11: {} - grok-mermaid@0.2.3: {} - highlight.js@10.7.3: {} hosted-git-info@9.0.3: @@ -1340,6 +1338,8 @@ snapshots: long@5.3.2: {} + lovely-mermaid@0.3.3: {} + lru-cache@11.5.2: {} marked-cjk-friendly@0.1.2(marked@18.0.11): diff --git a/src/catalog.ts b/src/catalog.ts index a0a75db..53ac982 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -365,6 +365,10 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model api, baseUrl: endpoint(config.baseUrl, api), headers: undefined, + // CLIProxyAPI gives OAuth Claude caches a one-hour TTL. Only api/ routes keep Pi's five-minute refreshes. + ...(api === "anthropic-messages" && prefix !== "api" + ? { promptCache: { short: 3600, long: 3600 } } + : {}), compat: api === "anthropic-messages" ? { @@ -374,6 +378,8 @@ export function mapCatalog(value: unknown, config: Config, known: readonly Model supportsMidConvoSystemMessages: false, supportsMidConvoToolChanges: false, supportsMidConvoEffort: false, + // A route that drops the thread field would send only the newest messages. + supportsMessageThreads: false, allowedFallbackModels: undefined, } : api === "google-generative-ai" diff --git a/src/claude-quota.ts b/src/claude-quota.ts index 8780a55..9c7528a 100644 --- a/src/claude-quota.ts +++ b/src/claude-quota.ts @@ -182,7 +182,8 @@ export async function fetchClaudeQuota( Authorization: "Bearer $TOKEN$", "Content-Type": "application/json", "anthropic-beta": "oauth-2025-04-20", - "User-Agent": "claude-cli/2.1.280 (external, cli)", + // CLIProxyAPI 8.0.63 presents this Claude Code version for OAuth accounts. + "User-Agent": "claude-cli/2.1.321 (external, cli)", }, }); if (!isRecord(response) || typeof response.status_code !== "number") diff --git a/src/picker.ts b/src/picker.ts index d4145d4..73427d5 100644 --- a/src/picker.ts +++ b/src/picker.ts @@ -295,6 +295,7 @@ export function registerModelPicker(pi: ExtensionAPI, config: Config) { () => tui.requestRender(), done, ), + { blocked: { kind: "question", message: "Choose a CLIProxyAPI image or video model" } }, ); } else { const matches = searchPickerItems(items, query); diff --git a/src/provider.ts b/src/provider.ts index 3f4f641..ea729b0 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -161,6 +161,9 @@ function route( options?.env?.CLIPROXYAPI_MANAGEMENT_KEY ?? process.env.CLIPROXYAPI_MANAGEMENT_KEY ?? ""; const liveClaude = model.api === "anthropic-messages" && !backend && authType !== "api-key" && managementKey !== ""; + // Pi's cache warmer replays a request with these options. A delayed replay cannot keep a cache warm. + const warming = + options?.disableContinuation === true && options.maxTokens === 1 && options.maxRetries === 0; const adapterModel = google ? { ...model, id: metadataId } : model; // Google needs canonical IDs for tool turns. Swap identities to keep bare history cross-route. const adapterContext = google @@ -231,7 +234,7 @@ function route( let started = false; let retry = false; for await (const event of run()) { - if (event.type === "error" && event.reason === "error" && !started) { + if (event.type === "error" && event.reason === "error" && !started && !warming) { const resetAt = quotaRetryAt(event.error.errorMessage, response); const failure = quotaError(event.error.errorMessage); const upstream = @@ -320,7 +323,7 @@ export function createCliproxyProvider( let skipped: string[] = []; const standardAuth = envApiKeyAuth("CLIProxyAPI API key", ["CLIPROXYAPI_API_KEY"]); const scope = createHash("sha256") - .update(JSON.stringify([5, { baseUrl: config.baseUrl, aliases: config.aliases }])) + .update(JSON.stringify([6, { baseUrl: config.baseUrl, aliases: config.aliases }])) .digest("hex"); const provider = createProvider({ id: PROVIDER_ID, diff --git a/test/package.test.ts b/test/package.test.ts index d9cf537..7315898 100644 --- a/test/package.test.ts +++ b/test/package.test.ts @@ -35,6 +35,7 @@ test("npm package contains only release files and loads without checkout depende }) .sort(), [ + "CHANGELOG.md", "LICENSE", "README.md", "extensions/index.ts", @@ -54,7 +55,11 @@ test("npm package contains only release files and loads without checkout depende timeout: 10000, }); const manifest: unknown = JSON.parse(await readFile(join(directory, "package/package.json"), "utf8")); - assert.ok(isRecord(manifest) && Array.isArray(manifest.keywords)); + assert.ok(isRecord(manifest) && Array.isArray(manifest.keywords) && typeof manifest.version === "string"); + // After an update, Pi shows the changelog entries newer than the previously installed version. + const heading = `## [${manifest.version}] - `; + const changelog = await readFile(join(directory, "package/CHANGELOG.md"), "utf8"); + assert.ok(changelog.split("\n").some((line) => line.startsWith(heading))); for (const keyword of ["pi-package", "pi-extension", "cliproxyapi"]) assert.ok(manifest.keywords.includes(keyword)); assert.ok(isRecord(manifest.peerDependencies)); diff --git a/test/picker.test.ts b/test/picker.test.ts index 35f5de5..1bfcdf0 100644 --- a/test/picker.test.ts +++ b/test/picker.test.ts @@ -963,6 +963,7 @@ test("media picker and listings show derived defaults without footer output or u const sessionManager = SessionManager.inMemory(); let hooks = 0; let footerWrites = 0; + let opened: { rendered: string; options: Parameters[1] } | undefined; const output: string[] = []; let command: Parameters[1] | undefined; let gets = 0; @@ -997,16 +998,18 @@ test("media picker and listings show derived defaults without footer output or u setStatus() { footerWrites++; }, - async custom(factory: Parameters[0]) { + async custom( + factory: Parameters[0], + options?: Parameters[1], + ) { const picker = await factory( { terminal: { rows: 14 }, requestRender() {} } as never, theme, new KeybindingsManager(TUI_KEYBINDINGS) as never, () => {}, ); - const rendered = picker.render(200).join("\n"); - assert.match(rendered, /gpt-image-2.6-sunburst.*automatic default/); - assert.match(rendered, /gpt-image-2.6-sunburst.*selected/); + // The command reports errors thrown here, so the test asserts after the command returns. + opened = { rendered: picker.render(200).join("\n"), options }; return undefined; }, }, @@ -1027,6 +1030,11 @@ test("media picker and listings show derived defaults without footer output or u assert.match(output.at(-1) ?? "", /gpt-image-2.6-sunburst.*automatic default/); } await command.handler("", { ...ctx, mode: "tui" }); + assert.ok(opened); + assert.match(opened.rendered, /gpt-image-2.6-sunburst.*automatic default/); + assert.match(opened.rendered, /gpt-image-2.6-sunburst.*selected/); + // Terminals that show program status report Pi as waiting for input while the picker is open. + assert.equal(opened.options?.blocked?.kind, "question"); await command.handler(`select ${video}`, ctx); assert.deepEqual(readMediaDefaults(config, ctx), { video }); await command.handler("clear video", ctx); diff --git a/test/provider.test.ts b/test/provider.test.ts index 6689060..8d81298 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -288,6 +288,38 @@ test("Antigravity high IDs use exact metadata fallbacks without changing adverti } }); +test("Claude routes keep message threads off and report one-hour caches except on API keys", () => { + const claude: Model = { + ...known[0], + promptCache: { short: 300, long: 3600 }, + compat: { ...known[0].compat, supportsMessageThreads: true }, + }; + const ids = [ + claude.id, + ...["oauth", "vertex", "antigravity", "api"].map((prefix) => `${prefix}/${claude.id}`), + known[1].id, + ]; + const mapped = mapCatalog({ data: ids.map((id) => ({ id })) }, parseConfig({}), [ + claude, + ...known.slice(1), + ]); + assert.deepEqual( + mapped.models.map(({ id, promptCache }) => [id, promptCache]), + ids.map((id) => [ + id, + id === known[1].id + ? undefined + : id.startsWith("api/") + ? claude.promptCache + : { short: 3600, long: 3600 }, + ]), + ); + for (const model of mapped.models.slice(0, -1)) { + assert.ok(hasApi(model, "anthropic-messages")); + assert.equal(model.compat?.supportsMessageThreads, false); + } +}); + test("malformed catalogs fail rather than replacing a working list", () => { for (const value of [ null, @@ -1029,8 +1061,9 @@ function claudeCooldown(res: ServerResponse) { type: "error", error: { type: "rate_limit_error", + // CLIProxyAPI 8.0.58 and newer append the reset delay. message: - "All credentials for model claude-fixture are cooling down via provider claude (last error: rate_limit_error: This request would exceed your account's rate limit. Please try again later.)", + "All credentials for model claude-fixture are cooling down via provider claude (last error: rate_limit_error: This request would exceed your account's rate limit. Please try again later.) (reset after 15m0s)", }, }), ); @@ -1574,6 +1607,51 @@ test("late wake-ups cannot retry chat after the quota deadline even when live qu }); }); +test("cache-warming replays skip quota waits, and other requests without continuation still wait", async (t) => { + for (const [name, live, warming] of [ + ["header replay", false, true], + ["live replay", true, true], + ["request without continuation", false, false], + ] as const) + await t.test(name, async (t) => { + let requests = 0; + let waits = 0; + const controller = new AbortController(); + const baseUrl = await server(t, (req, res) => { + req.resume(); + requests++; + if (live) claudeCooldown(res); + else res.writeHead(429, { "retry-after": "900" }).end(); + }); + const seen = mockClaudeManagement(t, baseUrl, () => claudeUsage(Date.now() + 900000)); + const config = parseConfig({ baseUrl }); + const provider = createCliproxyProvider(config, known, (_id, seconds) => { + if (seconds === undefined) return; + waits++; + controller.abort(); + }); + const result = await provider + .streamSimple( + mapCatalog(catalog, config, known).models[0], + normalizeContext({ messages: [{ role: "user", content: "Return ok.", timestamp: 0 }] }), + { + apiKey: key, + // Pi's cache warmer replays the last request with a one-token cap and no retries. + maxTokens: warming ? 1 : 32, + maxRetries: 0, + disableContinuation: true, + signal: AbortSignal.any([controller.signal, AbortSignal.timeout(5000)]), + env: { CLIPROXYAPI_MANAGEMENT_KEY: live ? "fixture-management-key" : "" }, + }, + ) + .result(); + assert.equal(result.stopReason, warming ? "error" : "aborted", result.errorMessage); + assert.equal(waits, warming ? 0 : 1); + assert.equal(requests, 1); + assert.deepEqual(seen.urls, []); + }); +}); + test("native Anthropic requests retain Antigravity high IDs and separate thinking effort", async (t) => { const ids = ["claude-opus-5-5-high", "claude-sonnet-5-5-high"].flatMap((id) => [id, `antigravity/${id}`]); const seen: string[] = []; @@ -2189,6 +2267,32 @@ test("previous-policy caches cannot restore GPT-image chat aliases", async (t) = } }); +test("previous cache-policy catalogs cannot restore five-minute Claude refreshes or message threads", async (t) => { + t.mock.method(globalThis, "fetch", () => assert.fail("Offline restoration must not contact the proxy")); + const config = parseConfig({}); + for (const version of [5, 6]) { + const provider = createCliproxyProvider(config, known); + await provider.refreshModels({ + allowNetwork: false, + signal: AbortSignal.timeout(5000), + stored: { + etag: createHash("sha256") + .update(JSON.stringify([version, { baseUrl: config.baseUrl, aliases: config.aliases }])) + .digest("hex"), + models: [ + { ...known[0], provider: PROVIDER_ID, headers: undefined, promptCache: { short: 300, long: 3600 } }, + ], + }, + async publish(publication) { + publication.update?.(); + return true; + }, + }); + // The current policy restores the same entry, so only the version blocks the older cache. + assert.equal(provider.getModels().length, version === 6 ? 1 : 0); + } +}); + test("live Pi catalogs add newer metadata, bundled metadata wins, and xAI chat uses the proxy Responses route", () => { const bundled = builtinCatalog(); const openai = bundled.find((model) => model.provider === "openai");