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..6959128 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..."]); @@ -2351,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(); }); 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(