From bac48d9050e8e405e5799aefd2fca29c17891096 Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:17:40 -0500 Subject: [PATCH 1/2] Support Pi 1.5.2 and CLIProxyAPI 8.0.64 Accept model catalogs where a provider's owned_by name is empty or contains spaces, JPEG edit inputs with data after the end marker, and the default n and partial_images values on Google and xAI image models. Read streamed image previews in linear time, accept xai/ alias references, and share one bounded JSON body reader. Move development dependencies to Pi 1.5.2, Biome 2.6.6, and pnpm 12.18.0. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- CHANGELOG.md | 9 ++ biome.json | 2 +- package.json | 10 +- pnpm-lock.yaml | 170 +++++++++++++++++----------------- src/catalog.ts | 26 ++---- src/claude-quota.ts | 22 +---- src/config.ts | 24 ++++- src/media.ts | 53 +++++------ src/provider.ts | 2 - test/media.test.ts | 15 ++- test/provider.test.ts | 27 ++++++ 13 files changed, 198 insertions(+), 166 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7de1023..3f430fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,6 @@ jobs: node-version: ${{ matrix.node }} - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 with: - version: 12.16.1 + version: 12.18.0 - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cc736a2..a6a05f7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: node-version: 24 - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 with: - version: 12.16.1 + version: 12.18.0 - run: test "v$(jq -r .version package.json)" = "$GITHUB_REF_NAME" - run: pnpm install --frozen-lockfile --ignore-scripts - run: pnpm check diff --git a/CHANGELOG.md b/CHANGELOG.md index 507c2d9..2a72137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ For releases before 0.3.6, see the [GitHub releases](https://github.com/hawkff/pi-cliproxyapi-native/releases). +## [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. +- Image edits accept JPEG files with data after the end marker, such as Samsung camera photos and motion photos. Before, the extension rejected them as unsupported images. +- `cliproxyapi_generate_image` accepts `n: 1` and `partial_images: 0` on Google and xAI models. Both values are the defaults, and the extension rejected them there as unsupported. +- Large streamed image previews no longer freeze Pi. The extension copied the pending frame for each network chunk, so a 20 MiB preview and final image blocked Pi for over a second. Reading them now takes under 50 ms. +- Aliases accept `xai/` metadata references. +- The extension targets Pi 1.5.2 and CLIProxyAPI 8.0.64. + ## [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. diff --git a/biome.json b/biome.json index a11038a..c3b74f7 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.6.5/schema.json", + "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 110 }, "linter": { "enabled": true } diff --git a/package.json b/package.json index b6abcbd..9f4d034 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-cliproxyapi-native", - "version": "0.3.6", + "version": "0.3.7", "description": "CLIProxyAPI chat discovery, image generation and editing, and video generation with Pi's native adapters", "publishConfig": { "access": "public" @@ -39,10 +39,10 @@ "check": "pnpm typecheck && pnpm lint && pnpm test" }, "devDependencies": { - "@biomejs/biome": "^2.6.5", - "@earendil-works/pi-ai": "^1.5.1", - "@earendil-works/pi-coding-agent": "^1.5.1", - "@earendil-works/pi-tui": "^1.5.1", + "@biomejs/biome": "^2.6.6", + "@earendil-works/pi-ai": "^1.5.2", + "@earendil-works/pi-coding-agent": "^1.5.2", + "@earendil-works/pi-tui": "^1.5.2", "@types/node": "^24.19.2", "typebox": "1.3.27", "typescript": "7.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 565cd9e..68daddb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,17 +9,17 @@ importers: .: devDependencies: '@biomejs/biome': - specifier: ^2.6.5 - version: 2.6.5 + specifier: ^2.6.6 + version: 2.6.6 '@earendil-works/pi-ai': - 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) + specifier: ^1.5.2 + version: 1.5.2(@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.1 - version: 1.5.1(@aws-sdk/credential-provider-node@3.972.83)(@smithy/signature-v4@5.7.3)(ws@8.21.3) + specifier: ^1.5.2 + version: 1.5.2(@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.1 - version: 1.5.1 + specifier: ^1.5.2 + version: 1.5.2 '@types/node': specifier: ^24.19.2 version: 24.19.2 @@ -125,107 +125,107 @@ packages: resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.6.5': - resolution: {integrity: sha512-n0sp74QbSVEkWXlH659TKv4oJNylwAvDeZ8K8vt/PbcF1xneuNWe57z/G0Bxfh00cufhWXF6DiStLM8+jcLUpg==} + '@biomejs/biome@2.6.6': + resolution: {integrity: sha512-NCgVJRyrU1+hsW1ILNaRqNAb9fRL4n49bwyef+wjVJQ488/bXbp0BfnNqTTExkiNQT3ugkgYKR1vK8NxsjlNSQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-android-arm64@2.6.5': - resolution: {integrity: sha512-fkLzc1WRU7wRIUNmAm7cxILb4dd6eESz/VycWvVFKVn6tXx0gFQKdUbPnwT42xys8AEvIJQpFGcK00kURwqorg==} + '@biomejs/cli-android-arm64@2.6.6': + resolution: {integrity: sha512-bicnqNNxEX7pjkDQaptYvPRIWDfgXJLl/Ok7iZjC+F1AhfReor3FSS7SgPYnOvUy/jf++5U/KYN4T8OKH5rrNA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [android] - '@biomejs/cli-android-x64@2.6.5': - resolution: {integrity: sha512-fkZ9dPnDAzAcaPtPCiwqtkub5C9FQK/oGCGaU9sq+DysTaq13pE9FxBIMcEZ3ydn8/FfcMdXi/DDzKDP+qyn2Q==} + '@biomejs/cli-android-x64@2.6.6': + resolution: {integrity: sha512-N443kDSElF4kDMKjarsL+ymj/ewLA5R9VAdawA7fJ7pwgN3JVDZp0K+xhKBglH8EwsKi2+K8xC6WxXSkgziFfw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [android] - '@biomejs/cli-darwin-arm64@2.6.5': - resolution: {integrity: sha512-bKd0EOREgnVjeZm4s9knQ+WdPOkWS3JHB+eWGHlx+Pq66bqQA1/KybwU0O+zuyAyXiphSCBcrLruZt3YtnJoaQ==} + '@biomejs/cli-darwin-arm64@2.6.6': + resolution: {integrity: sha512-/vSAqWx3by3p8w5ew+HVJDgguuaTK3U6Fb5gk/oDUH21iWOopm3gOlhT6tZfy5mTaM5R4CVc3uFWkxBtEDrKyw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.6.5': - resolution: {integrity: sha512-UkT9BECNOXSiW6SGddjyaRACy362Sc9n3vDaTFkccL0bC2gW7JzM16i4+JKPbEaDn1Sa+oETHV8KungnI/y40Q==} + '@biomejs/cli-darwin-x64@2.6.6': + resolution: {integrity: sha512-lLdOAJKbrAoDs+3GvTyVmMW345D9IhrIE3GgoL2eeHwO1boN4VhrSlALtFYsP0p+Jy4udUp4bTguij0qjKrH8w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.6.5': - resolution: {integrity: sha512-kfc3AePW1a0/d/gUpcIz95nf3Ce8YY2/xL0Fk3fLqAhKsotqUi6PN6BaVWss4p6ijWbiuacZ9NQ4NiQtpjbYpw==} + '@biomejs/cli-linux-arm64-musl@2.6.6': + resolution: {integrity: sha512-rw2Vsoi/WZGDyRjMXx9KZH9zsHe79LIzkdWHkLqsIkeKZm6qiGxtiIApxn7cmJSoEPaSvHaH5pRj1FhTcyL/XA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [musl] - '@biomejs/cli-linux-arm64@2.6.5': - resolution: {integrity: sha512-H9cVMWBqW/lkFK9NHJdt0bpM2q7PacUDEjQoff9+m0dCV09H/jmBykfTCkbDthHf8+jCuWDJWiQFJ7bDkiGOcA==} + '@biomejs/cli-linux-arm64@2.6.6': + resolution: {integrity: sha512-UzJdUCg9dbEGpZOuZRyehEPrnwKjx9z4Um7sWLG5x6txu7UjeWdJZuBXVGlyAdD39w2Hx0nZeK1jKC4NFUTCNQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] libc: [glibc] - '@biomejs/cli-linux-x64-musl@2.6.5': - resolution: {integrity: sha512-0mHVpLNt7/FugfZ2VPugUIEIiooWIyHGGJUqU9StxamBln9hz+maXRFIECU50jaf5/ZB93veGIViN5VKgw2AEA==} + '@biomejs/cli-linux-x64-musl@2.6.6': + resolution: {integrity: sha512-DiKejs+GnFbOVQsBnukUpiB+T13tqUDFGwwjsTq51vatAsaflzFzVD0BOgjVUGRrqKgE0K0Xf+wNtkcmdN2chw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [musl] - '@biomejs/cli-linux-x64@2.6.5': - resolution: {integrity: sha512-e6/WMSN/+FhH+ZOoBevps/euxFQmv6BXqYNuTn1+ITR6O4XYFjHF5PeVSNZnjkgSYmvhcDkfepSX5lXczMijGA==} + '@biomejs/cli-linux-x64@2.6.6': + resolution: {integrity: sha512-l8/aPXc1cz8VpUyd4hb5Pm5zx+X3voQBks4aLYbp5pC8KSchuHyvqDfY5xax39qCHomI/2xn6NKEot+zebG38g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] libc: [glibc] - '@biomejs/cli-win32-arm64@2.6.5': - resolution: {integrity: sha512-uBbfpdR/cH5up1iRJFGAPP97xKMzrgMs685CSrY7NM+Q4Unw7chY0o3JulEvJdmU6c6hGQxwqotMq7DDH0DzxA==} + '@biomejs/cli-win32-arm64@2.6.6': + resolution: {integrity: sha512-DjVXwQAQeHrO1JEZAEus54k40Tnh4glyU3VFLcDBKwoz0xnxCEnqw6TCMXoujcQMQynWd3AY8bLrbR1o0XNqow==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.6.5': - resolution: {integrity: sha512-07LaCeN9vvmbAAMOMsUjMfL5UbsCDkvVeyMJIgwNRc0xYqhizBGkjVzx05LYEwCXl2AiHcdzqVPR4IOYAsI1QQ==} + '@biomejs/cli-win32-x64@2.6.6': + resolution: {integrity: sha512-B7KMG26A4I9lAuxwBGMmiVcjMGAeJRoz+pzFt9brRvOAsIrgOu8ptJld5rUWui6tM1uiJwxeJhhDPuAiZtxXdw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] - '@earendil-works/pi-agent-core@1.5.1': - resolution: {integrity: sha512-jD4qinlcwO3AoN/97v0AG3b0Umz/7hIBlEStmz6M3AQNicWOU+UK851fdDvzvCCukpQBSyOraEGRFMTgj+xGpA==} + '@earendil-works/pi-agent-core@1.5.2': + resolution: {integrity: sha512-ie5mejS7mGfXt//9As7i3LfxVXdktZvF1JP4EQsUXTp4mU437h23MNGx7wXZ5VYs3YiG/Qnj3e7OZ2HC069OMw==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-ai@1.5.1': - resolution: {integrity: sha512-ukMQ78DE1ypirehoWOrg32dZNEQsY/VXRETPJf5qgdpaw8abq/jlALpQpiEpRDyCkCyX/9FPiQRIazw7FA1FCw==} + '@earendil-works/pi-ai@1.5.2': + resolution: {integrity: sha512-2tWPSnV3qzfiDIY3aWluBag3vC++Vs+VsXt/UquSU5onPMohPih72FUhIhaqmDdMYubOgB3Byl9uF4Vk40JLvQ==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-codemode@1.5.1': - resolution: {integrity: sha512-xVuCpni7AuLPhvrN+oCP9Kcq0I2kY8SdtJRNMevQMmC2DcuQcgSWgOH2KOtuG0/Ihe1BZgPCahY4q5oiFMHw4g==} + '@earendil-works/pi-codemode@1.5.2': + resolution: {integrity: sha512-HoZQ40a9RgdZ1VgmrX6x8887cNXedaRJT1l+Ie5YRIMK4gY7v0EpsiNqf6dmixrKylZlTgUSyR+vE8xhjfkr+w==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-coding-agent@1.5.1': - resolution: {integrity: sha512-rdu4pRvpQba2ep83nqHaN8L1pvY1OQ3i/2FucogA/eyRxwoUXMUXdk1Z9JnZFJR+9ck9Gs1ajuSb9m7Hz7ejYw==} + '@earendil-works/pi-coding-agent@1.5.2': + resolution: {integrity: sha512-mUWI04rqF1Vd/m1q+Ps1XeWsTXEVvRUnsmRa83I3Dzua0Tx5bYS93imeqtIfrXf7PkkaR6ERz+AuQhIJlom5yA==} engines: {node: '>=22.19.0'} hasBin: true - '@earendil-works/pi-mcp@1.5.1': - resolution: {integrity: sha512-cUFhLnanLjBf8+NpHU+shJTzvcfLBjjDI02tI7hSmYnYqNfhqXhFPEkqT8G+/e+PX6r46DX1ZU54BTC5+p+MRA==} + '@earendil-works/pi-mcp@1.5.2': + resolution: {integrity: sha512-zU1UgeXDlbixPqGEWD7Ql0brEMIpYr7aEWjoEE5h5enJbM/kQ2jIr7lN8JoR80oUnq1fJSU7SC8kLx2Fx/IZYg==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-schema@1.5.1': - resolution: {integrity: sha512-QmvyJYVTva2ZT6/R2rFL64nmaiRmc6hCHv9ZoVNeyDhvLbRHIQLU8mI140vuX6JOnT7tCEd74GOyui/MIRF7ww==} + '@earendil-works/pi-schema@1.5.2': + resolution: {integrity: sha512-hJoyCXWUhsSrXY5wJo2Q4kHXUsR51pNhkJHAMkWRwVMLDRtMDPkKNaa+JlVRVqTw1hjdSxcVVaFLSDbKlaTXEQ==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-telemetry@1.5.1': - resolution: {integrity: sha512-uFi8XzG10gMgz9LggOlQW4CvEe/t1wZ6bwLChx36rmyZE7ogg/jHKTGekG73o6Rkwxi0bqE2AnTk+RZ4GmYQ2w==} + '@earendil-works/pi-telemetry@1.5.2': + resolution: {integrity: sha512-m+cumjIqNh1aMISi/9ttcG3ZOwIdriPbz/eMyWmRMJCGa/WcgY1RX4lrb7HvsHhSfI/JUeBESA4nOCr8C3y5ow==} engines: {node: '>=22.19.0'} - '@earendil-works/pi-tui@1.5.1': - resolution: {integrity: sha512-UZkaMYLuJrk2anJhZf06dNYdLWL8toqWNL807fhAorc8Lzy5vkxWNq/pC1f0CIM2EVdlctuRibWuIQGKYOgx+A==} + '@earendil-works/pi-tui@1.5.2': + resolution: {integrity: sha512-WK8ih+c88GWizaB79y7Pq3PV7ySf5VI1pZ1i/6aBBjDOAiG2EE3ojKjl+pxQqLfyQGnzHYu4VfakOBJgumuoyw==} engines: {node: '>=22.19.0'} '@google/genai@2.21.0': @@ -920,53 +920,53 @@ snapshots: '@babel/runtime@7.29.7': {} - '@biomejs/biome@2.6.5': + '@biomejs/biome@2.6.6': optionalDependencies: - '@biomejs/cli-android-arm64': 2.6.5 - '@biomejs/cli-android-x64': 2.6.5 - '@biomejs/cli-darwin-arm64': 2.6.5 - '@biomejs/cli-darwin-x64': 2.6.5 - '@biomejs/cli-linux-arm64': 2.6.5 - '@biomejs/cli-linux-arm64-musl': 2.6.5 - '@biomejs/cli-linux-x64': 2.6.5 - '@biomejs/cli-linux-x64-musl': 2.6.5 - '@biomejs/cli-win32-arm64': 2.6.5 - '@biomejs/cli-win32-x64': 2.6.5 - - '@biomejs/cli-android-arm64@2.6.5': + '@biomejs/cli-android-arm64': 2.6.6 + '@biomejs/cli-android-x64': 2.6.6 + '@biomejs/cli-darwin-arm64': 2.6.6 + '@biomejs/cli-darwin-x64': 2.6.6 + '@biomejs/cli-linux-arm64': 2.6.6 + '@biomejs/cli-linux-arm64-musl': 2.6.6 + '@biomejs/cli-linux-x64': 2.6.6 + '@biomejs/cli-linux-x64-musl': 2.6.6 + '@biomejs/cli-win32-arm64': 2.6.6 + '@biomejs/cli-win32-x64': 2.6.6 + + '@biomejs/cli-android-arm64@2.6.6': optional: true - '@biomejs/cli-android-x64@2.6.5': + '@biomejs/cli-android-x64@2.6.6': optional: true - '@biomejs/cli-darwin-arm64@2.6.5': + '@biomejs/cli-darwin-arm64@2.6.6': optional: true - '@biomejs/cli-darwin-x64@2.6.5': + '@biomejs/cli-darwin-x64@2.6.6': optional: true - '@biomejs/cli-linux-arm64-musl@2.6.5': + '@biomejs/cli-linux-arm64-musl@2.6.6': optional: true - '@biomejs/cli-linux-arm64@2.6.5': + '@biomejs/cli-linux-arm64@2.6.6': optional: true - '@biomejs/cli-linux-x64-musl@2.6.5': + '@biomejs/cli-linux-x64-musl@2.6.6': optional: true - '@biomejs/cli-linux-x64@2.6.5': + '@biomejs/cli-linux-x64@2.6.6': optional: true - '@biomejs/cli-win32-arm64@2.6.5': + '@biomejs/cli-win32-arm64@2.6.6': optional: true - '@biomejs/cli-win32-x64@2.6.5': + '@biomejs/cli-win32-x64@2.6.6': optional: true - '@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-agent-core@1.5.2(@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.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 + '@earendil-works/pi-ai': 1.5.2(@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.2 transitivePeerDependencies: - '@aws-sdk/credential-provider-node' - '@modelcontextprotocol/sdk' @@ -980,12 +980,12 @@ snapshots: - ws - zod - '@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-ai@1.5.2(@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.1 - '@earendil-works/pi-telemetry': 1.5.1 + '@earendil-works/pi-schema': 1.5.2 + '@earendil-works/pi-telemetry': 1.5.2 '@google/genai': 2.21.0 '@smithy/node-http-handler': 4.12.1 http-proxy-agent: 9.1.0 @@ -1005,17 +1005,17 @@ snapshots: - ws - zod - '@earendil-works/pi-codemode@1.5.1': + '@earendil-works/pi-codemode@1.5.2': dependencies: quickjs-wasi: 3.6.2 - '@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)': + '@earendil-works/pi-coding-agent@1.5.2(@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.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 + '@earendil-works/pi-agent-core': 1.5.2(@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.2(@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.2 + '@earendil-works/pi-mcp': 1.5.2 + '@earendil-works/pi-tui': 1.5.2 '@silvia-odwyer/photon-node': 0.3.4 brace-expansion: 5.0.12 chalk: 6.0.0 @@ -1045,15 +1045,15 @@ snapshots: - ws - zod - '@earendil-works/pi-mcp@1.5.1': + '@earendil-works/pi-mcp@1.5.2': dependencies: cross-spawn: 7.0.6 - '@earendil-works/pi-schema@1.5.1': {} + '@earendil-works/pi-schema@1.5.2': {} - '@earendil-works/pi-telemetry@1.5.1': {} + '@earendil-works/pi-telemetry@1.5.2': {} - '@earendil-works/pi-tui@1.5.1': + '@earendil-works/pi-tui@1.5.2': dependencies: get-east-asian-width: 1.6.0 marked: 18.0.11 diff --git a/src/catalog.ts b/src/catalog.ts index 53ac982..50dab6c 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,5 +1,5 @@ import type { Api, Model } from "@earendil-works/pi-ai"; -import { type Config, deadline, isModelId, isRecord, PROVIDER_ID, validateKey } from "./config.ts"; +import { type Config, deadline, isModelId, isRecord, PROVIDER_ID, readJson, validateKey } from "./config.ts"; export type CpaApi = | "anthropic-messages" @@ -26,11 +26,13 @@ export function parseCatalog(value: unknown) { if ( !isRecord(entry) || !isModelId(entry.id) || - (entry.owned_by !== undefined && !isModelId(entry.owned_by)) + (entry.owned_by !== undefined && typeof entry.owned_by !== "string") ) { throw new Error("Invalid CLIProxyAPI model catalog entry."); } - return { id: entry.id, owner: entry.owned_by, hidden: entry.visibility === "hide" }; + // 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" }; }); } @@ -52,31 +54,17 @@ export async function fetchCatalog(config: Config, key: string, signal: AbortSig await response.body?.cancel(); throw new Error(`CLIProxyAPI discovery failed (HTTP ${response.status}).`); } - let payload: unknown; const reader = response.body?.getReader(); if (!reader) throw new Error("CLIProxyAPI discovery returned an empty response."); try { - const decoder = new TextDecoder(); - let text = ""; - let size = 0; - while (true) { - const chunk = await reader.read(); - if (chunk.done) break; - size += chunk.value.byteLength; - if (size > 4 * 1024 * 1024) { - await reader.cancel(); - throw new Error("Catalog too large."); - } - text += decoder.decode(chunk.value, { stream: true }); - } - payload = JSON.parse(text + decoder.decode()); + return await readJson(reader, 4 * 1024 * 1024); } catch { + void reader.cancel().catch(() => {}); signal.throwIfAborted(); throw new Error("CLIProxyAPI catalog is invalid, incomplete, or larger than 4 MiB."); } finally { reader.releaseLock(); } - return payload; } // Resolve metadata and capabilities without changing registry or request IDs. diff --git a/src/claude-quota.ts b/src/claude-quota.ts index 9c7528a..7709946 100644 --- a/src/claude-quota.ts +++ b/src/claude-quota.ts @@ -1,6 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { modelRoute } from "./catalog.ts"; -import { type Config, isRecord } from "./config.ts"; +import { type Config, deadline, isRecord, readJson } from "./config.ts"; export type QuotaWaitHandler = (id: symbol, remainingSeconds: number | undefined) => void; @@ -36,21 +36,9 @@ async function managementRequest( const reader = response.body?.getReader(); if (!reader) throw new Error("The management API returned an empty response."); try { - const decoder = new TextDecoder(); - let text = ""; - let size = 0; - for (;;) { - const chunk = await reader.read(); - if (chunk.done) break; - size += chunk.value.byteLength; - if (size > 2 * 1024 * 1024) { - await reader.cancel(); - throw new Error("Response too large."); - } - text += decoder.decode(chunk.value, { stream: true }); - } - return JSON.parse(text + decoder.decode()) as unknown; + return await readJson(reader, 2 * 1024 * 1024); } catch { + void reader.cancel().catch(() => {}); throw new Error("The live quota response is invalid, incomplete, or larger than 2 MiB."); } finally { reader.releaseLock(); @@ -134,7 +122,7 @@ export async function fetchClaudeQuota( "Set CLIPROXYAPI_MANAGEMENT_KEY to read live Claude quota. The chat API key is not a management key.", ); } - const bounded = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(15000)]); + const bounded = deadline(signal, 15000); const listing = await managementRequest(config, key, "credentials", bounded); if (!isRecord(listing) || !Array.isArray(listing.files) || listing.files.length > 10000) { throw new Error("The management API returned an invalid account list."); @@ -182,7 +170,7 @@ export async function fetchClaudeQuota( Authorization: "Bearer $TOKEN$", "Content-Type": "application/json", "anthropic-beta": "oauth-2025-04-20", - // CLIProxyAPI 8.0.63 presents this Claude Code version for OAuth accounts. + // CLIProxyAPI 8.0.64 presents this Claude Code version for OAuth accounts. "User-Agent": "claude-cli/2.1.321 (external, cli)", }, }); diff --git a/src/config.ts b/src/config.ts index 3bd839d..b87c1f2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,26 @@ export function deadline(signal: AbortSignal | undefined, milliseconds: number) return AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(milliseconds)]); } +// Fetch and Node web-stream readers both satisfy this shape. +export type StreamReader = { + read(): Promise<{ done: boolean; value?: Uint8Array }>; + cancel(): Promise; +}; + +// Throws on a body over maxBytes or invalid JSON. The caller cancels the reader and reports the failure. +export async function readJson(reader: StreamReader, maxBytes: number) { + const chunks: Uint8Array[] = []; + for (let size = 0; ; ) { + const chunk = await reader.read(); + if (chunk.done || !chunk.value) break; + size += chunk.value.byteLength; + if (size > maxBytes) throw new Error("Response too large."); + chunks.push(chunk.value); + } + const value: unknown = JSON.parse(new TextDecoder().decode(Buffer.concat(chunks))); + return value; +} + export function normalizeBaseUrl(value: string) { let url: URL; try { @@ -65,10 +85,10 @@ export function parseConfig(value: unknown, baseUrlOverride?: string) { if ( separator < 1 || !isModelId(reference.slice(separator + 1)) || - !["anthropic", "openai", "openai-codex", "google"].includes(provider) + !["anthropic", "openai", "openai-codex", "google", "xai"].includes(provider) ) { throw new Error( - "CLIProxyAPI aliases require an anthropic, openai, openai-codex, or google reference.", + "CLIProxyAPI aliases require an anthropic, openai, openai-codex, google, or xai reference.", ); } return [id, reference] as const; diff --git a/src/media.ts b/src/media.ts index 2e8f619..7189b10 100644 --- a/src/media.ts +++ b/src/media.ts @@ -18,7 +18,15 @@ import { getBuiltinModels } from "@earendil-works/pi-ai/providers/all"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { type Static, Type } from "typebox"; import { fetchCatalog, mapMediaCatalog, mediaCapability, mediaControls } from "./catalog.ts"; -import { type Config, deadline, isRecord, PROVIDER_ID, validateKey } from "./config.ts"; +import { + type Config, + deadline, + isRecord, + PROVIDER_ID, + readJson, + type StreamReader, + validateKey, +} from "./config.ts"; import { type DefaultsContext, effectiveMediaDefaults, resolveMediaModel } from "./media-defaults.ts"; type MediaContext = Pick & @@ -177,9 +185,10 @@ function mediaOutputOptions(model: string, params: Partial; - cancel(): Promise; -}; // Fetch and Node web streams have incompatible DOM typings; both satisfy this shape. type ResponseBody = { getReader(): StreamReader & { releaseLock(): void }; cancel(): Promise }; @@ -554,11 +559,14 @@ async function readEvents( if (size > maxBytes) throw new Error("Response too large."); const text = carry + decoder.decode(chunk.value, { stream: true }); carry = text.endsWith("\r") ? "\r" : ""; - buffer += (carry ? text.slice(0, -1) : text).replace(/\r\n/g, "\n"); - for (let end = buffer.indexOf("\n\n", searched); end >= 0; end = buffer.indexOf("\n\n")) { - frame(buffer.slice(0, end)); - buffer = buffer.slice(end + 2); - } + const lines = (carry ? text.slice(0, -1) : text).replace(/\r\n/g, "\n"); + buffer += lines; + // Image data has no line breaks. Searching after a chunk without one would copy the whole pending frame. + if (lines.includes("\n")) + for (let end = buffer.indexOf("\n\n", searched); end >= 0; end = buffer.indexOf("\n\n")) { + frame(buffer.slice(0, end)); + buffer = buffer.slice(end + 2); + } searched = Math.max(0, buffer.length - 1); } frame(buffer + (carry + decoder.decode()).replace(/\r\n/g, "\n")); @@ -623,21 +631,7 @@ async function mediaRequest( } if (!reader) throw new Error("CLIProxyAPI media returned an empty response."); try { - if (onPartial) return await readEvents(reader, maxBytes, onPartial); - const chunks: Uint8Array[] = []; - let size = 0; - while (true) { - const chunk = await reader.read(); - if (chunk.done || !chunk.value) break; - size += chunk.value.byteLength; - if (size > maxBytes) { - await reader.cancel(); - throw new Error("Response too large."); - } - chunks.push(chunk.value); - } - const result: unknown = JSON.parse(Buffer.concat(chunks).toString("utf8")); - return result; + return onPartial ? await readEvents(reader, maxBytes, onPartial) : await readJson(reader, maxBytes); } catch { void reader.cancel().catch(() => {}); throw new Error( @@ -684,12 +678,11 @@ function imageType(bytes: Buffer) { extension = "png"; mimeType = "image/png"; } else if ( - bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff && - bytes.at(-2) === 0xff && - bytes.at(-1) === 0xd9 + // Cameras append data after the end marker, so it only has to be present. + bytes.includes("ffd9", 3, "hex") ) { extension = "jpg"; mimeType = "image/jpeg"; diff --git a/src/provider.ts b/src/provider.ts index ea729b0..a85f4ec 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -32,8 +32,6 @@ import { withCompactionRecovery } from "./compaction.ts"; import { type Config, isRecord, PROVIDER_ID, validateKey } from "./config.ts"; import { generateNativeImages } from "./media.ts"; -export type { QuotaWaitHandler } from "./claude-quota.ts"; - export function builtinCatalog() { return [ ...getBuiltinModels("anthropic"), diff --git a/test/media.test.ts b/test/media.test.ts index f918099..6382134 100644 --- a/test/media.test.ts +++ b/test/media.test.ts @@ -257,6 +257,8 @@ test("image responses reject malformed base64, unsupported bytes and URL-only re { data: [{ b64_json: "" }] }, { data: [{ b64_json: "!!!!" }] }, { data: [{ b64_json: "aGVsbG8=" }] }, + // A JPEG start without an end marker. + { data: [{ b64_json: "/9j/AAA=" }] }, { data: [{ b64_json: png }, { b64_json: png }] }, ]) { result = value; @@ -1567,7 +1569,12 @@ test("image and video output controls reach only the selected backend's wire fie } } for (const model of ids.slice(0, 3)) { - await generateImage(config, { model, prompt: "fixture", resolution: "2k", aspect_ratio: "20:9" }, ctx); + // One image and no previews are the defaults, so every route accepts them. + await generateImage( + config, + { model, prompt: "fixture", resolution: "2k", aspect_ratio: "20:9", n: 1, partial_images: 0 }, + ctx, + ); assert.deepEqual(requests.at(-1), { path: "/gateway/v1/images/generations", body: { @@ -1739,9 +1746,11 @@ test("OpenAI editing uploads bounded local files and a mask to the exact edit ro })}/gateway`, }); 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"); const files = [ ["source.png", png], - ["reference.jpg", jpeg], + ["reference.jpg", photo], ["mask.png", png], ]; for (const [name, data] of files) await writeFile(join(ctx.cwd, name), Buffer.from(data, "base64")); @@ -1766,7 +1775,7 @@ 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,${jpeg}` }], + images: [{ image_url: `data:image/png;base64,${png}` }, { image_url: `data:image/jpeg;base64,${photo}` }], mask: { image_url: `data:image/png;base64,${png}` }, }); assert.deepEqual(progress, ["Editing image...", "Saving image..."]); diff --git a/test/provider.test.ts b/test/provider.test.ts index 8d81298..fd69372 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -334,6 +334,13 @@ test("malformed catalogs fail rather than replacing a working list", () => { assert.throws(() => parseCatalog(value)); } assert.deepEqual(parseCatalog({ data: [] }), []); + // An owner is only a hint. The proxy reports a custom provider's configured name, which may be empty. + const named = { data: ["My Router", "", "claude"].map((owned_by) => ({ id: known[0].id, owned_by })) }; + assert.deepEqual( + parseCatalog(named).map(({ owner }) => owner), + [undefined, undefined, "claude"], + ); + assert.equal(mapCatalog(named, parseConfig({}), known).models[0].api, "anthropic-messages"); }); test("native refresh persists metadata, restores offline, keeps failures, and accepts an empty catalog", async (t) => { @@ -1099,6 +1106,24 @@ 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. + 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) => ({ + kind: "weekly_scoped", + is_active, + percent, + resets_at: reset, + scope: { model: { display_name: "Fable 5" } }, + }); + assert.equal(claudeQuotaState(fable, "claude-opus-fixture", now).state, "ready"); + assert.equal(claudeQuotaState(fable, "claude-fable-fixture", now).weeklyResetAt, now + 2 * 86400000); + for (const [limits, state] of [ + [[limit(10)], "ready"], + [[limit(10, false)], "blocked"], + [[{ ...limit(10), scope: { model: { display_name: "Opus" } } }], "blocked"], + ] as const) + assert.equal(claudeQuotaState({ ...fable, limits }, "claude-fable-fixture", now).state, state); for (const payload of [ {}, { five_hour: null }, @@ -2313,6 +2338,8 @@ test("live Pi catalogs add newer metadata, bundled metadata wins, and xAI chat u { id: grok.id, owned_by: "xai" }, { id: `api/${grok.id}` }, ]; + const aliased = parseConfig({ aliases: { "team-grok": `xai/${grok.id}` } }); + assert.equal(mapCatalog({ data: [{ id: "team-grok" }] }, aliased, merged).models[0].api, grok.api); const mapped = mapCatalog({ data }, parseConfig({}), merged); assert.deepEqual(mapped.skipped, []); assert.deepEqual( From b8a85440fa7c757cff4f78bf01562f79408b229b Mon Sep 17 00:00:00 2001 From: hawkff <109485367+hawkff@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:40:46 -0500 Subject: [PATCH 2/2] Deliver the streamed test frame in separate chunks Node 24 and newer hand writes from one turn to the reader as one chunk, so the frame split in the streaming test reached the reader whole. A pause before each part keeps the chunks without a line break apart. --- test/media.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/media.test.ts b/test/media.test.ts index 6382134..6959128 100644 --- a/test/media.test.ts +++ b/test/media.test.ts @@ -2360,14 +2360,18 @@ test("partial previews stream over the image transport and only the final image // Split the CRLF separator between two writes so the reader sees CR and LF in separate chunks. const invalid = { type: "image_generation.partial_image", b64_json: "!!!!", partial_image_index: 1 }; res.write(`event: ${invalid.type}\r\ndata: ${JSON.stringify(invalid)}\r\n\r`); - void sleep(25).then(() => { + void sleep(25).then(async () => { res.write("\n"); if (mode === "error") send({ type: "error", error: { message: `Moderation blocked ${key}` } }); else if (mode === "success") { const usage = { input_tokens: 10, output_tokens: 300, total_tokens: 310 }; const frame = `data: ${JSON.stringify({ type: "image_generation.completed", b64_json: png, usage })}\n\n`; - res.write(frame.slice(0, 20)); - res.write(frame.slice(20)); + // Node 24 and newer deliver writes from one turn as one chunk, so a pause keeps the parts apart. + // The first two have no line break, like the image data in a large frame. + for (const part of [frame.slice(0, 20), frame.slice(20, 60), frame.slice(60)]) { + await sleep(25); + res.write(part); + } } res.end(); });