diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1b2cba1..302b925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,32 @@ jobs: - name: Typecheck run: bun run typecheck + lint: + name: check (lint) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Lint + run: bun run lint + + format: + name: check (format) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - name: Install dependencies + run: bun install --frozen-lockfile + - name: Format + run: bun run format:check + pack: name: check (pack) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 15dfddb..cb7f1af 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dist/ .DS_Store opencode.local.json .commandcode/ + +# workit: SDD working state (never commit) +docs/*/sdd/ diff --git a/README.md b/README.md index b7852c2..0fd11c7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - Bundled `models.json` is the default runtime catalog (no local CLI scrape). - CLI cost extraction can fail (as on `command-code@1.38.x`) without dropping models. -- Official docs fill missing costs; leftover models use a conservative fallback (`degraded` in `manifest.json`). +- Official docs fill missing costs; remaining paid gaps use [models.dev](https://models.dev) as a reference. Command Code free SKUs stay `$0`. - Reasoning effort **variants** on models that declare `reasoningEfforts`. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). @@ -74,14 +74,14 @@ Maintainers only. OpenCode will scrape a local `command-code` install when `COMM git clone https://github.com/BrainerVirus/opencode-commandcode-provider.git cd opencode-commandcode-provider bun install -bun test tests/unit/ +bun run check # oxlint + oxfmt + bun test + tsc (same stack as workit) ``` ```bash bun run sync -- --remote # refresh models.json + manifest.json from command-code@latest ``` -CI (`.github/workflows/catalog-sync.yml`) opens a PR every 6 hours when Command Code ships a new catalog. Merge after **check (test)**, **check (typecheck)**, and **check (pack)** are green. `.github/workflows/release.yml` then runs **semantic-release** (npm publish + GitHub Release + tag). Do not push to `main`. +CI (`.github/workflows/catalog-sync.yml`) opens a PR every 6 hours when Command Code ships a new catalog. Merge after **check (test)**, **check (typecheck)**, **check (lint)**, **check (format)**, and **check (pack)** are green. `.github/workflows/release.yml` then runs **semantic-release** (npm publish + GitHub Release + tag). Do not push to `main`. The GitHub Actions secret name is `NPMJS` (same as workit). It is mapped to both `NPM_TOKEN` and `NODE_AUTH_TOKEN`. Use an npm **Automation** token (bypasses 2FA). A login token from `~/.npmrc` fails CI with `EOTP`. Catalog PRs get a real CI run when `RELEASE_SYNC_TOKEN` (or `CATALOG_PUSH_TOKEN`) is a PAT; `GITHUB_TOKEN` can open the PR but GitHub will not start workflows from that event. diff --git a/bun.lock b/bun.lock index cb18086..b7ff817 100644 --- a/bun.lock +++ b/bun.lock @@ -11,6 +11,8 @@ "@semantic-release/release-notes-generator": "14.1.1", "@types/node": "^25.9.1", "ai": "6.0.191", + "oxfmt": "0.63.0", + "oxlint": "1.78.0", "semantic-release": "25.0.9", "typescript": "^6.0.3", }, @@ -64,6 +66,82 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.63.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.63.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.63.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.63.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], + "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], @@ -346,6 +424,10 @@ "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], + "oxfmt": ["oxfmt@0.63.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.63.0", "@oxfmt/binding-android-arm64": "0.63.0", "@oxfmt/binding-darwin-arm64": "0.63.0", "@oxfmt/binding-darwin-x64": "0.63.0", "@oxfmt/binding-freebsd-x64": "0.63.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.63.0", "@oxfmt/binding-linux-arm-musleabihf": "0.63.0", "@oxfmt/binding-linux-arm64-gnu": "0.63.0", "@oxfmt/binding-linux-arm64-musl": "0.63.0", "@oxfmt/binding-linux-ppc64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-musl": "0.63.0", "@oxfmt/binding-linux-s390x-gnu": "0.63.0", "@oxfmt/binding-linux-x64-gnu": "0.63.0", "@oxfmt/binding-linux-x64-musl": "0.63.0", "@oxfmt/binding-openharmony-arm64": "0.63.0", "@oxfmt/binding-win32-arm64-msvc": "0.63.0", "@oxfmt/binding-win32-ia32-msvc": "0.63.0", "@oxfmt/binding-win32-x64-msvc": "0.63.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw=="], + + "oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="], + "p-each-series": ["p-each-series@3.0.0", "", {}, "sha512-lastgtAdoH9YaLyDa5i5z64q+kzOcQHsQ5SsZJD3q0VEyI8mq872S3geuNbRUQLVAE9siMfgKrpj7MloKFHruw=="], "p-event": ["p-event@6.0.1", "", { "dependencies": { "p-timeout": "^6.1.2" } }, "sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w=="], @@ -458,7 +540,7 @@ "super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="], - "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], "supports-hyperlinks": ["supports-hyperlinks@3.2.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig=="], @@ -478,6 +560,8 @@ "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], "traverse": ["traverse@0.6.8", "", {}, "sha512-aXJDbk6SnumuaZSANd21XAo15ucCDE38H4fkqiGsc3MhCK+wOlZvLP9cB/TvpHT0mOyWgC4Z8EwRlzqYSUzdsA=="], @@ -552,6 +636,8 @@ "execa/get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "fdir/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "load-json-file/parse-json": ["parse-json@4.0.0", "", { "dependencies": { "error-ex": "^1.3.1", "json-parse-better-errors": "^1.0.1" } }, "sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw=="], @@ -860,6 +946,8 @@ "signale/figures": ["figures@2.0.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-Oa2M9atig69ZkfwiApY8F2Yy+tzMbazyvqv21R0NsSC8floSOC09BbT1ITWAdoMGQvJ/aZnR1KMwdx9tvHnTNA=="], + "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tempy/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], "tempy/type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], @@ -874,6 +962,8 @@ "cli-highlight/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cli-highlight/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "cli-highlight/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], "cli-highlight/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], diff --git a/docs/2026-08-28-ci-catalog/spec.md b/docs/2026-08-28-ci-catalog/spec.md index d1b21e6..255ed04 100644 --- a/docs/2026-08-28-ci-catalog/spec.md +++ b/docs/2026-08-28-ci-catalog/spec.md @@ -15,7 +15,7 @@ Watch `command-code` on npm every 6 hours, refresh the bundled catalog, publish - Tag + GitHub Release **after** successful `npm publish` only. Never tag a version that is not on the registry. - Catalog auto-commit uses `GITHUB_TOKEN` (or `CATALOG_PUSH_TOKEN` if protection blocks it). Those commits do **not** trigger other workflows, so catalog-sync must publish in the **same job**. - Human merges to `main` run a separate release job that publishes only if `package.json` version is unpublished (code fixes). -- Cost-only CLI failure still ships (`degraded` if fallback/unmatched costs remain). Model extract failure → no publish, `catalog-break` issue. +- Cost-only CLI failure still ships (`degraded` only if unmatched placeholder costs remain). Model extract failure → no publish, `catalog-break` issue. - Runtime catalog stays bundled `models.json`. No GitHub fetch at OpenCode startup. - Hybrid OpenCode transport stays `@ai-sdk/openai-compatible` + Provider API; this package is the **plugin**, not the SDK `npm` field. diff --git a/docs/specs/2026-08-28-ci-catalog-automation.md b/docs/specs/2026-08-28-ci-catalog-automation.md index 67aba99..b935caa 100644 --- a/docs/specs/2026-08-28-ci-catalog-automation.md +++ b/docs/specs/2026-08-28-ci-catalog-automation.md @@ -61,6 +61,7 @@ Human- and CI-readable metadata about the bundled catalog. Shipped with the plug "cli": 40, "officialDocs": 25, "thirdParty": 0, + "free": 0, "fallback": 0, "unmatched": 0 }, @@ -71,18 +72,18 @@ Human- and CI-readable metadata about the bundled catalog. Shipped with the plug Write order: bump `package.json` version first, then write `manifest.json` with that `pluginVersion`, then one commit. Do not store `gitCommit` in the file (unknown until after commit; put SHA on the GitHub Release instead). `status` rules: -- `healthy`: model catalog ok **and** every model has costs from `cli`, `officialDocs`, or `thirdParty` (`fallback` = 0 and `unmatched` = 0) -- `degraded`: model catalog ok, but at least one model used hardcoded fallback or has no cost +- `healthy`: model catalog ok **and** every model has a sourced price (`cli`, `officialDocs`, `thirdParty` / models.dev, or `free`). `unmatched` = 0 +- `degraded`: model catalog ok, but at least one model still has the unmatched placeholder `{ input: 0.5, output: 2 }` - `broken`: model catalog extraction failed -`extraction.costCatalog` is the **best** source that contributed (`cli` | `docs` | `thirdParty` | `fallback` | `missing`). +`extraction.costCatalog` is the **best** source that contributed (`cli` | `docs` | `thirdParty` | `free` | `fallback` | `missing`). Status values: | status | Meaning | CI action | |---|---|---| -| `healthy` | models ok; costs from CLI, official docs, and/or trusted API | release | -| `degraded` | models ok; some costs are hardcoded fallback or missing | release + note which models fell through | +| `healthy` | models ok; costs from CLI, official docs, models.dev, and/or free SKUs | release | +| `degraded` | models ok; some costs have no listed source | release + note which models fell through | | `broken` | model catalog extraction failed | no release, open issue | ### `_version.txt` (existing) @@ -100,10 +101,11 @@ Waterfall, per model, first hit wins. Later steps only fill models still missing - Preferred parse target: [https://commandcode.ai/models](https://commandcode.ai/models) (per-model table: Input/M, Output/M, Cache read, Cache write). - Fallback page: [https://commandcode.ai/docs/resources/pricing-limits](https://commandcode.ai/docs/resources/pricing-limits) if `/models` fetch or parse fails. - Store the **current billed** per-1M USD rates shown on the page (deal-adjusted when the page shows an effective price). Do not invent deal math. -3. **Trusted third-party API** — only if a provider is configured (`COST_ENRICHMENT_API_URL` secret/env). Default: **none**. Skip this step until one is added. Do not use OpenRouter (or similar) unless explicitly configured; their list prices are not Command Code’s billed rates. -4. **Hardcoded fallback** (`FALLBACK_COSTS` + existing default `{ input: 0.5, output: 2 }`) — last resort so the catalog still ships. +3. **Free SKUs** — catalog id/name matching `\bfree\b` or id ending `-free` → `{ input: 0, output: 0 }`. Runs **before** models.dev so Command Code free SKUs stay $0 even when models.dev lists a paid twin (e.g. Tencent Hy3). +4. **models.dev** — `GET https://models.dev/api.json` for remaining **paid** gaps. Exact id (case-insensitive), then last path segment as id, then exact display name. Reference prices, not Command Code billed rates. Do not apply to free SKUs. +5. **Unmatched placeholder** `{ input: 0.5, output: 2 }` — last resort so the catalog still ships. These are the only models that mark the catalog `degraded`. -Match docs/API rows to catalog models with **exact** id (case-insensitive) then **exact** display name (case-insensitive). No fuzzy matching. Unmatched models go to the next step. +Match docs/API rows to catalog models with **exact** id (case-insensitive) then **exact** display name (case-insensitive). models.dev also tries the last `/` segment of the catalog id as an exact id. No fuzzy matching. Unmatched models go to the next step. Never fail the sync because costs are incomplete. Model catalog remains the hard requirement. @@ -126,8 +128,8 @@ Steps: 3. Download tarball (reuse logic from `scripts/sync-models.ts --remote`). 4. Extract models (required) then run the **cost waterfall**: - model catalog fail → status `broken`, stop (no commit, no publish). - - CLI costs fail or partial → continue; fill gaps from official docs, then optional trusted API, then hardcoded fallback. - - Record `costSources` on the manifest. `degraded` only if any model still used fallback/unmatched. + - CLI costs fail or partial → continue; fill gaps from official docs, then free SKUs ($0), then models.dev, then unmatched placeholder. + - Record `costSources` on the manifest. `degraded` only if any model is still unmatched. 5. Sanity floor: `modelCount >= max(20, floor(lastSuccessfulModelCount * 0.5))`. `lastSuccessfulModelCount` is `modelCount` from the last committed manifest with `status` `healthy` or `degraded`. Fail as `broken` if below. If no prior manifest, use `20`. 6. Write `models.json`, `_version.txt`, bump patch in `package.json`, write `manifest.json`. 7. Run unit tests (including 1.38 costless fixture). @@ -171,7 +173,7 @@ When a subsequent sync succeeds after manual fix: | Event | Version bump | Publish | |---|---|---| | New command-code catalog (healthy) | patch | yes | -| New command-code catalog (degraded: some fallback costs) | patch | yes | +| New command-code catalog (degraded: unmatched placeholder costs) | patch | yes | | Model extraction broken | none | no | | Plugin code fix (manual PR) | patch/minor per semver | yes (manual or on merge) | @@ -255,7 +257,6 @@ Commit **extracted snapshots** (model JSON + expected counts/errors), not the pr | `NPM_TOKEN` | publish plugin package (`brainervirus`) | | `GITHUB_TOKEN` | commit, release, issues | | `CATALOG_PUSH_TOKEN` | optional; only if branch protection blocks `GITHUB_TOKEN` | -| `COST_ENRICHMENT_API_URL` | optional; skip third-party cost step when unset | If `main` is protected against `GITHUB_TOKEN`, set `CATALOG_PUSH_TOKEN`. One push path only. @@ -263,7 +264,7 @@ If `main` is protected against `GITHUB_TOKEN`, set `CATALOG_PUSH_TOKEN`. One pus - User can run OpenCode with **no** global/local `command-code` install and get current models from the installed plugin (npm package, or a `file://` checkout that has been synced). - Within 6 hours of a new `command-code` npm release, CI either commits a catalog update (and publishes a plugin patch if `NPM_TOKEN` is set) or opens/updates a `catalog-break` issue. -- Cost-only CLI regressions (like 1.38) still ship a catalog. Costs come from official docs when the CLI map fails; `degraded` only if docs/API also miss models. +- Cost-only CLI regressions (like 1.38) still ship a catalog. Costs come from official docs, then free SKUs ($0), then models.dev; `degraded` only if models remain unmatched. - Successful sync never requires local `bun run sync` from the user. - Failed model extraction never publishes a misleading npm release. diff --git a/index.ts b/index.ts index 7635fa8..c1cb194 100644 --- a/index.ts +++ b/index.ts @@ -1,20 +1,20 @@ -import { resolveApiKey } from "./src/auth.js" -import { CommandCodeLanguageModel } from "./src/model.js" +import { resolveApiKey } from "./src/auth.js"; +import { CommandCodeLanguageModel } from "./src/model.js"; export interface CommandCodeProviderOptions { - name?: string - apiKey?: string - baseURL?: string - headers?: Record - authPaths?: string[] + name?: string; + apiKey?: string; + baseURL?: string; + headers?: Record; + authPaths?: string[]; } export function createCommandCode(options: CommandCodeProviderOptions = {}) { - const apiKey = resolveApiKey({ apiKey: options.apiKey, authPaths: options.authPaths }) + const apiKey = resolveApiKey({ apiKey: options.apiKey, authPaths: options.authPaths }); if (!apiKey) { throw new Error( "Command Code API key not found. Set COMMANDCODE_API_KEY env var, create ~/.commandcode/auth.json, or pass apiKey option.", - ) + ); } return { @@ -22,8 +22,11 @@ export function createCommandCode(options: CommandCodeProviderOptions = {}) { return new CommandCodeLanguageModel(modelId, { apiKey, baseURL: typeof options.baseURL === "string" ? options.baseURL : undefined, - headers: typeof options.headers === "object" && options.headers !== null ? options.headers as Record : undefined, - }) + headers: + typeof options.headers === "object" && options.headers !== null + ? (options.headers as Record) + : undefined, + }); }, - } + }; } diff --git a/manifest.json b/manifest.json index 21ae74e..7703701 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "generatedAt": "2026-08-28T19:09:12.385Z", + "generatedAt": "2026-08-28T19:50:57.386Z", "pluginVersion": "0.5.0", "commandCodeVersion": "1.38.1", "commandCodeTarball": "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", @@ -13,10 +13,22 @@ }, "costSources": { "cli": 0, - "officialDocs": 53, + "officialDocs": 60, "thirdParty": 0, - "fallback": 7, - "unmatched": 5 + "free": 5, + "fallback": 0, + "unmatched": 0 }, - "status": "degraded" + "review": { + "thirdParty": [], + "free": [ + "inclusionai/ling-3.0-flash-free", + "minimax/minimax-m2.7-free", + "MiniMaxAI/MiniMax-M3-Free", + "minimax/minimax-m3-free", + "tencent/Hy3" + ], + "unmatched": [] + }, + "status": "healthy" } diff --git a/models.json b/models.json index fc053b3..bc8195a 100644 --- a/models.json +++ b/models.json @@ -793,8 +793,8 @@ "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 0, + "output": 0 }, "limit": { "context": 256000, @@ -872,8 +872,8 @@ "reasoning": false, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 0, + "output": 0 }, "limit": { "context": 197000, @@ -903,8 +903,8 @@ "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 0, + "output": 0 }, "limit": { "context": 1000000, @@ -918,8 +918,8 @@ "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 0, + "output": 0 }, "limit": { "context": 1000000, @@ -1193,8 +1193,8 @@ "reasoning": true, "tool_call": true, "cost": { - "input": 0.5, - "output": 2 + "input": 0, + "output": 0 }, "limit": { "context": 262144, diff --git a/package.json b/package.json index 4d8af39..49e99cd 100644 --- a/package.json +++ b/package.json @@ -1,22 +1,46 @@ { "name": "@brainervirus/commandcode-go-opencode-provider", "version": "0.5.0", + "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code", + "keywords": [ + "ai", + "claude", + "commandcode", + "deepseek", + "gemini", + "glm", + "gpt", + "kimi", + "llm", + "minimax", + "opencode", + "opencode-plugin", + "opencode-provider", + "qwen" + ], + "homepage": "https://github.com/BrainerVirus/opencode-commandcode-provider#readme", + "bugs": { + "url": "https://github.com/BrainerVirus/opencode-commandcode-provider/issues" + }, + "license": "MIT", "author": "Brent Weatherall", "repository": { "type": "git", "url": "git+https://github.com/BrainerVirus/opencode-commandcode-provider.git" }, + "files": [ + "index.ts", + "plugin.ts", + "models.json", + "manifest.json", + "_version.txt", + "README.md", + "CHANGELOG.md", + "src/" + ], + "type": "module", "main": "index.ts", - "devDependencies": { - "@semantic-release/commit-analyzer": "13.0.1", - "@semantic-release/github": "12.0.9", - "@semantic-release/npm": "13.1.5", - "@semantic-release/release-notes-generator": "14.1.1", - "@types/node": "^25.9.1", - "ai": "6.0.191", - "semantic-release": "25.0.9", - "typescript": "^6.0.3" - }, + "types": "index.ts", "exports": { ".": { "import": "./index.ts" @@ -25,36 +49,15 @@ "import": "./plugin.ts" } }, - "bugs": { - "url": "https://github.com/BrainerVirus/opencode-commandcode-provider/issues" - }, - "description": "Command Code API provider for opencode — use Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, and Step models via Command Code", - "engines": { - "bun": ">=1.0.0" - }, - "files": ["index.ts", "plugin.ts", "models.json", "manifest.json", "_version.txt", "README.md", "CHANGELOG.md", "src/"], - "homepage": "https://github.com/BrainerVirus/opencode-commandcode-provider#readme", - "keywords": [ - "opencode", - "opencode-provider", - "opencode-plugin", - "commandcode", - "ai", - "llm", - "claude", - "gpt", - "gemini", - "deepseek", - "qwen", - "kimi", - "glm", - "minimax" - ], - "license": "MIT", "publishConfig": { "access": "public" }, "scripts": { + "lint": "oxlint --deny-warnings src scripts plugin.ts index.ts tests", + "lint:fix": "oxlint --fix src scripts plugin.ts index.ts tests", + "format": "oxfmt src scripts plugin.ts index.ts tests package.json tsconfig.json release.config.cjs", + "format:check": "oxfmt --check src scripts plugin.ts index.ts tests package.json tsconfig.json release.config.cjs", + "check": "bun run lint && bun run format:check && bun test tests/unit/ && bun run typecheck", "test": "bun test tests/unit/", "test:integration": "bun test tests/integration/", "test:all": "bun test", @@ -65,9 +68,22 @@ "sync:global": "bun run scripts/sync-models.ts --update-global", "catalog:ci": "bun run scripts/catalog-sync-ci.ts" }, - "type": "module", - "types": "index.ts", + "devDependencies": { + "@semantic-release/commit-analyzer": "13.0.1", + "@semantic-release/github": "12.0.9", + "@semantic-release/npm": "13.1.5", + "@semantic-release/release-notes-generator": "14.1.1", + "@types/node": "^25.9.1", + "ai": "6.0.191", + "oxfmt": "0.63.0", + "oxlint": "1.78.0", + "semantic-release": "25.0.9", + "typescript": "^6.0.3" + }, "peerDependencies": { "ai": ">=6.0.0" + }, + "engines": { + "bun": ">=1.0.0" } } diff --git a/plugin.ts b/plugin.ts index 1693c56..bb8b346 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,70 +1,75 @@ -import { readFileSync, existsSync } from "fs" -import { homedir } from "os" -import { join, dirname } from "path" -import { fileURLToPath } from "url" +import { readFileSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; import { generateOpencodeModels, loadCatalogFromLocalCommandCode, type ModelEntry, -} from "./src/catalog.js" +} from "./src/catalog.js"; import { readCatalogCache, writeCatalogCache, writeStartupSummary, pluginStateDir, -} from "./src/startup.js" -import type { CatalogManifest } from "./src/manifest.js" +} from "./src/startup.js"; +import type { CatalogManifest } from "./src/manifest.js"; -const __dirname = dirname(fileURLToPath(import.meta.url)) -const MODELS_PATH = join(__dirname, "models.json") -const VERSION_PATH = join(__dirname, "_version.txt") -const MANIFEST_PATH = join(__dirname, "manifest.json") +const __dirname = dirname(fileURLToPath(import.meta.url)); +const MODELS_PATH = join(__dirname, "models.json"); +const VERSION_PATH = join(__dirname, "_version.txt"); +const MANIFEST_PATH = join(__dirname, "manifest.json"); interface PluginFileConfig { - disableModelSync?: boolean - commandCodePackagePath?: string - debugStartupLogs?: boolean + disableModelSync?: boolean; + commandCodePackagePath?: string; + debugStartupLogs?: boolean; } function loadPluginConfig(): PluginFileConfig { - const configPath = join(homedir(), ".config", "opencode", "commandcode-go-opencode-provider.json") - if (!existsSync(configPath)) return {} + const configPath = join( + homedir(), + ".config", + "opencode", + "commandcode-go-opencode-provider.json", + ); + if (!existsSync(configPath)) return {}; try { - return JSON.parse(readFileSync(configPath, "utf-8")) + return JSON.parse(readFileSync(configPath, "utf-8")); } catch { - return {} + return {}; } } -export type { ModelEntry } +export type { ModelEntry }; function loadBundledModels(): ModelEntry[] | null { try { - return JSON.parse(readFileSync(MODELS_PATH, "utf-8")) + return JSON.parse(readFileSync(MODELS_PATH, "utf-8")); } catch { - return null + return null; } } function readBundledManifest(): CatalogManifest | null { - if (!existsSync(MANIFEST_PATH)) return null + if (!existsSync(MANIFEST_PATH)) return null; try { - return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as CatalogManifest + return JSON.parse(readFileSync(MANIFEST_PATH, "utf-8")) as CatalogManifest; } catch { - return null + return null; } } function readBundledVersion(): string | null { - const manifest = readBundledManifest() - if (manifest?.commandCodeVersion) return manifest.commandCodeVersion - if (!existsSync(VERSION_PATH)) return null + const manifest = readBundledManifest(); + if (manifest?.commandCodeVersion) return manifest.commandCodeVersion; + if (!existsSync(VERSION_PATH)) return null; try { - const parts = readFileSync(VERSION_PATH, "utf-8").split("\n") - const first = parts[0]?.trim() - return first || null + const parts = readFileSync(VERSION_PATH, "utf-8").split("\n"); + const first = parts[0]?.trim(); + return first || null; } catch { - return null + return null; } } @@ -72,76 +77,78 @@ export default async function commandcodePlugin() { return { config: async (config: Record) => { if (!(config as Record).provider) { - ;(config as Record).provider = { commandcode: {} } + (config as Record).provider = { commandcode: {} }; } - const cc = ((config as Record).provider as Record>) - ?.commandcode as Record | undefined - if (!cc) return + const cc = ( + (config as Record).provider as Record> + )?.commandcode as Record | undefined; + if (!cc) return; - const pluginCfg = loadPluginConfig() - const debug = pluginCfg.debugStartupLogs === true + const pluginCfg = loadPluginConfig(); + const debug = pluginCfg.debugStartupLogs === true; const override = pluginCfg.commandCodePackagePath?.trim() || process.env.COMMANDCODE_PACKAGE_PATH?.trim() || - "" + ""; - if (!cc.npm) cc.npm = "commandcode-go-opencode-provider" - if (!cc.name) cc.name = "Command Code" - if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"] + if (!cc.npm) cc.npm = "commandcode-go-opencode-provider"; + if (!cc.name) cc.name = "Command Code"; + if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"]; - if (cc.models) return + if (cc.models) return; - let models: ModelEntry[] = [] - let catalogSource: "bundled" | "cache" | "opt-in-local" = "bundled" - let commandCodeVersion: string | null = null - let degraded = false - let degradedReason: string | null = null + let models: ModelEntry[] = []; + let catalogSource: "bundled" | "cache" | "opt-in-local" = "bundled"; + let commandCodeVersion: string | null = null; + let degraded = false; + let degradedReason: string | null = null; if (override) { - const localCatalog = loadCatalogFromLocalCommandCode({ packagePath: override }) + const localCatalog = loadCatalogFromLocalCommandCode({ packagePath: override }); if (localCatalog && localCatalog.models.length > 0) { - models = localCatalog.models - catalogSource = "opt-in-local" - commandCodeVersion = localCatalog.version + models = localCatalog.models; + catalogSource = "opt-in-local"; + commandCodeVersion = localCatalog.version; } } if (models.length === 0) { - const bundled = loadBundledModels() + const bundled = loadBundledModels(); if (bundled) { - models = bundled - catalogSource = "bundled" - commandCodeVersion = readBundledVersion() - const manifest = readBundledManifest() + models = bundled; + catalogSource = "bundled"; + commandCodeVersion = readBundledVersion(); + const manifest = readBundledManifest(); if (manifest?.status === "degraded" || manifest?.status === "broken") { - degraded = true - degradedReason = manifest.status === "broken" - ? "bundled catalog marked broken" - : "bundled catalog costs include fallback or unmatched models" + degraded = true; + degradedReason = + manifest.status === "broken" + ? "bundled catalog marked broken" + : "bundled catalog has models with no listed price"; } } else { - const cached = readCatalogCache() + const cached = readCatalogCache(); if (cached) { - models = cached - catalogSource = "cache" - degraded = true - degradedReason = "bundled models.json unreadable; using last-good cache" + models = cached; + catalogSource = "cache"; + degraded = true; + degradedReason = "bundled models.json unreadable; using last-good cache"; } else { - degraded = true - degradedReason = "no bundled catalog and no cache" + degraded = true; + degradedReason = "no bundled catalog and no cache"; } } } if (models.length > 0) { try { - writeCatalogCache(pluginStateDir(), models) + writeCatalogCache(pluginStateDir(), models); } catch { // ignore cache write } } - cc.models = generateOpencodeModels(models) + cc.models = generateOpencodeModels(models); const summary = { catalogSource, @@ -150,14 +157,14 @@ export default async function commandcodePlugin() { reasoningModelCount: models.filter((m) => m.reasoning).length, degraded, degradedReason, - } + }; try { - writeStartupSummary(pluginStateDir(), summary) + writeStartupSummary(pluginStateDir(), summary); } catch { // ignore } if (debug) { - console.warn("[commandcode]", JSON.stringify(summary)) + console.warn("[commandcode]", JSON.stringify(summary)); } }, @@ -168,24 +175,24 @@ export default async function commandcodePlugin() { type: "api", label: "API Key", authorize: async (inputs: Record | undefined) => { - const rawKey = inputs?.key - if (typeof rawKey !== "string") return { type: "failed" as const } - const key = rawKey.trim() - if (!key) return { type: "failed" as const } - return { type: "success" as const, key } + const rawKey = inputs?.key; + if (typeof rawKey !== "string") return { type: "failed" as const }; + const key = rawKey.trim(); + if (!key) return { type: "failed" as const }; + return { type: "success" as const, key }; }, }, ], loader: async (getAuth: () => Promise<{ type: string; key?: string } | null>) => { try { - const auth = await getAuth() - if (!auth) return {} - if (auth.type === "api" && auth.key) return { apiKey: auth.key } - return {} + const auth = await getAuth(); + if (!auth) return {}; + if (auth.type === "api" && auth.key) return { apiKey: auth.key }; + return {}; } catch { - return {} + return {}; } }, }, - } + }; } diff --git a/scripts/catalog-release-notes.ts b/scripts/catalog-release-notes.ts index b24bb1e..31e97e4 100644 --- a/scripts/catalog-release-notes.ts +++ b/scripts/catalog-release-notes.ts @@ -1,18 +1,11 @@ #!/usr/bin/env bun -import { readFileSync } from "fs" -import { join } from "path" -import { FALLBACK_COSTS } from "../src/catalog.js" -import { catalogReleaseNotesFromFiles } from "../src/catalog-release-notes.js" -import type { CatalogManifest } from "../src/manifest.js" -import type { PricedModel } from "../src/catalog-release-notes.js" +import { readFileSync } from "fs"; +import { join } from "path"; +import { catalogReleaseNotesFromFiles } from "../src/catalog-release-notes.js"; +import type { CatalogManifest } from "../src/manifest.js"; +import type { PricedModel } from "../src/catalog-release-notes.js"; -const ROOT = join(import.meta.dir, "..") -const manifest = JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf-8")) as CatalogManifest -const models = JSON.parse(readFileSync(join(ROOT, "models.json"), "utf-8")) as PricedModel[] -process.stdout.write( - catalogReleaseNotesFromFiles({ - manifest, - models, - fallbackCosts: FALLBACK_COSTS, - }), -) +const ROOT = join(import.meta.dir, ".."); +const manifest = JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf-8")) as CatalogManifest; +const models = JSON.parse(readFileSync(join(ROOT, "models.json"), "utf-8")) as PricedModel[]; +process.stdout.write(catalogReleaseNotesFromFiles({ manifest, models })); diff --git a/scripts/catalog-sync-ci.ts b/scripts/catalog-sync-ci.ts index 754cd12..9ecf03a 100644 --- a/scripts/catalog-sync-ci.ts +++ b/scripts/catalog-sync-ci.ts @@ -1,169 +1,170 @@ #!/usr/bin/env bun -import { existsSync, readFileSync } from "fs" -import { join } from "path" -import { execSync } from "child_process" -import { catalogBreakTitle, renderCatalogBreakBody } from "../src/catalog-break.js" +import { existsSync, readFileSync } from "fs"; +import { join } from "path"; +import { execSync } from "child_process"; +import { catalogBreakTitle, renderCatalogBreakBody } from "../src/catalog-break.js"; import { lastSuccessfulModelCount, meetsModelCountFloor, type CatalogManifest, -} from "../src/manifest.js" -import { decideCatalogSync } from "../src/publish-policy.js" -import { npmLatestVersion, npmPackageVersions } from "./npm-registry.js" +} from "../src/manifest.js"; +import { decideCatalogSync } from "../src/publish-policy.js"; +import { npmLatestVersion, npmPackageVersions } from "./npm-registry.js"; -const ROOT = join(import.meta.dir, "..") -const CATALOG_FILES = ["models.json", "_version.txt", "manifest.json"] -const CATALOG_BRANCH = "chore/catalog-sync" +const ROOT = join(import.meta.dir, ".."); +const CATALOG_FILES = ["models.json", "_version.txt", "manifest.json"]; +const CATALOG_BRANCH = "chore/catalog-sync"; function readJson(path: string): T | null { - if (!existsSync(path)) return null + if (!existsSync(path)) return null; try { - return JSON.parse(readFileSync(path, "utf-8")) as T + return JSON.parse(readFileSync(path, "utf-8")) as T; } catch { - return null + return null; } } function bundledCommandCodeVersion(): string | null { - const path = join(ROOT, "_version.txt") - if (!existsSync(path)) return null - return readFileSync(path, "utf-8").split("\n")[0]?.trim() || null + const path = join(ROOT, "_version.txt"); + if (!existsSync(path)) return null; + return readFileSync(path, "utf-8").split("\n")[0]?.trim() || null; } function git(args: string): string { - return execSync(`git ${args}`, { cwd: ROOT, encoding: "utf-8" }).trim() + return execSync(`git ${args}`, { cwd: ROOT, encoding: "utf-8" }).trim(); } -function openOrUpdateCatalogBreak(input: { - commandCodeVersion: string - error: string -}): void { - const title = catalogBreakTitle(input.commandCodeVersion) +function openOrUpdateCatalogBreak(input: { commandCodeVersion: string; error: string }): void { + const title = catalogBreakTitle(input.commandCodeVersion); const body = renderCatalogBreakBody({ commandCodeVersion: input.commandCodeVersion, error: input.error, - workflowUrl: process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID - ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` - : "(local)", + workflowUrl: + process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID + ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` + : "(local)", bundledCommandCodeVersion: bundledCommandCodeVersion(), - }) + }); const existing = execSync( `gh issue list --label catalog-break --state open --json number,title`, { cwd: ROOT, encoding: "utf-8" }, - ) - const issues = JSON.parse(existing) as Array<{ number: number; title: string }> + ); + const issues = JSON.parse(existing) as Array<{ number: number; title: string }>; if (issues.length === 0) { - execSync(`gh issue create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --label catalog-break --label automation`, { - cwd: ROOT, - stdio: "inherit", - }) - return + execSync( + `gh issue create --title ${JSON.stringify(title)} --body ${JSON.stringify(body)} --label catalog-break --label automation`, + { + cwd: ROOT, + stdio: "inherit", + }, + ); + return; } execSync(`gh issue comment ${issues[0].number} --body ${JSON.stringify(body)}`, { cwd: ROOT, stdio: "inherit", - }) + }); } function openCatalogPr(commandCodeVersion: string): void { - const status = git(`status --porcelain -- ${CATALOG_FILES.join(" ")}`) + const status = git(`status --porcelain -- ${CATALOG_FILES.join(" ")}`); if (!status) { - console.log("catalog files unchanged") - return + console.log("catalog files unchanged"); + return; } - git('config user.name "github-actions[bot]"') - git('config user.email "41898282+github-actions[bot]@users.noreply.github.com"') - execSync(`git checkout -B ${CATALOG_BRANCH}`, { cwd: ROOT, stdio: "inherit" }) - execSync(`git add ${CATALOG_FILES.join(" ")}`, { cwd: ROOT, stdio: "inherit" }) + git('config user.name "github-actions[bot]"'); + git('config user.email "41898282+github-actions[bot]@users.noreply.github.com"'); + execSync(`git checkout -B ${CATALOG_BRANCH}`, { cwd: ROOT, stdio: "inherit" }); + execSync(`git add ${CATALOG_FILES.join(" ")}`, { cwd: ROOT, stdio: "inherit" }); execSync( `git commit -m ${JSON.stringify(`fix(catalog): sync command-code@${commandCodeVersion}`)}`, { cwd: ROOT, stdio: "inherit" }, - ) - execSync(`git push -u origin ${CATALOG_BRANCH} --force`, { cwd: ROOT, stdio: "inherit" }) - const existing = execSync( - `gh pr list --head ${CATALOG_BRANCH} --base main --json number`, - { cwd: ROOT, encoding: "utf-8" }, - ) - const prs = JSON.parse(existing) as Array<{ number: number }> + ); + execSync(`git push -u origin ${CATALOG_BRANCH} --force`, { cwd: ROOT, stdio: "inherit" }); + const existing = execSync(`gh pr list --head ${CATALOG_BRANCH} --base main --json number`, { + cwd: ROOT, + encoding: "utf-8", + }); + const prs = JSON.parse(existing) as Array<{ number: number }>; if (prs.length > 0) { - console.log(`updated catalog PR #${prs[0].number}`) - return + console.log(`updated catalog PR #${prs[0].number}`); + return; } execSync( `gh pr create --base main --head ${CATALOG_BRANCH} --title ${JSON.stringify(`fix(catalog): sync command-code@${commandCodeVersion}`)} --body ${JSON.stringify(`Automated catalog refresh from command-code@${commandCodeVersion}. Merge after CI is green; semantic-release publishes the patch.`)}`, { cwd: ROOT, stdio: "inherit" }, - ) + ); } async function main(): Promise { - const force = process.env.FORCE === "true" || process.argv.includes("--force") - const pkgPath = join(ROOT, "package.json") - const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name: string; version: string } - const latestCc = await npmLatestVersion("command-code") - const published = await npmPackageVersions(pkg.name) + const force = process.env.FORCE === "true" || process.argv.includes("--force"); + const pkgPath = join(ROOT, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { name: string; version: string }; + const latestCc = await npmLatestVersion("command-code"); + const published = await npmPackageVersions(pkg.name); const decision = decideCatalogSync({ force, latestCommandCodeVersion: latestCc, bundledCommandCodeVersion: bundledCommandCodeVersion(), pluginVersion: pkg.version, publishedPluginVersions: published, - }) + }); - console.log(JSON.stringify({ latestCc, pluginVersion: pkg.version, decision })) + console.log(JSON.stringify({ latestCc, pluginVersion: pkg.version, decision })); if (!decision.extract) { - console.log("no catalog extract (release job owns unpublished plugin versions)") - return + console.log("no catalog extract (release job owns unpublished plugin versions)"); + return; } - const prior = readJson(join(ROOT, "manifest.json")) + const prior = readJson(join(ROOT, "manifest.json")); const beforeModels = existsSync(join(ROOT, "models.json")) ? readFileSync(join(ROOT, "models.json"), "utf-8") - : "" - const beforeVersion = bundledCommandCodeVersion() + : ""; + const beforeVersion = bundledCommandCodeVersion(); try { - execSync("bun run scripts/sync-models.ts -- --remote", { cwd: ROOT, stdio: "inherit" }) + execSync("bun run scripts/sync-models.ts -- --remote", { cwd: ROOT, stdio: "inherit" }); } catch (err) { - const message = err instanceof Error ? err.message : String(err) + const message = err instanceof Error ? err.message : String(err); try { - openOrUpdateCatalogBreak({ commandCodeVersion: latestCc, error: message }) + openOrUpdateCatalogBreak({ commandCodeVersion: latestCc, error: message }); } catch (issueErr) { - console.error("failed to open catalog-break issue", issueErr) + console.error("failed to open catalog-break issue", issueErr); } - process.exitCode = 1 - return + process.exitCode = 1; + return; } - const afterModels = readFileSync(join(ROOT, "models.json"), "utf-8") - const entries = JSON.parse(afterModels) as unknown[] - const lastCount = lastSuccessfulModelCount(prior) + const afterModels = readFileSync(join(ROOT, "models.json"), "utf-8"); + const entries = JSON.parse(afterModels) as unknown[]; + const lastCount = lastSuccessfulModelCount(prior); if (!meetsModelCountFloor(entries.length, lastCount)) { - const message = `model count ${entries.length} below floor (lastSuccessful=${lastCount})` + const message = `model count ${entries.length} below floor (lastSuccessful=${lastCount})`; try { - openOrUpdateCatalogBreak({ commandCodeVersion: latestCc, error: message }) + openOrUpdateCatalogBreak({ commandCodeVersion: latestCc, error: message }); } catch (issueErr) { - console.error("failed to open catalog-break issue", issueErr) + console.error("failed to open catalog-break issue", issueErr); } - execSync(`git checkout -- ${CATALOG_FILES.join(" ")}`, { cwd: ROOT, stdio: "inherit" }) - process.exitCode = 1 - return + execSync(`git checkout -- ${CATALOG_FILES.join(" ")}`, { cwd: ROOT, stdio: "inherit" }); + process.exitCode = 1; + return; } - const changed = afterModels !== beforeModels || bundledCommandCodeVersion() !== beforeVersion + const changed = afterModels !== beforeModels || bundledCommandCodeVersion() !== beforeVersion; if (!changed) { - console.log("extract produced no catalog changes") - return + console.log("extract produced no catalog changes"); + return; } if (process.env.CI === "true") { - openCatalogPr(bundledCommandCodeVersion() ?? latestCc) + openCatalogPr(bundledCommandCodeVersion() ?? latestCc); } } if (import.meta.main) { main().catch((err) => { - console.error(err) - process.exit(1) - }) + console.error(err); + process.exit(1); + }); } diff --git a/scripts/generate-readme.ts b/scripts/generate-readme.ts index 1e018d5..fec3472 100644 --- a/scripts/generate-readme.ts +++ b/scripts/generate-readme.ts @@ -1,15 +1,11 @@ -import { readFileSync } from "fs" -import { join } from "path" +import { readFileSync } from "fs"; +import { join } from "path"; -const ROOT = join(import.meta.dir, "..") -const MODELS_JSON = join(ROOT, "models.json") +const ROOT = join(import.meta.dir, ".."); +const MODELS_JSON = join(ROOT, "models.json"); -const models: unknown[] = JSON.parse(readFileSync(MODELS_JSON, "utf-8")) -const withEfforts = models.filter( - (m: any) => m.reasoningEfforts?.length > 0, -).length +const models: unknown[] = JSON.parse(readFileSync(MODELS_JSON, "utf-8")); +const withEfforts = models.filter((m: any) => m.reasoningEfforts?.length > 0).length; -console.log( - `models.json: ${models.length} models (${withEfforts} with reasoning efforts)`, -) -console.log("The README no longer contains a model table — check models.json directly.") +console.log(`models.json: ${models.length} models (${withEfforts} with reasoning efforts)`); +console.log("The README no longer contains a model table — check models.json directly."); diff --git a/scripts/npm-registry.ts b/scripts/npm-registry.ts index 39ef605..2e756de 100644 --- a/scripts/npm-registry.ts +++ b/scripts/npm-registry.ts @@ -1,18 +1,18 @@ export async function npmPackageVersions(name: string): Promise { - const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`) - if (res.status === 404) return [] - if (!res.ok) throw new Error(`npm registry returned ${res.status} for ${name}`) - const body = (await res.json()) as { versions?: Record } - return Object.keys(body.versions ?? {}) + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}`); + if (res.status === 404) return []; + if (!res.ok) throw new Error(`npm registry returned ${res.status} for ${name}`); + const body = (await res.json()) as { versions?: Record }; + return Object.keys(body.versions ?? {}); } export async function npmLatestVersion(name: string): Promise { - const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`) - if (!res.ok) throw new Error(`npm registry returned ${res.status} for ${name}@latest`) - const body = (await res.json()) as { version: string } - return body.version + const res = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name)}/latest`); + if (!res.ok) throw new Error(`npm registry returned ${res.status} for ${name}@latest`); + const body = (await res.json()) as { version: string }; + return body.version; } export function npmTokenSet(): boolean { - return Boolean(process.env.NPM_TOKEN?.trim() || process.env.NODE_AUTH_TOKEN?.trim()) + return Boolean(process.env.NPM_TOKEN?.trim() || process.env.NODE_AUTH_TOKEN?.trim()); } diff --git a/scripts/semantic-release-catalog-notes.cjs b/scripts/semantic-release-catalog-notes.cjs index 4d5134a..50f84e5 100644 --- a/scripts/semantic-release-catalog-notes.cjs +++ b/scripts/semantic-release-catalog-notes.cjs @@ -1,10 +1,10 @@ -const { execFileSync } = require("node:child_process") -const { join } = require("node:path") +const { execFileSync } = require("node:child_process"); +const { join } = require("node:path"); module.exports = { generateNotes() { return execFileSync("bun", [join(__dirname, "catalog-release-notes.ts")], { encoding: "utf8", - }) + }); }, -} +}; diff --git a/scripts/sync-models.ts b/scripts/sync-models.ts index 8ce3e52..364d290 100644 --- a/scripts/sync-models.ts +++ b/scripts/sync-models.ts @@ -1,191 +1,224 @@ -import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync } from "fs" -import { join } from "path" -import { homedir, tmpdir } from "os" -import { execSync } from "child_process" +import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, statSync } from "fs"; +import { join } from "path"; +import { homedir, tmpdir } from "os"; +import { execSync } from "child_process"; import { NPM_PACKAGE, - FALLBACK_COSTS, extractCostData, buildCostMap, generateOpencodeModels, loadCatalogFromBundle, loadCatalogFromLocalCommandCode, type ModelEntry, -} from "../src/catalog.js" -import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "../src/costs-docs.js" +} from "../src/catalog.js"; +import { applyDocCosts, fetchOfficialModelsMarkdown, parseModelsTable } from "../src/costs-docs.js"; +import { + applyFreeCosts, + applyModelsDevCosts, + fetchModelsDevJson, + parseModelsDev, +} from "../src/costs-models-dev.js"; import { buildManifest, commandCodeTarballUrl, countCostSources, writeManifest, -} from "../src/manifest.js" +} from "../src/manifest.js"; function cliCostIds(source: string): Set { try { - return new Set(buildCostMap(extractCostData(source)).keys()) + return new Set(buildCostMap(extractCostData(source)).keys()); } catch { - return new Set() + return new Set(); } } -const PROJECT_ROOT = join(import.meta.dir, "..") -const MODELS_JSON = join(PROJECT_ROOT, "models.json") -const VERSION_PATH = join(PROJECT_ROOT, "_version.txt") -const MANIFEST_PATH = join(PROJECT_ROOT, "manifest.json") -const PACKAGE_JSON = join(PROJECT_ROOT, "package.json") -const GLOBAL_CONFIG = join(homedir(), ".config", "opencode", "opencode.jsonc") -const TMP_DIR = join(tmpdir(), "cc-model-sync") +const PROJECT_ROOT = join(import.meta.dir, ".."); +const MODELS_JSON = join(PROJECT_ROOT, "models.json"); +const VERSION_PATH = join(PROJECT_ROOT, "_version.txt"); +const MANIFEST_PATH = join(PROJECT_ROOT, "manifest.json"); +const PACKAGE_JSON = join(PROJECT_ROOT, "package.json"); +const GLOBAL_CONFIG = join(homedir(), ".config", "opencode", "opencode.jsonc"); +const TMP_DIR = join(tmpdir(), "cc-model-sync"); async function fetchLatestBundle(): Promise<{ source: string; version: string }> { - console.log(`Fetching latest ${NPM_PACKAGE} metadata...`) - const metaResp = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`) - if (!metaResp.ok) throw new Error(`npm registry returned ${metaResp.status}`) - const meta = await metaResp.json() - const version = meta.version as string - const tarball = meta.dist.tarball as string - console.log(` Latest version: ${version}`) - console.log(` Tarball: ${tarball}`) - - mkdirSync(TMP_DIR, { recursive: true }) - const tgzPath = join(TMP_DIR, `${NPM_PACKAGE}.tgz`) - - console.log("Downloading tarball...") - const tarballResp = await fetch(tarball) - if (!tarballResp.ok) throw new Error(`tarball download returned ${tarballResp.status}`) - const buffer = Buffer.from(await tarballResp.arrayBuffer()) - writeFileSync(tgzPath, buffer) - - console.log("Extracting...") - execSync(`tar -xzf "${tgzPath}" -C "${TMP_DIR}"`, { stdio: "pipe" }) - - const pkgRoot = join(TMP_DIR, "package") - const candidates = [ - join(pkgRoot, "dist", "cli.mjs"), - join(pkgRoot, "dist", "index.mjs"), - ] - const bundlePath = candidates.find((p) => existsSync(p) && statSync(p).size >= 4096) - ?? candidates.find((p) => existsSync(p)) - if (!bundlePath) throw new Error(`Bundle not found under ${pkgRoot}/dist`) - - const source = readFileSync(bundlePath, "utf-8") - - rmSync(TMP_DIR, { recursive: true, force: true }) - - return { source, version } + console.log(`Fetching latest ${NPM_PACKAGE} metadata...`); + const metaResp = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE}/latest`); + if (!metaResp.ok) throw new Error(`npm registry returned ${metaResp.status}`); + const meta = await metaResp.json(); + const version = meta.version as string; + const tarball = meta.dist.tarball as string; + console.log(` Latest version: ${version}`); + console.log(` Tarball: ${tarball}`); + + mkdirSync(TMP_DIR, { recursive: true }); + const tgzPath = join(TMP_DIR, `${NPM_PACKAGE}.tgz`); + + console.log("Downloading tarball..."); + const tarballResp = await fetch(tarball); + if (!tarballResp.ok) throw new Error(`tarball download returned ${tarballResp.status}`); + const buffer = Buffer.from(await tarballResp.arrayBuffer()); + writeFileSync(tgzPath, buffer); + + console.log("Extracting..."); + execSync(`tar -xzf "${tgzPath}" -C "${TMP_DIR}"`, { stdio: "pipe" }); + + const pkgRoot = join(TMP_DIR, "package"); + const candidates = [join(pkgRoot, "dist", "cli.mjs"), join(pkgRoot, "dist", "index.mjs")]; + const bundlePath = + candidates.find((p) => existsSync(p) && statSync(p).size >= 4096) ?? + candidates.find((p) => existsSync(p)); + if (!bundlePath) throw new Error(`Bundle not found under ${pkgRoot}/dist`); + + const source = readFileSync(bundlePath, "utf-8"); + + rmSync(TMP_DIR, { recursive: true, force: true }); + + return { source, version }; } function stripJsonc(input: string): string { - let out = "" - let i = 0 + let out = ""; + let i = 0; while (i < input.length) { - const ch = input[i] + const ch = input[i]; if (ch === '"') { - const start = i - i++ + const start = i; + i++; while (i < input.length && input[i] !== '"') { - if (input[i] === "\\") i++ - i++ + if (input[i] === "\\") i++; + i++; } - i++ - out += input.slice(start, i) + i++; + out += input.slice(start, i); } else if (ch === "/" && input[i + 1] === "/") { - while (i < input.length && input[i] !== "\n") i++ + while (i < input.length && input[i] !== "\n") i++; } else if (ch === "/" && input[i + 1] === "*") { - i += 2 - while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++ - i += 2 + i += 2; + while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++; + i += 2; } else { - out += ch - i++ + out += ch; + i++; } } - return out.replace(/,\s*([}\]])/g, "$1") + return out.replace(/,\s*([}\]])/g, "$1"); } function updateGlobalConfig(modelsObj: Record) { if (!existsSync(GLOBAL_CONFIG)) { - console.log(` Global config not found at ${GLOBAL_CONFIG}, skipping`) - return + console.log(` Global config not found at ${GLOBAL_CONFIG}, skipping`); + return; } - const raw = readFileSync(GLOBAL_CONFIG, "utf-8") - const jsonStr = stripJsonc(raw) + const raw = readFileSync(GLOBAL_CONFIG, "utf-8"); + const jsonStr = stripJsonc(raw); - let config: any + let config: any; try { - config = JSON.parse(jsonStr) + config = JSON.parse(jsonStr); } catch { - console.error(" Failed to parse global config as JSON after stripping comments") - return + console.error(" Failed to parse global config as JSON after stripping comments"); + return; } - if (!config.provider) config.provider = {} + if (!config.provider) config.provider = {}; if (!config.provider.commandcode) { config.provider.commandcode = { npm: "commandcode-go-opencode-provider", name: "Command Code", env: ["COMMANDCODE_API_KEY"], - } + }; } - config.provider.commandcode.models = modelsObj + config.provider.commandcode.models = modelsObj; - const output = JSON.stringify(config, null, 2) + "\n" - writeFileSync(GLOBAL_CONFIG, output, "utf-8") - console.log(` Updated ${GLOBAL_CONFIG}`) + const output = JSON.stringify(config, null, 2) + "\n"; + writeFileSync(GLOBAL_CONFIG, output, "utf-8"); + console.log(` Updated ${GLOBAL_CONFIG}`); } async function main() { - const args = process.argv.slice(2) - const shouldUpdateGlobal = args.includes("--update-global") - const forceRemote = args.includes("--remote") + const args = process.argv.slice(2); + const shouldUpdateGlobal = args.includes("--update-global"); + const forceRemote = args.includes("--remote"); - let entries: ModelEntry[] - let version: string - let sourceLabel: string - let bundleSource: string | null = null + let entries: ModelEntry[]; + let version: string; + let sourceLabel: string; + let bundleSource: string | null = null; - const local = !forceRemote ? loadCatalogFromLocalCommandCode() : null + const local = !forceRemote ? loadCatalogFromLocalCommandCode() : null; if (local) { - entries = local.models - version = local.version - bundleSource = local.bundleSource - sourceLabel = `local ${local.root}` - console.log(`Loaded catalog from local command-code@${version}`) - console.log(` Path: ${local.root}`) - console.log(` Models: ${entries.length}`) + entries = local.models; + version = local.version; + bundleSource = local.bundleSource; + sourceLabel = `local ${local.root}`; + console.log(`Loaded catalog from local command-code@${version}`); + console.log(` Path: ${local.root}`); + console.log(` Models: ${entries.length}`); } else { - const bundle = await fetchLatestBundle() - version = bundle.version - bundleSource = bundle.source - sourceLabel = `npm tarball v${version}` - console.log(`Read CLI bundle v${version} (${(bundle.source.length / 1024).toFixed(0)} KB)`) - console.log("Extracting model catalog...") - entries = loadCatalogFromBundle(bundle.source) - console.log(` Found ${entries.length} models`) + const bundle = await fetchLatestBundle(); + version = bundle.version; + bundleSource = bundle.source; + sourceLabel = `npm tarball v${version}`; + console.log(`Read CLI bundle v${version} (${(bundle.source.length / 1024).toFixed(0)} KB)`); + console.log("Extracting model catalog..."); + entries = loadCatalogFromBundle(bundle.source); + console.log(` Found ${entries.length} models`); } - const cliIds = bundleSource ? cliCostIds(bundleSource) : new Set() - const docIds = new Set() - const docsMd = await fetchOfficialModelsMarkdown() + const cliIds = bundleSource ? cliCostIds(bundleSource) : new Set(); + const docIds = new Set(); + const docsMd = await fetchOfficialModelsMarkdown(); if (docsMd) { - const filled = applyDocCosts(entries, parseModelsTable(docsMd), cliIds, docIds) - console.log(` Applied official-docs costs to ${filled} models (${cliIds.size} kept CLI costs)`) + const filled = applyDocCosts(entries, parseModelsTable(docsMd), cliIds, docIds); + console.log( + ` Applied official-docs costs to ${filled} models (${cliIds.size} kept CLI costs)`, + ); } else { - console.log(" Official docs returned no parseable cost table; keeping CLI/fallback costs") + console.log(" Official docs returned no parseable cost table; keeping CLI costs"); + } + + const priced = new Set([...cliIds, ...docIds]); + const freeIds = new Set(); + const freeFilled = applyFreeCosts(entries, priced, freeIds); + console.log(` Applied free SKU $0 to ${freeFilled} models`); + + const thirdPartyIds = new Set(); + const skipAfterFree = new Set([...priced, ...freeIds]); + try { + const modelsDevJson = await fetchModelsDevJson(); + const filled = applyModelsDevCosts( + entries, + parseModelsDev(modelsDevJson), + skipAfterFree, + thirdPartyIds, + ); + console.log(` Applied models.dev costs to ${filled} models`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.log(` models.dev fill skipped: ${message}`); } - console.log(`\nWriting ${MODELS_JSON} with ${entries.length} models from ${sourceLabel}...`) - writeFileSync(MODELS_JSON, JSON.stringify(entries, null, 2) + "\n", "utf-8") - writeFileSync(VERSION_PATH, `${version}\n`, "utf-8") + console.log(`\nWriting ${MODELS_JSON} with ${entries.length} models from ${sourceLabel}...`); + writeFileSync(MODELS_JSON, JSON.stringify(entries, null, 2) + "\n", "utf-8"); + writeFileSync(VERSION_PATH, `${version}\n`, "utf-8"); - const pluginVersion = (JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { version: string }).version + const pluginVersion = (JSON.parse(readFileSync(PACKAGE_JSON, "utf-8")) as { version: string }) + .version; + const unmatchedIds = entries + .filter( + (e) => + !cliIds.has(e.id) && !docIds.has(e.id) && !freeIds.has(e.id) && !thirdPartyIds.has(e.id), + ) + .map((e) => e.id); const costSources = countCostSources({ modelIds: entries.map((e) => e.id), cliIds, officialDocIds: docIds, - thirdPartyIds: new Set(), - fallbackIds: new Set(Object.keys(FALLBACK_COSTS)), - }) + thirdPartyIds, + freeIds, + }); writeManifest( MANIFEST_PATH, buildManifest({ @@ -196,33 +229,38 @@ async function main() { reasoningModelCount: entries.filter((e) => e.reasoning).length, modelCatalogOk: true, costSources, + review: { + thirdParty: [...thirdPartyIds], + free: [...freeIds], + unmatched: unmatchedIds, + }, generatedAt: new Date().toISOString(), }), - ) + ); - const modelsObj = generateOpencodeModels(entries) + const modelsObj = generateOpencodeModels(entries); if (shouldUpdateGlobal) { - console.log("Updating global config...") - updateGlobalConfig(modelsObj) + console.log("Updating global config..."); + updateGlobalConfig(modelsObj); } - console.log("\nModel list:") + console.log("\nModel list:"); for (const entry of entries) { - const cost = `$${entry.cost.input}/$${entry.cost.output}` + const cost = `$${entry.cost.input}/$${entry.cost.output}`; const efforts = entry.reasoningEfforts?.length ? ` efforts=[${entry.reasoningEfforts.join(",")}]` - : "" + : ""; console.log( ` ${entry.tier.padEnd(12)} ${entry.id.padEnd(35)} ${entry.name.padEnd(25)} ${cost}${efforts}`, - ) + ); } if (!shouldUpdateGlobal) { - console.log(`\nRun with --update-global to update ${GLOBAL_CONFIG}`) + console.log(`\nRun with --update-global to update ${GLOBAL_CONFIG}`); } - console.log("\nDone.") + console.log("\nDone."); } -main() +main(); diff --git a/scripts/sync-release-manifests.ts b/scripts/sync-release-manifests.ts index 4b2f1f7..c539f28 100644 --- a/scripts/sync-release-manifests.ts +++ b/scripts/sync-release-manifests.ts @@ -1,39 +1,39 @@ #!/usr/bin/env bun // After semantic-release, open a PR so protected main gets package.json / manifest // versions that match the new v* tag (workit AR-15). -import { existsSync, readFileSync, writeFileSync } from "fs" -import { join } from "path" -import { execSync } from "child_process" +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { join } from "path"; +import { execSync } from "child_process"; -const ROOT = join(import.meta.dir, "..") +const ROOT = join(import.meta.dir, ".."); function git(args: string): string { - return execSync(`git ${args}`, { cwd: ROOT, encoding: "utf-8" }).trim() + return execSync(`git ${args}`, { cwd: ROOT, encoding: "utf-8" }).trim(); } function latestTag(): string { - const tag = git("tag --list 'v*' --sort=-v:refname").split("\n")[0] - if (!tag) throw new Error("no v* release tag found") - return tag + const tag = git("tag --list 'v*' --sort=-v:refname").split("\n")[0]; + if (!tag) throw new Error("no v* release tag found"); + return tag; } -const tag = latestTag() -const version = tag.replace(/^v/, "") -const pkgPath = join(ROOT, "package.json") -const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string } +const tag = latestTag(); +const version = tag.replace(/^v/, ""); +const pkgPath = join(ROOT, "package.json"); +const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version: string }; if (pkg.version === version) { - console.log(`package.json already ${version}`) - process.exit(0) + console.log(`package.json already ${version}`); + process.exit(0); } -pkg.version = version -writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`) +pkg.version = version; +writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); -const manifestPath = join(ROOT, "manifest.json") +const manifestPath = join(ROOT, "manifest.json"); if (existsSync(manifestPath)) { - const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { pluginVersion?: string } - manifest.pluginVersion = version - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as { pluginVersion?: string }; + manifest.pluginVersion = version; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); } -console.log(`aligned manifests to ${tag}`) +console.log(`aligned manifests to ${tag}`); diff --git a/scripts/verify-release-candidate.ts b/scripts/verify-release-candidate.ts index 93c9795..d1b4547 100644 --- a/scripts/verify-release-candidate.ts +++ b/scripts/verify-release-candidate.ts @@ -1,38 +1,41 @@ #!/usr/bin/env bun // Pack-only gate: write a local tarball and check payload. Never publish or tag. -import { execSync } from "child_process" -import { mkdtempSync, readdirSync, rmSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" +import { execSync } from "child_process"; +import { mkdtempSync, readdirSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; -const ROOT = join(import.meta.dir, "..") -const REQUIRED = ["plugin.ts", "models.json", "manifest.json", "package.json", "src/catalog.ts"] -const FORBIDDEN = ["tests/", "docs/", ".github/"] +const ROOT = join(import.meta.dir, ".."); +const REQUIRED = ["plugin.ts", "models.json", "manifest.json", "package.json", "src/catalog.ts"]; +const FORBIDDEN = ["tests/", "docs/", ".github/"]; -const dir = mkdtempSync(join(tmpdir(), "cc-pack-")) +const dir = mkdtempSync(join(tmpdir(), "cc-pack-")); try { execSync("npm pack --pack-destination " + JSON.stringify(dir), { cwd: ROOT, stdio: "pipe", encoding: "utf-8", - }) - const tgz = readdirSync(dir).find((f) => f.endsWith(".tgz")) - if (!tgz) throw new Error("npm pack produced no tarball") + }); + const tgz = readdirSync(dir).find((f) => f.endsWith(".tgz")); + if (!tgz) throw new Error("npm pack produced no tarball"); const listing = execSync(`tar -tzf ${JSON.stringify(join(dir, tgz))}`, { encoding: "utf-8", - }) - const files = listing.split("\n").filter(Boolean).map((f) => f.replace(/^package\//, "")) + }); + const files = listing + .split("\n") + .filter(Boolean) + .map((f) => f.replace(/^package\//, "")); for (const need of REQUIRED) { if (!files.includes(need) && !files.some((f) => f === need || f.startsWith(need))) { - throw new Error(`tarball missing ${need}`) + throw new Error(`tarball missing ${need}`); } } for (const banned of FORBIDDEN) { if (files.some((f) => f === banned || f.startsWith(banned))) { - throw new Error(`tarball must not include ${banned}`) + throw new Error(`tarball must not include ${banned}`); } } - console.log(`verified ${tgz} (${files.length} files)`) + console.log(`verified ${tgz} (${files.length} files)`); } finally { - rmSync(dir, { recursive: true, force: true }) + rmSync(dir, { recursive: true, force: true }); } diff --git a/src/auth.ts b/src/auth.ts index 6c7d152..7582b0c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,43 +1,43 @@ -import { readFileSync, existsSync } from "fs" -import { homedir } from "os" -import { join } from "path" +import { readFileSync, existsSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; export function resolveApiKey(options: { - apiKey?: string - env?: Record - authPaths?: string[] + apiKey?: string; + env?: Record; + authPaths?: string[]; }): string | undefined { - if (options.apiKey) return options.apiKey + if (options.apiKey) return options.apiKey; - const envKey = options.env?.COMMANDCODE_API_KEY ?? process.env.COMMANDCODE_API_KEY - if (envKey) return envKey + const envKey = options.env?.COMMANDCODE_API_KEY ?? process.env.COMMANDCODE_API_KEY; + if (envKey) return envKey; const authPaths = options.authPaths ?? [ join(homedir(), ".commandcode", "auth.json"), join(homedir(), ".pi", "agent", "auth.json"), - ] + ]; for (const p of authPaths) { - if (!existsSync(p)) continue + if (!existsSync(p)) continue; try { - const parsed = JSON.parse(readFileSync(p, "utf-8")) + const parsed = JSON.parse(readFileSync(p, "utf-8")); if (typeof parsed === "object" && parsed !== null) { - if (typeof parsed.apiKey === "string") return parsed.apiKey - if (typeof parsed.commandcode === "string") return parsed.commandcode + if (typeof parsed.apiKey === "string") return parsed.apiKey; + if (typeof parsed.commandcode === "string") return parsed.commandcode; if ( typeof parsed.commandcode === "object" && parsed.commandcode !== null && parsed.commandcode.type === "oauth" && typeof parsed.commandcode.access === "string" ) { - return parsed.commandcode.access + return parsed.commandcode.access; } } } catch { // intentionally silent: skip unreadable or malformed auth files - continue + continue; } } - return undefined + return undefined; } diff --git a/src/catalog-break.ts b/src/catalog-break.ts index d63e519..508216f 100644 --- a/src/catalog-break.ts +++ b/src/catalog-break.ts @@ -1,14 +1,14 @@ export function catalogBreakTitle(commandCodeVersion: string): string { - return `[catalog-break] command-code@${commandCodeVersion} — model extraction failed` + return `[catalog-break] command-code@${commandCodeVersion} — model extraction failed`; } export function renderCatalogBreakBody(input: { - commandCodeVersion: string - error: string - workflowUrl: string - bundledCommandCodeVersion: string | null + commandCodeVersion: string; + error: string; + workflowUrl: string; + bundledCommandCodeVersion: string | null; }): string { - const bundled = input.bundledCommandCodeVersion ?? "(none)" + const bundled = input.bundledCommandCodeVersion ?? "(none)"; return `Model catalog extraction failed for \`command-code@${input.commandCodeVersion}\`. ## Error @@ -30,11 +30,11 @@ Bundled catalog is still \`command-code@${bundled}\`. No npm release was publish - [ ] Check extraction anchors in \`src/catalog.ts\` - [ ] Add/adjust unit fixtures under \`tests/fixtures/command-code/\` - [ ] Re-run **Catalog sync** with \`force=true\` -` +`; } export function catalogBreakResolvedComment(input: { commit: string; tag: string }): string { - return `Catalog sync recovered. Fix commit: \`${input.commit}\`. Release: \`${input.tag}\`.` + return `Catalog sync recovered. Fix commit: \`${input.commit}\`. Release: \`${input.tag}\`.`; } -export const CATALOG_BREAK_LABEL = "catalog-break" +export const CATALOG_BREAK_LABEL = "catalog-break"; diff --git a/src/catalog-release-notes.ts b/src/catalog-release-notes.ts index 67fc881..f21e550 100644 --- a/src/catalog-release-notes.ts +++ b/src/catalog-release-notes.ts @@ -1,85 +1,91 @@ -import type { CatalogManifest, CatalogStatus, CostSources } from "./manifest.js" +import type { CatalogManifest, CatalogReview, CatalogStatus, CostSources } from "./manifest.js"; export type PricedModel = { - id: string - name: string - cost: { input: number; output: number } -} + id: string; + name: string; + cost: { input: number; output: number }; +}; export type CatalogReleaseInput = { - pluginVersion: string - commandCodeVersion: string - modelCount: number - reasoningModelCount: number - status: CatalogStatus - costSources: CostSources - models: PricedModel[] - fallbackCosts: Record -} + pluginVersion: string; + commandCodeVersion: string; + modelCount: number; + reasoningModelCount: number; + status: CatalogStatus; + costSources: CostSources; + models: PricedModel[]; + review?: CatalogReview; +}; -const DEFAULT_COST = { input: 0.5, output: 2 } +const DEFAULT_COST = { input: 0.5, output: 2 }; export function partitionPrices( models: PricedModel[], - fallbackCosts: Record, -): { official: PricedModel[]; fallback: PricedModel[]; unmatched: PricedModel[] } { - const official: PricedModel[] = [] - const fallback: PricedModel[] = [] - const unmatched: PricedModel[] = [] + review: CatalogReview | undefined, +): { thirdParty: PricedModel[]; free: PricedModel[]; unmatched: PricedModel[] } { + const byId = new Map(models.map((m) => [m.id, m])); + if (review) { + return { + thirdParty: review.thirdParty.flatMap((id) => (byId.get(id) ? [byId.get(id)!] : [])), + free: review.free.flatMap((id) => (byId.get(id) ? [byId.get(id)!] : [])), + unmatched: review.unmatched.flatMap((id) => (byId.get(id) ? [byId.get(id)!] : [])), + }; + } + const thirdParty: PricedModel[] = []; + const free: PricedModel[] = []; + const unmatched: PricedModel[] = []; for (const model of models) { - const known = fallbackCosts[model.id] - if (known && known.input === model.cost.input && known.output === model.cost.output) { - fallback.push(model) - } else if (model.cost.input === DEFAULT_COST.input && model.cost.output === DEFAULT_COST.output) { - unmatched.push(model) - } else { - official.push(model) + if (model.cost.input === 0 && model.cost.output === 0) free.push(model); + else if (model.cost.input === DEFAULT_COST.input && model.cost.output === DEFAULT_COST.output) { + unmatched.push(model); } } - return { official, fallback, unmatched } + return { thirdParty, free, unmatched }; } function n(count: number, one: string, many: string): string { - return `${count} ${count === 1 ? one : many}` + return `${count} ${count === 1 ? one : many}`; } function headline(status: CatalogStatus): string { if (status === "healthy") { - return "**Catalog is complete.** All listed prices come from Command Code or official docs." + return "**Catalog is complete.** Prices come from Command Code, official docs, models.dev, or free SKUs."; } if (status === "broken") { - return "**Do not use this catalog.** Model extraction failed; OpenCode would not get a current model list." + return "**Do not use this catalog.** Model extraction failed; OpenCode would not get a current model list."; } - return "**Safe to use.** Every model is listed. Some prices are estimates — expand the sections below to review them." + return "**Safe to use.** Every model is listed. Some prices have no listed source — expand the sections below to review them."; } function priceSummary(sources: CostSources): string { - const parts: string[] = [] - if (sources.cli > 0) parts.push(`${sources.cli} CLI`) - if (sources.officialDocs > 0) parts.push(`${sources.officialDocs} official docs`) - if (sources.thirdParty > 0) parts.push(`${sources.thirdParty} third-party`) - if (sources.fallback > 0) parts.push(`${sources.fallback} known fallback${sources.fallback === 1 ? "" : "s"}`) - if (sources.unmatched > 0) parts.push(`${sources.unmatched} no listed price`) - return parts.join(" · ") || "none" + const parts: string[] = []; + if (sources.cli > 0) parts.push(`${sources.cli} CLI`); + if (sources.officialDocs > 0) parts.push(`${sources.officialDocs} official docs`); + if (sources.thirdParty > 0) parts.push(`${sources.thirdParty} models.dev`); + if (sources.free > 0) parts.push(`${sources.free} free`); + if (sources.fallback > 0) + parts.push(`${sources.fallback} hardcoded fallback${sources.fallback === 1 ? "" : "s"}`); + if (sources.unmatched > 0) parts.push(`${sources.unmatched} no listed price`); + return parts.join(" · ") || "none"; } function money(n: number): string { - return `$${n}` + return `$${n}`; } function modelTable(models: PricedModel[]): string { const rows = models .map((m) => `| ${m.name} | \`${m.id}\` | ${money(m.cost.input)} / ${money(m.cost.output)} |`) - .join("\n") - return `| Model | Id | In / out per 1M |\n| --- | --- | --- |\n${rows}` + .join("\n"); + return `| Model | Id | In / out per 1M |\n| --- | --- | --- |\n${rows}`; } function details(summary: string, body: string): string { - return `
\n${summary}\n\n${body}\n\n
` + return `
\n${summary}\n\n${body}\n\n
`; } export function renderCatalogReleaseNotes(input: CatalogReleaseInput): string { - const { fallback, unmatched } = partitionPrices(input.models, input.fallbackCosts) + const { thirdParty, free, unmatched } = partitionPrices(input.models, input.review); const sections = [ headline(input.status), "", @@ -89,25 +95,39 @@ export function renderCatalogReleaseNotes(input: CatalogReleaseInput): string { `| Command Code | ${input.commandCodeVersion} |`, `| Models | ${input.modelCount} (${input.reasoningModelCount} with reasoning) |`, `| Prices | ${priceSummary(input.costSources)} |`, - ] + ]; - if (fallback.length > 0) { + if (thirdParty.length > 0) { sections.push( "", details( - n(fallback.length, "model with a known fallback price", "models with known fallback prices"), - modelTable(fallback), + n( + thirdParty.length, + "model with a models.dev reference price", + "models with models.dev reference prices", + ), + modelTable(thirdParty), ), - ) + ); + } + if (free.length > 0) { + sections.push( + "", + details(n(free.length, "free model ($0)", "free models ($0)"), modelTable(free)), + ); } if (unmatched.length > 0) { sections.push( "", details( - n(unmatched.length, "model with no official price ($0.50 / $2)", "models with no official price ($0.50 / $2)"), + n( + unmatched.length, + "model with no listed price ($0.50 / $2)", + "models with no listed price ($0.50 / $2)", + ), modelTable(unmatched), ), - ) + ); } sections.push( @@ -129,15 +149,14 @@ export function renderCatalogReleaseNotes(input: CatalogReleaseInput): string { ) + "\n```", ), - ) + ); - return sections.join("\n") + "\n" + return sections.join("\n") + "\n"; } export function catalogReleaseNotesFromFiles(input: { - manifest: CatalogManifest - models: PricedModel[] - fallbackCosts: Record + manifest: CatalogManifest; + models: PricedModel[]; }): string { return renderCatalogReleaseNotes({ pluginVersion: input.manifest.pluginVersion, @@ -147,6 +166,6 @@ export function catalogReleaseNotesFromFiles(input: { status: input.manifest.status, costSources: input.manifest.costSources, models: input.models, - fallbackCosts: input.fallbackCosts, - }) + review: input.manifest.review, + }); } diff --git a/src/catalog.ts b/src/catalog.ts index 8dcf563..213c9ba 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -1,69 +1,56 @@ -import { createRequire } from "module" -import { execSync } from "child_process" -import { existsSync, readFileSync, statSync } from "fs" -import { dirname, join, resolve as resolvePath } from "path" -import { fileURLToPath } from "url" +import { createRequire } from "module"; +import { execSync } from "child_process"; +import { existsSync, readFileSync, statSync } from "fs"; +import { dirname, join, resolve as resolvePath } from "path"; +import { fileURLToPath } from "url"; -export const NPM_PACKAGE = "command-code" +export const NPM_PACKAGE = "command-code"; export interface ModelEntry { - id: string - name: string - tier: "premium" | "open-source" - reasoning: boolean - reasoningEfforts?: string[] - tool_call: boolean - cost: { input: number; output: number; cache_read?: number; cache_write?: number } - limit: { context: number; output: number } + id: string; + name: string; + tier: "premium" | "open-source"; + reasoning: boolean; + reasoningEfforts?: string[]; + tool_call: boolean; + cost: { input: number; output: number; cache_read?: number; cache_write?: number }; + limit: { context: number; output: number }; } export interface CostEntry { - id: string - provider: string - category: string - promptCost: number - completionCost: number - cacheWrite5mCost: number - cacheWrite1hCost: number - cacheHitCost: number + id: string; + provider: string; + category: string; + promptCost: number; + completionCost: number; + cacheWrite5mCost: number; + cacheWrite1hCost: number; + cacheHitCost: number; } export interface SnEntry { - id: string - provider: string - spec: string - label: string - name: string - description: string - reasoning?: boolean - reasoningEfforts?: string[] - contextWindow?: number + id: string; + provider: string; + spec: string; + label: string; + name: string; + description: string; + reasoning?: boolean; + reasoningEfforts?: string[]; + contextWindow?: number; } export interface ResolvedCommandCodePackage { - root: string - bundlePath: string - version: string + root: string; + bundlePath: string; + version: string; } export interface LocalCatalogResult { - models: ModelEntry[] - version: string - root: string - bundleSource: string -} - -export const FALLBACK_COSTS: Record = { - "deepseek/deepseek-v4-pro": { input: 0.435, output: 0.87, cache_read: 0.003625 }, - "deepseek/deepseek-v4-flash": { input: 0.14, output: 0.28, cache_read: 0.01 }, - "zai-org/GLM-5.1": { input: 1.4, output: 4.4, cache_read: 0.26 }, - "MiniMaxAI/MiniMax-M2.7": { input: 0.3, output: 1.2, cache_read: 0.06 }, - "Qwen/Qwen3.6-Max-Preview": { input: 1.3, output: 7.8, cache_read: 0.26, cache_write: 1.63 }, - "Qwen/Qwen3.6-Plus": { input: 0.5, output: 3, cache_read: 0.1 }, - "Qwen/Qwen3.7-Max": { input: 1.25, output: 3.75, cache_read: 0.25, cache_write: 1.56 }, - "stepfun/Step-3.5-Flash": { input: 0.1, output: 0.3, cache_read: 0.02 }, - "google/gemini-3.5-flash": { input: 1.5, output: 9, cache_read: 0.15 }, - "google/gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cache_read: 0.03 }, + models: ModelEntry[]; + version: string; + root: string; + bundleSource: string; } export const FALLBACK_LIMITS: Record = { @@ -89,7 +76,7 @@ export const FALLBACK_LIMITS: Record = { anthropic: "premium", @@ -110,88 +97,92 @@ export const TIER_MAP: Record = { "vercel-ai-gateway": "open-source", openrouter: "open-source", "cloudflare-ai-gateway": "open-source", -} +}; -const CATALOG_ANCHOR = 'SONNET_4_6:{id:"claude-sonnet-4-6"' +const CATALOG_ANCHOR = 'SONNET_4_6:{id:"claude-sonnet-4-6"'; const COST_ANCHORS = [ '{id:"anthropic:claude-sonnet-4-', '{id:"anthropic:claude-sonnet-5"', '{id:"anthropic:claude-sonnet-4-6"', -] as const +] as const; /** Object after `(` (legacy IIFE / enum form): `(Wt={...})` */ export function findBalancedObject(source: string, anchor: string): string { - const anchorIdx = source.indexOf(anchor) - if (anchorIdx < 0) throw new Error(`Anchor not found: ${anchor}`) + const anchorIdx = source.indexOf(anchor); + if (anchorIdx < 0) throw new Error(`Anchor not found: ${anchor}`); - let parenIdx = anchorIdx - 1 - while (parenIdx >= 0 && source[parenIdx] !== "(") parenIdx-- - if (parenIdx < 0) throw new Error(`Could not find opening ( before anchor: ${anchor}`) + let parenIdx = anchorIdx - 1; + while (parenIdx >= 0 && source[parenIdx] !== "(") parenIdx--; + if (parenIdx < 0) throw new Error(`Could not find opening ( before anchor: ${anchor}`); - const braceStart = source.indexOf("{", parenIdx) - if (braceStart < 0) throw new Error(`Could not find { after opening (`) + const braceStart = source.indexOf("{", parenIdx); + if (braceStart < 0) throw new Error(`Could not find { after opening (`); - let depth = 0 - let end = braceStart + let depth = 0; + let end = braceStart; for (; end < source.length; end++) { - if (source[end] === "{") depth++ + if (source[end] === "{") depth++; else if (source[end] === "}") { - depth-- - if (depth === 0) break + depth--; + if (depth === 0) break; } } - return source.slice(braceStart, end + 1) + return source.slice(braceStart, end + 1); } /** All balanced `{...}` spans that contain the anchor, innermost → outermost. */ -export function findEnclosingObjectCandidates(source: string, anchor: string, fromIndex = 0): string[] { - const anchorIdx = source.indexOf(anchor, fromIndex) - if (anchorIdx < 0) throw new Error(`Anchor not found: ${anchor}`) +export function findEnclosingObjectCandidates( + source: string, + anchor: string, + fromIndex = 0, +): string[] { + const anchorIdx = source.indexOf(anchor, fromIndex); + if (anchorIdx < 0) throw new Error(`Anchor not found: ${anchor}`); - const stack: number[] = [] + const stack: number[] = []; for (let i = 0; i <= anchorIdx; i++) { - const ch = source[i] - if (ch === "{") stack.push(i) - else if (ch === "}") stack.pop() + const ch = source[i]; + if (ch === "{") stack.push(i); + else if (ch === "}") stack.pop(); } - if (stack.length === 0) throw new Error(`Could not find enclosing { for anchor: ${anchor}`) + if (stack.length === 0) throw new Error(`Could not find enclosing { for anchor: ${anchor}`); - const MAX = 2_000_000 - const out: string[] = [] + const MAX = 2_000_000; + const out: string[] = []; // innermost first for (let s = stack.length - 1; s >= 0; s--) { - const start = stack[s] - if (start === undefined) continue - let depth = 0 - let end = start + const start = stack[s]; + if (start === undefined) continue; + let depth = 0; + let end = start; for (; end < source.length; end++) { - if (source[end] === "{") depth++ + if (source[end] === "{") depth++; else if (source[end] === "}") { - depth-- - if (depth === 0) break + depth--; + if (depth === 0) break; } } - if (end >= source.length || end < anchorIdx) continue - if (end + 1 - start > MAX) continue - out.push(source.slice(start, end + 1)) + if (end >= source.length || end < anchorIdx) continue; + if (end + 1 - start > MAX) continue; + out.push(source.slice(start, end + 1)); } - if (out.length === 0) throw new Error(`Could not close enclosing { for anchor: ${anchor}`) - return out + if (out.length === 0) throw new Error(`Could not close enclosing { for anchor: ${anchor}`); + return out; } /** Default: innermost enclosing object (good for plain catalogs). */ export function findEnclosingObject(source: string, anchor: string, fromIndex = 0): string { - const first = findEnclosingObjectCandidates(source, anchor, fromIndex)[0] - if (!first) throw new Error(`Could not find enclosing object for anchor: ${anchor}`) - return first + const first = findEnclosingObjectCandidates(source, anchor, fromIndex)[0]; + if (!first) throw new Error(`Could not find enclosing object for anchor: ${anchor}`); + return first; } export function evaluateWithContext(code: string, context: Record): any { - const keys = Object.keys(context) - const values = keys.map((k) => context[k]) - const fn = Function(...keys, `"use strict"; return (${code})`) - return fn(...values) + const keys = Object.keys(context); + const values = keys.map((k) => context[k]); + const fn = Function(...keys, `"use strict"; return (${code})`); + return fn(...values); } export function normalizeForEval(code: string): string { @@ -200,80 +191,80 @@ export function normalizeForEval(code: string): string { .replace(/!1/g, "false") .replace(/(\d+)e(\d+)/g, (_: string, m: string, e: string) => String(Number(m) * Math.pow(10, Number(e))), - ) + ); } function getVarNameBefore(source: string, anchorIdx: number): string | null { - const before = source.slice(Math.max(0, anchorIdx - 50), anchorIdx) - const match = before.match(/\(([A-Za-z_$]+)=\{$/) - if (match?.[1]) return match[1] - const match2 = before.match(/([A-Za-z_$]+)=\{$/) - if (match2?.[1]) return match2[1] - return null + const before = source.slice(Math.max(0, anchorIdx - 50), anchorIdx); + const match = before.match(/\(([A-Za-z_$]+)=\{$/); + if (match?.[1]) return match[1]; + const match2 = before.match(/([A-Za-z_$]+)=\{$/); + if (match2?.[1]) return match2[1]; + return null; } /** Prefer provider enums that include gateway/openai keys over UI-only enums. */ export function extractBestProviderEnum( source: string, ): { name: string; value: Record } | null { - const anchor = 'ANTHROPIC:"anthropic"' - let from = 0 - let fallback: { name: string; value: Record } | null = null + const anchor = 'ANTHROPIC:"anthropic"'; + let from = 0; + let fallback: { name: string; value: Record } | null = null; while (from < source.length) { - const idx = source.indexOf(anchor, from) - if (idx < 0) break + const idx = source.indexOf(anchor, from); + if (idx < 0) break; try { - let parenIdx = idx - 1 - while (parenIdx >= 0 && source[parenIdx] !== "(") parenIdx-- + let parenIdx = idx - 1; + while (parenIdx >= 0 && source[parenIdx] !== "(") parenIdx--; if (parenIdx < 0) { - from = idx + 1 - continue + from = idx + 1; + continue; } - const braceStart = source.indexOf("{", parenIdx) + const braceStart = source.indexOf("{", parenIdx); if (braceStart < 0 || braceStart > idx) { - from = idx + 1 - continue + from = idx + 1; + continue; } - let depth = 0 - let end = braceStart + let depth = 0; + let end = braceStart; for (; end < source.length; end++) { - if (source[end] === "{") depth++ + if (source[end] === "{") depth++; else if (source[end] === "}") { - depth-- - if (depth === 0) break + depth--; + if (depth === 0) break; } } - const obj = source.slice(braceStart, end + 1) - const value = evaluateWithContext(normalizeForEval(obj), {}) as Record - const name = getVarNameBefore(source, idx) || "Wt" + const obj = source.slice(braceStart, end + 1); + const value = evaluateWithContext(normalizeForEval(obj), {}) as Record; + const name = getVarNameBefore(source, idx) || "Wt"; const rich = "OPENAI" in value || "BASETEN" in value || "VERCEL_AI_GATEWAY" in value || - Object.values(value).includes("openai") - const hit = { name, value } - if (rich) return hit - if (!fallback) fallback = hit + Object.values(value).includes("openai"); + const hit = { name, value }; + if (rich) return hit; + if (!fallback) fallback = hit; } catch { // try next occurrence } - from = idx + 1 + from = idx + 1; } - return fallback + return fallback; } export function extractWt(source: string): Record { - const best = extractBestProviderEnum(source) - if (!best) throw new Error(`Anchor not found: ANTHROPIC:"anthropic"`) - return best.value + const best = extractBestProviderEnum(source); + if (!best) throw new Error(`Anchor not found: ANTHROPIC:"anthropic"`); + return best.value; } export function getWtVarName(source: string): string { - const best = extractBestProviderEnum(source) - if (!best) throw new Error("Could not find Wt enum") - return best.name + const best = extractBestProviderEnum(source); + if (!best) throw new Error("Could not find Wt enum"); + return best.name; } /** Collect nearby `name="value"` bindings and simple aliases (`tI=Qx`). */ @@ -282,75 +273,79 @@ export function extractStringBindings( endIdx: number, window = 12000, ): Record { - const before = source.slice(Math.max(0, endIdx - window), endIdx) - const bindings: Record = {} - const strRe = /\b([A-Za-z_$][\w$]*)="([^"]*)"/g - let m: RegExpExecArray | null + const before = source.slice(Math.max(0, endIdx - window), endIdx); + const bindings: Record = {}; + const strRe = /\b([A-Za-z_$][\w$]*)="([^"]*)"/g; + let m: RegExpExecArray | null; while ((m = strRe.exec(before))) { - const name = m[1] - const value = m[2] - if (name !== undefined && value !== undefined) bindings[name] = value + const name = m[1]; + const value = m[2]; + if (name !== undefined && value !== undefined) bindings[name] = value; } for (let pass = 0; pass < 4; pass++) { - const aliasRe = /\b([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\b/g + const aliasRe = /\b([A-Za-z_$][\w$]*)=([A-Za-z_$][\w$]*)\b/g; while ((m = aliasRe.exec(before))) { - const alias = m[1] - const target = m[2] - if (alias === undefined || target === undefined || bindings[alias] !== undefined) continue - const resolved = bindings[target] - if (resolved !== undefined) bindings[alias] = resolved + const alias = m[1]; + const target = m[2]; + if (alias === undefined || target === undefined || bindings[alias] !== undefined) continue; + const resolved = bindings[target]; + if (resolved !== undefined) bindings[alias] = resolved; } } - return bindings + return bindings; } -export function extractSpecConstants(source: string): { chatComplete: string; responses: string; qt: string } { - const anchorIdx = source.indexOf(CATALOG_ANCHOR) - if (anchorIdx < 0) throw new Error("Could not find model catalog anchor") +export function extractSpecConstants(source: string): { + chatComplete: string; + responses: string; + qt: string; +} { + const anchorIdx = source.indexOf(CATALOG_ANCHOR); + if (anchorIdx < 0) throw new Error("Could not find model catalog anchor"); - const before = source.slice(Math.max(0, anchorIdx - 5000), anchorIdx) + const before = source.slice(Math.max(0, anchorIdx - 5000), anchorIdx); - const chatMatch = before.match(/([A-Za-z_$]+)="chatComplete"/) - const respMatch = before.match(/([A-Za-z_$]+)="responses"/) - const chatName = chatMatch?.[1] - const respName = respMatch?.[1] - if (!chatName || !respName) throw new Error("Could not find spec constants") + const chatMatch = before.match(/([A-Za-z_$]+)="chatComplete"/); + const respMatch = before.match(/([A-Za-z_$]+)="responses"/); + const chatName = chatMatch?.[1]; + const respName = respMatch?.[1]; + if (!chatName || !respName) throw new Error("Could not find spec constants"); - const qtMatch = before.match(/([A-Za-z_$]+)=Vt\[0\]/) - const qtVar = qtMatch?.[1] ?? "" + const qtMatch = before.match(/([A-Za-z_$]+)=Vt\[0\]/); + const qtVar = qtMatch?.[1] ?? ""; return { chatComplete: chatName, responses: respName, qt: qtVar, - } + }; } function buildEvalContext(source: string, catalogIdx: number): Record { const ctx: Record = { ...extractStringBindings(source, catalogIdx), - } + }; - const spec = extractSpecConstants(source) - ctx[spec.chatComplete] = ctx[spec.chatComplete] ?? "chatComplete" - ctx[spec.responses] = ctx[spec.responses] ?? "responses" + const spec = extractSpecConstants(source); + ctx[spec.chatComplete] = ctx[spec.chatComplete] ?? "chatComplete"; + ctx[spec.responses] = ctx[spec.responses] ?? "responses"; - const providerEnum = extractBestProviderEnum(source) + const providerEnum = extractBestProviderEnum(source); if (providerEnum) { - ctx[providerEnum.name] = providerEnum.value + ctx[providerEnum.name] = providerEnum.value; if (spec.qt && providerEnum.value.VERCEL_AI_GATEWAY) { - ctx[spec.qt] = providerEnum.value.VERCEL_AI_GATEWAY + ctx[spec.qt] = providerEnum.value.VERCEL_AI_GATEWAY; } } - return ctx + return ctx; } function isModelCatalog(value: unknown): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return false - const vals = Object.values(value as Record) - if (vals.length < 2) return false - let modelish = 0 + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const vals = Object.values(value as Record); + if (vals.length < 2) return false; + let modelish = 0; for (const v of vals) { if ( v && @@ -359,10 +354,10 @@ function isModelCatalog(value: unknown): value is Record { typeof (v as SnEntry).id === "string" && typeof (v as SnEntry).name === "string" ) { - modelish++ + modelish++; } } - return modelish >= 2 + return modelish >= 2; } export function extractModelCatalog( @@ -371,46 +366,46 @@ export function extractModelCatalog( wtName?: string, spec?: ReturnType, ): Record { - const catalogIdx = source.indexOf(CATALOG_ANCHOR) - if (catalogIdx < 0) throw new Error("Could not find model catalog anchor") + const catalogIdx = source.indexOf(CATALOG_ANCHOR); + if (catalogIdx < 0) throw new Error("Could not find model catalog anchor"); - const ctx = buildEvalContext(source, catalogIdx) - if (wt && wtName) ctx[wtName] = wt + const ctx = buildEvalContext(source, catalogIdx); + if (wt && wtName) ctx[wtName] = wt; if (spec) { - ctx[spec.chatComplete] = "chatComplete" - ctx[spec.responses] = "responses" - if (spec.qt && wt?.VERCEL_AI_GATEWAY) ctx[spec.qt] = wt.VERCEL_AI_GATEWAY + ctx[spec.chatComplete] = "chatComplete"; + ctx[spec.responses] = "responses"; + if (spec.qt && wt?.VERCEL_AI_GATEWAY) ctx[spec.qt] = wt.VERCEL_AI_GATEWAY; } - const candidates: string[] = [] + const candidates: string[] = []; try { - candidates.push(...findEnclosingObjectCandidates(source, CATALOG_ANCHOR, catalogIdx)) + candidates.push(...findEnclosingObjectCandidates(source, CATALOG_ANCHOR, catalogIdx)); } catch { // ignore } try { - candidates.push(findBalancedObject(source, CATALOG_ANCHOR)) + candidates.push(findBalancedObject(source, CATALOG_ANCHOR)); } catch { // ignore } - if (candidates.length === 0) throw new Error("Could not locate model catalog object") + if (candidates.length === 0) throw new Error("Could not locate model catalog object"); for (const raw of candidates) { try { - const value = evaluateWithContext(normalizeForEval(raw), ctx) - if (isModelCatalog(value)) return value + const value = evaluateWithContext(normalizeForEval(raw), ctx); + if (isModelCatalog(value)) return value; } catch { // try next span } } - throw new Error("Could not evaluate model catalog") + throw new Error("Could not evaluate model catalog"); } function isCostMap(value: unknown): value is Record { - if (!value || typeof value !== "object" || Array.isArray(value)) return false - const vals = Object.values(value as Record) - if (vals.length === 0) return false + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const vals = Object.values(value as Record); + if (vals.length === 0) return false; // At least one provider bucket should be an array of cost rows return vals.some( (v) => @@ -419,7 +414,7 @@ function isCostMap(value: unknown): value is Record { typeof v[0] === "object" && v[0] !== null && typeof (v[0] as CostEntry).promptCost === "number", - ) + ); } export function extractCostData( @@ -427,73 +422,73 @@ export function extractCostData( wt?: Record, wtName?: string, ): Record { - let anchorIdx = -1 - let usedAnchor: string | null = null + let anchorIdx = -1; + let usedAnchor: string | null = null; for (const anchor of COST_ANCHORS) { - const idx = source.indexOf(anchor) + const idx = source.indexOf(anchor); if (idx >= 0) { - anchorIdx = idx - usedAnchor = anchor - break + anchorIdx = idx; + usedAnchor = anchor; + break; } } - if (anchorIdx < 0 || !usedAnchor) throw new Error("Could not find cost data anchor") + if (anchorIdx < 0 || !usedAnchor) throw new Error("Could not find cost data anchor"); - const candidates = findEnclosingObjectCandidates(source, usedAnchor, anchorIdx) - const catalogIdx = source.indexOf(CATALOG_ANCHOR) - const ctx = buildEvalContext(source, catalogIdx >= 0 ? catalogIdx : anchorIdx) - if (wt && wtName) ctx[wtName] = wt + const candidates = findEnclosingObjectCandidates(source, usedAnchor, anchorIdx); + const catalogIdx = source.indexOf(CATALOG_ANCHOR); + const ctx = buildEvalContext(source, catalogIdx >= 0 ? catalogIdx : anchorIdx); + if (wt && wtName) ctx[wtName] = wt; for (const raw of candidates) { try { - const value = evaluateWithContext(normalizeForEval(raw), ctx) - if (isCostMap(value)) return value + const value = evaluateWithContext(normalizeForEval(raw), ctx); + if (isCostMap(value)) return value; } catch { // try next enclosing span } } - throw new Error("Could not evaluate cost data map") + throw new Error("Could not evaluate cost data map"); } export function buildCostMap(costs: Record): Map { - const map = new Map() + const map = new Map(); for (const arr of Object.values(costs)) { for (const entry of arr) { - const colonIdx = entry.id.indexOf(":") - const bareId = colonIdx >= 0 ? entry.id.slice(colonIdx + 1) : entry.id - map.set(bareId, entry) + const colonIdx = entry.id.indexOf(":"); + const bareId = colonIdx >= 0 ? entry.id.slice(colonIdx + 1) : entry.id; + map.set(bareId, entry); } } - return map + return map; } export function buildModelEntry( entry: SnEntry, costMap: Map, ): ModelEntry | null { - const provider = entry.provider || "unknown" - const tier = TIER_MAP[provider] ?? "open-source" + const provider = entry.provider || "unknown"; + const tier = TIER_MAP[provider] ?? "open-source"; - const costEntry = costMap.get(entry.id) - let cost: { input: number; output: number; cache_read?: number; cache_write?: number } + const costEntry = costMap.get(entry.id); + let cost: { input: number; output: number; cache_read?: number; cache_write?: number }; if (costEntry) { cost = { input: costEntry.promptCost, output: costEntry.completionCost, - } - if (costEntry.cacheHitCost > 0) cost.cache_read = costEntry.cacheHitCost - if (costEntry.cacheWrite5mCost > 0) cost.cache_write = costEntry.cacheWrite5mCost + }; + if (costEntry.cacheHitCost > 0) cost.cache_read = costEntry.cacheHitCost; + if (costEntry.cacheWrite5mCost > 0) cost.cache_write = costEntry.cacheWrite5mCost; } else { - const fallback = FALLBACK_COSTS[entry.id] - cost = fallback ? { ...fallback } : { input: 0.5, output: 2 } + // ponytail: sync fills real prices (docs → free $0 → models.dev); this is only the unmatched placeholder + cost = { input: 0.5, output: 2 }; } const limit = entry.contextWindow ? { context: entry.contextWindow, output: FALLBACK_LIMITS[entry.id]?.output ?? 65536 } - : FALLBACK_LIMITS[entry.id] ?? { context: 200000, output: 65536 } + : (FALLBACK_LIMITS[entry.id] ?? { context: 200000, output: 65536 }); - const efforts = entry.reasoningEfforts?.length ? [...entry.reasoningEfforts] : undefined + const efforts = entry.reasoningEfforts?.length ? [...entry.reasoningEfforts] : undefined; return { id: entry.id, @@ -504,84 +499,84 @@ export function buildModelEntry( tool_call: true, cost, limit, - } + }; } export function disambiguateModelNames(entries: ModelEntry[]): ModelEntry[] { - const groups = new Map() + const groups = new Map(); for (const entry of entries) { - const group = groups.get(entry.name) ?? [] - group.push(entry) - groups.set(entry.name, group) + const group = groups.get(entry.name) ?? []; + group.push(entry); + groups.set(entry.name, group); } for (const group of groups.values()) { - if (group.length < 2) continue + if (group.length < 2) continue; for (const entry of group) { - const suffix = entry.id.split("/").pop() ?? entry.id - const normalized = suffix.replace(/[-_]/g, " ") + const suffix = entry.id.split("/").pop() ?? entry.id; + const normalized = suffix.replace(/[-_]/g, " "); if (normalized.toLowerCase().includes("free")) { - entry.name = `${entry.name} Free` + entry.name = `${entry.name} Free`; } else if (normalized.toLowerCase() !== entry.name.toLowerCase()) { - entry.name = `${entry.name} (${suffix})` + entry.name = `${entry.name} (${suffix})`; } } } - return entries + return entries; } export function sortModelEntries(entries: ModelEntry[]): ModelEntry[] { - disambiguateModelNames(entries) + disambiguateModelNames(entries); return entries.sort((a, b) => { - if (a.tier !== b.tier) return a.tier === "premium" ? -1 : 1 - return a.name.localeCompare(b.name) - }) + if (a.tier !== b.tier) return a.tier === "premium" ? -1 : 1; + return a.name.localeCompare(b.name); + }); } export function loadCatalogFromBundle(source: string): ModelEntry[] { - const models = extractModelCatalog(source) - let costMap = new Map() + const models = extractModelCatalog(source); + let costMap = new Map(); try { - costMap = buildCostMap(extractCostData(source)) + costMap = buildCostMap(extractCostData(source)); } catch { - // ponytail: CLI cost map is optional; buildModelEntry applies FALLBACK_COSTS / defaults + // ponytail: CLI cost map is optional; buildModelEntry uses unmatched placeholder until sync fills prices } - const entries: ModelEntry[] = [] + const entries: ModelEntry[] = []; for (const model of Object.values(models)) { - if (!model || typeof model !== "object" || typeof model.id !== "string") continue - const entry = buildModelEntry(model, costMap) - if (entry) entries.push(entry) + if (!model || typeof model !== "object" || typeof model.id !== "string") continue; + const entry = buildModelEntry(model, costMap); + if (entry) entries.push(entry); } for (const extra of HARDCODED_EXTRAS) { if (!entries.some((e) => e.id === extra.id)) { - const entry = buildModelEntry(extra, costMap) - if (entry) entries.push(entry) + const entry = buildModelEntry(extra, costMap); + if (entry) entries.push(entry); } } - return sortModelEntries(entries) + return sortModelEntries(entries); } function readPackageMeta(root: string): { version: string; bundlePath: string } | null { - const pkgPath = join(root, "package.json") - if (!existsSync(pkgPath)) return null + const pkgPath = join(root, "package.json"); + if (!existsSync(pkgPath)) return null; try { const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { - version?: string - module?: string - main?: string - bin?: string | Record - exports?: unknown - } - - const binPaths: string[] = [] - if (typeof pkg.bin === "string") binPaths.push(join(root, pkg.bin)) + version?: string; + module?: string; + main?: string; + bin?: string | Record; + exports?: unknown; + }; + + const binPaths: string[] = []; + if (typeof pkg.bin === "string") binPaths.push(join(root, pkg.bin)); else if (pkg.bin && typeof pkg.bin === "object") { for (const rel of Object.values(pkg.bin)) { - if (typeof rel === "string") binPaths.push(join(root, rel)) + if (typeof rel === "string") binPaths.push(join(root, rel)); } } @@ -593,82 +588,82 @@ function readPackageMeta(root: string): { version: string; bundlePath: string } join(root, "dist", "index.mjs"), pkg.module ? join(root, pkg.module) : "", pkg.main ? join(root, pkg.main) : "", - ].filter(Boolean) + ].filter(Boolean); - let best: { path: string; size: number } | null = null + let best: { path: string; size: number } | null = null; for (const candidate of candidates) { - if (!existsSync(candidate)) continue + if (!existsSync(candidate)) continue; try { - const size = statSync(candidate).size + const size = statSync(candidate).size; // Skip tiny launcher stubs (< 4KB) that only re-export another file - if (size < 4096) continue - if (!best || size > best.size) best = { path: candidate, size } + if (size < 4096) continue; + if (!best || size > best.size) best = { path: candidate, size }; } catch { // ignore unreadable candidate } } if (best) { - return { version: pkg.version ?? "unknown", bundlePath: best.path } + return { version: pkg.version ?? "unknown", bundlePath: best.path }; } } catch { - return null + return null; } - return null + return null; } function asPackageRoot(path: string): ResolvedCommandCodePackage | null { - const resolved = resolvePath(path) - if (!existsSync(resolved)) return null + const resolved = resolvePath(path); + if (!existsSync(resolved)) return null; // Allow pointing at a file; still prefer the full CLI bundle under the package root if (resolved.endsWith(".mjs") || resolved.endsWith(".js")) { - const root = dirname(dirname(resolved)) - const meta = readPackageMeta(root) - if (meta) return { root, bundlePath: meta.bundlePath, version: meta.version } + const root = dirname(dirname(resolved)); + const meta = readPackageMeta(root); + if (meta) return { root, bundlePath: meta.bundlePath, version: meta.version }; try { if (statSync(resolved).size >= 4096) { - return { root, bundlePath: resolved, version: "unknown" } + return { root, bundlePath: resolved, version: "unknown" }; } } catch { - return null + return null; } - return null + return null; } - const meta = readPackageMeta(resolved) - if (!meta) return null - return { root: resolved, bundlePath: meta.bundlePath, version: meta.version } + const meta = readPackageMeta(resolved); + if (!meta) return null; + return { root: resolved, bundlePath: meta.bundlePath, version: meta.version }; } function tryRequireResolve(): ResolvedCommandCodePackage | null { try { - const require = createRequire(import.meta.url) - const pkgJson = require.resolve(`${NPM_PACKAGE}/package.json`) - return asPackageRoot(dirname(pkgJson)) + const require = createRequire(import.meta.url); + const pkgJson = require.resolve(`${NPM_PACKAGE}/package.json`); + return asPackageRoot(dirname(pkgJson)); } catch { // try resolving from cwd } try { - const require = createRequire(join(process.cwd(), "package.json")) - const pkgJson = require.resolve(`${NPM_PACKAGE}/package.json`) - return asPackageRoot(dirname(pkgJson)) + const require = createRequire(join(process.cwd(), "package.json")); + const pkgJson = require.resolve(`${NPM_PACKAGE}/package.json`); + return asPackageRoot(dirname(pkgJson)); } catch { - return null + return null; } } function walkNodeModules(startDir: string): ResolvedCommandCodePackage | null { - let dir = resolvePath(startDir) + let dir = resolvePath(startDir); for (let i = 0; i < 12; i++) { - const candidate = join(dir, "node_modules", NPM_PACKAGE) - const found = asPackageRoot(candidate) - if (found) return found - const parent = dirname(dir) - if (parent === dir) break - dir = parent + const candidate = join(dir, "node_modules", NPM_PACKAGE); + const found = asPackageRoot(candidate); + if (found) return found; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; } - return null + return null; } function tryNpmRootGlobal(): ResolvedCommandCodePackage | null { @@ -677,31 +672,29 @@ function tryNpmRootGlobal(): ResolvedCommandCodePackage | null { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 3000, - }).trim() - if (!root) return null - return asPackageRoot(join(root, NPM_PACKAGE)) + }).trim(); + if (!root) return null; + return asPackageRoot(join(root, NPM_PACKAGE)); } catch { - return null + return null; } } function tryWindowsAppData(): ResolvedCommandCodePackage | null { - const appData = process.env.APPDATA - if (!appData) return null - return asPackageRoot(join(appData, "npm", "node_modules", NPM_PACKAGE)) + const appData = process.env.APPDATA; + if (!appData) return null; + return asPackageRoot(join(appData, "npm", "node_modules", NPM_PACKAGE)); } export function resolveCommandCodePackage(options?: { - packagePath?: string + packagePath?: string; }): ResolvedCommandCodePackage | null { const explicit = - options?.packagePath?.trim() || - process.env.COMMANDCODE_PACKAGE_PATH?.trim() || - "" + options?.packagePath?.trim() || process.env.COMMANDCODE_PACKAGE_PATH?.trim() || ""; if (explicit) { - const found = asPackageRoot(explicit) - if (found) return found + const found = asPackageRoot(explicit); + if (found) return found; } return ( @@ -710,37 +703,37 @@ export function resolveCommandCodePackage(options?: { tryNpmRootGlobal() || tryWindowsAppData() || null - ) + ); } export function loadCatalogFromLocalCommandCode(options?: { - packagePath?: string + packagePath?: string; }): LocalCatalogResult | null { try { - const resolved = resolveCommandCodePackage(options) - if (!resolved) return null - const source = readFileSync(resolved.bundlePath, "utf-8") - const models = loadCatalogFromBundle(source) - if (models.length === 0) return null - return { models, version: resolved.version, root: resolved.root, bundleSource: source } + const resolved = resolveCommandCodePackage(options); + if (!resolved) return null; + const source = readFileSync(resolved.bundlePath, "utf-8"); + const models = loadCatalogFromBundle(source); + if (models.length === 0) return null; + return { models, version: resolved.version, root: resolved.root, bundleSource: source }; } catch { - return null + return null; } } export function toConfigKey(id: string): string { - const slashIdx = id.indexOf("/") - const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id - return short.toLowerCase() + const slashIdx = id.indexOf("/"); + const short = slashIdx >= 0 ? id.slice(slashIdx + 1) : id; + return short.toLowerCase(); } export function generateOpencodeModels(entries: ModelEntry[]): Record { - const models: Record = {} + const models: Record = {}; for (const entry of entries) { - const key = toConfigKey(entry.id) - const costObj: Record = { input: entry.cost.input, output: entry.cost.output } - if (entry.cost.cache_read !== undefined) costObj.cache_read = entry.cost.cache_read - if (entry.cost.cache_write !== undefined) costObj.cache_write = entry.cost.cache_write + const key = toConfigKey(entry.id); + const costObj: Record = { input: entry.cost.input, output: entry.cost.output }; + if (entry.cost.cache_read !== undefined) costObj.cache_read = entry.cost.cache_read; + if (entry.cost.cache_write !== undefined) costObj.cache_write = entry.cost.cache_write; const model: Record = { id: entry.id, @@ -749,23 +742,23 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record 0) { - model.reasoningEfforts = entry.reasoningEfforts - const variants: Record> = {} + model.reasoningEfforts = entry.reasoningEfforts; + const variants: Record> = {}; for (const effort of entry.reasoningEfforts) { - variants[effort] = { reasoningEffort: effort } + variants[effort] = { reasoningEffort: effort }; } - model.variants = variants + model.variants = variants; } - models[key] = model + models[key] = model; } - return models + return models; } // Keep import.meta.url resolution available for callers that need package-relative paths export function catalogModuleDir(): string { - return dirname(fileURLToPath(import.meta.url)) + return dirname(fileURLToPath(import.meta.url)); } diff --git a/src/convert.ts b/src/convert.ts index aa4e3e6..286b41a 100644 --- a/src/convert.ts +++ b/src/convert.ts @@ -1,4 +1,4 @@ -import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" +import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"; import type { LanguageModelV3FunctionTool, LanguageModelV3Message, @@ -7,139 +7,148 @@ import type { LanguageModelV3ToolCallPart, LanguageModelV3ToolResultPart, LanguageModelV3ToolResultOutput, -} from "@ai-sdk/provider" +} from "@ai-sdk/provider"; type CCMessage = | { role: "user"; content: string | unknown[] } | { role: "assistant"; content: CCAssistantContent[] } - | { role: "tool"; content: CCToolResultContent[] } + | { role: "tool"; content: CCToolResultContent[] }; type CCAssistantContent = | { type: "text"; text: string } | { type: "reasoning"; text: string } - | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown } + | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }; type CCToolResultContent = { - type: "tool-result" - toolCallId: string - toolName: string - output: { type: "text"; value: string } | { type: "error-text"; value: string } -} + type: "tool-result"; + toolCallId: string; + toolName: string; + output: { type: "text"; value: string } | { type: "error-text"; value: string }; +}; type CCTool = { - type: "function" - name: string - description?: string - input_schema: unknown -} + type: "function"; + name: string; + description?: string; + input_schema: unknown; +}; interface CCRequestEnvelope { config: { - workingDir: string - date: string - environment: string - structure: unknown[] - isGitRepo: boolean - currentBranch: string - mainBranch: string - gitStatus: string - recentCommits: unknown[] - } - memory: string - taste: string - skills: null - permissionMode: string + workingDir: string; + date: string; + environment: string; + structure: unknown[]; + isGitRepo: boolean; + currentBranch: string; + mainBranch: string; + gitStatus: string; + recentCommits: unknown[]; + }; + memory: string; + taste: string; + skills: null; + permissionMode: string; params: { - model: string - messages: CCMessage[] - tools: CCTool[] - system: string - max_tokens: number - stream: true - temperature?: number - top_p?: number - top_k?: number - } + model: string; + messages: CCMessage[]; + tools: CCTool[]; + system: string; + max_tokens: number; + stream: true; + temperature?: number; + top_p?: number; + top_k?: number; + }; } function hasType(p: unknown, type: string): boolean { - return typeof p === "object" && p !== null && (p as { type?: string }).type === type + return typeof p === "object" && p !== null && (p as { type?: string }).type === type; } function isTextPart(p: unknown): p is LanguageModelV3TextPart { - return hasType(p, "text") + return hasType(p, "text"); } function isReasoningPart(p: unknown): p is LanguageModelV3ReasoningPart { - return hasType(p, "reasoning") + return hasType(p, "reasoning"); } function isToolCallPart(p: unknown): p is LanguageModelV3ToolCallPart { - return hasType(p, "tool-call") + return hasType(p, "tool-call"); } function isToolResultPart(p: unknown): p is LanguageModelV3ToolResultPart { - return hasType(p, "tool-result") + return hasType(p, "tool-result"); } function extractText(content: unknown): string { - if (typeof content === "string") return content + if (typeof content === "string") return content; if (Array.isArray(content)) { - const textParts = content.filter(isTextPart) as LanguageModelV3TextPart[] - const nonTextParts = content.filter((p) => !isTextPart(p)) + const textParts = content.filter(isTextPart) as LanguageModelV3TextPart[]; + const nonTextParts = content.filter((p) => !isTextPart(p)); if (nonTextParts.length > 0 && textParts.length === 0) { - console.warn(`Command Code provider: dropped ${nonTextParts.length} non-text part(s) in user message`) + console.warn( + `Command Code provider: dropped ${nonTextParts.length} non-text part(s) in user message`, + ); } - return textParts.map((p) => p.text).join("\n") + return textParts.map((p) => p.text).join("\n"); } - return "" + return ""; } -function convertToolResultOutput(output: LanguageModelV3ToolResultOutput): CCToolResultContent["output"] { +function convertToolResultOutput( + output: LanguageModelV3ToolResultOutput, +): CCToolResultContent["output"] { switch (output.type) { case "text": - return { type: "text", value: output.value } + return { type: "text", value: output.value }; case "error-text": - return { type: "error-text", value: output.value } + return { type: "error-text", value: output.value }; case "json": - return { type: "text", value: JSON.stringify(output.value) } + return { type: "text", value: JSON.stringify(output.value) }; case "execution-denied": - return { type: "error-text", value: output.reason ?? "Execution denied" } + return { type: "error-text", value: output.reason ?? "Execution denied" }; case "error-json": - return { type: "error-text", value: JSON.stringify(output.value) } + return { type: "error-text", value: JSON.stringify(output.value) }; case "content": - return { type: "text", value: output.value.map((v: Record) => ("text" in v ? v.text : JSON.stringify(v))).join("\n") } + return { + type: "text", + value: output.value + .map((v: Record) => ("text" in v ? v.text : JSON.stringify(v))) + .join("\n"), + }; default: - return { type: "text", value: JSON.stringify(output) } + return { type: "text", value: JSON.stringify(output) }; } } function convertMessage(msg: LanguageModelV3Message): CCMessage | null { switch (msg.role) { case "user": { - const text = extractText(msg.content) - return { role: "user", content: text } + const text = extractText(msg.content); + return { role: "user", content: text }; } case "assistant": { - const parts: CCAssistantContent[] = [] + const parts: CCAssistantContent[] = []; for (const part of msg.content) { if (isTextPart(part)) { - parts.push({ type: "text", text: part.text }) + parts.push({ type: "text", text: part.text }); } else if (isReasoningPart(part)) { - parts.push({ type: "reasoning", text: part.text }) + parts.push({ type: "reasoning", text: part.text }); } else if (isToolCallPart(part)) { parts.push({ type: "tool-call", toolCallId: part.toolCallId, toolName: part.toolName, input: part.input, - }) + }); } } - return { role: "assistant", content: parts } + return { role: "assistant", content: parts }; } case "tool": { - const parts: CCToolResultContent[] = [] + const parts: CCToolResultContent[] = []; for (const part of msg.content) { if (isToolResultPart(part)) { parts.push({ @@ -147,20 +156,30 @@ function convertMessage(msg: LanguageModelV3Message): CCMessage | null { toolCallId: part.toolCallId, toolName: part.toolName, output: convertToolResultOutput(part.output), - }) + }); } } - return { role: "tool", content: parts } + return { role: "tool", content: parts }; } default: - return null + return null; } } function convertTools( - tools: Array }> | undefined, + tools: + | Array< + | LanguageModelV3FunctionTool + | { + type: "provider"; + id: `${string}.${string}`; + name: string; + args: Record; + } + > + | undefined, ): CCTool[] { - if (!tools) return [] + if (!tools) return []; return tools .filter((t): t is LanguageModelV3FunctionTool => t.type === "function") .map((t) => ({ @@ -168,23 +187,23 @@ function convertTools( name: t.name, description: t.description, input_schema: t.inputSchema, - })) + })); } export function buildRequest( modelId: string, options: LanguageModelV3CallOptions, ): CCRequestEnvelope { - let systemPrompt = "" - const messages: CCMessage[] = [] + let systemPrompt = ""; + const messages: CCMessage[] = []; for (const msg of options.prompt) { if (msg.role === "system") { - systemPrompt += (systemPrompt ? "\n\n" : "") + msg.content - continue + systemPrompt += (systemPrompt ? "\n\n" : "") + msg.content; + continue; } - const converted = convertMessage(msg) - if (converted) messages.push(converted) + const converted = convertMessage(msg); + if (converted) messages.push(converted); } const params: CCRequestEnvelope["params"] = { @@ -194,11 +213,11 @@ export function buildRequest( system: systemPrompt, max_tokens: options.maxOutputTokens ?? 16384, stream: true, - } + }; - if (options.temperature !== undefined) params.temperature = options.temperature - if (options.topP !== undefined) params.top_p = options.topP - if (options.topK !== undefined) params.top_k = options.topK + if (options.temperature !== undefined) params.temperature = options.temperature; + if (options.topP !== undefined) params.top_p = options.topP; + if (options.topK !== undefined) params.top_k = options.topK; return { config: { @@ -219,5 +238,5 @@ export function buildRequest( skills: null, permissionMode: "standard", params, - } + }; } diff --git a/src/costs-docs.ts b/src/costs-docs.ts index 0d264be..3a02b91 100644 --- a/src/costs-docs.ts +++ b/src/costs-docs.ts @@ -1,24 +1,24 @@ -import type { ModelEntry } from "./catalog.js" +import type { ModelEntry } from "./catalog.js"; export type DocCostRow = { - name: string - id?: string - input: number - output: number - cache_read?: number - cache_write?: number -} + name: string; + id?: string; + input: number; + output: number; + cache_read?: number; + cache_write?: number; +}; export function parseMoneyCell(cell: string): number | undefined { - const trimmed = cell.trim() - if (!trimmed || trimmed === "—" || trimmed === "-") return undefined - if (/^free$/i.test(trimmed)) return 0 - const amounts = [...trimmed.matchAll(/\$([0-9]+(?:\.[0-9]+)?)/g)].map((m) => Number(m[1])) - if (amounts.length === 0) return undefined - return amounts[amounts.length - 1] + const trimmed = cell.trim(); + if (!trimmed || trimmed === "—" || trimmed === "-") return undefined; + if (/^free$/i.test(trimmed)) return 0; + const amounts = [...trimmed.matchAll(/\$([0-9]+(?:\.[0-9]+)?)/g)].map((m) => Number(m[1])); + if (amounts.length === 0) return undefined; + return amounts[amounts.length - 1]; } -type CellRow = { cells: string[]; id?: string } +type CellRow = { cells: string[]; id?: string }; function decodeEntities(text: string): string { return text @@ -27,113 +27,115 @@ function decodeEntities(text: string): string { .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") - .replace(/ /g, " ") + .replace(/ /g, " "); } function stripTags(html: string): string { - return decodeEntities(html.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim() + return decodeEntities(html.replace(/<[^>]+>/g, " ")) + .replace(/\s+/g, " ") + .trim(); } function visibleCellText(innerHtml: string): string { - const trunc = innerHtml.match(/]*>([^<]*)<\/span>/i) - if (trunc?.[1]) return decodeEntities(trunc[1]).trim() - return stripTags(innerHtml) + const trunc = innerHtml.match(/]*>([^<]*)<\/span>/i); + if (trunc?.[1]) return decodeEntities(trunc[1]).trim(); + return stripTags(innerHtml); } function headerName(cell: string): string { - return cell.replace(/↕/g, "").replace(/\s+/g, " ").trim().toLowerCase() + return cell.replace(/↕/g, "").replace(/\s+/g, " ").trim().toLowerCase(); } function colIndex(headers: string[], name: string): number { - return headers.findIndex((h) => h === name || h.startsWith(`${name} `) || h.startsWith(name)) + return headers.findIndex((h) => h === name || h.startsWith(`${name} `) || h.startsWith(name)); } function pipeRows(markdown: string): CellRow[] { - const rows: CellRow[] = [] + const rows: CellRow[] = []; for (const line of markdown.split("\n")) { - if (!line.includes("|")) continue + if (!line.includes("|")) continue; const cells = line .split("|") .map((c) => c.trim()) - .filter((c, i, arr) => !(i === 0 && c === "") && !(i === arr.length - 1 && c === "")) - if (cells.length) rows.push({ cells }) + .filter((c, i, arr) => !(i === 0 && c === "") && !(i === arr.length - 1 && c === "")); + if (cells.length) rows.push({ cells }); } - return rows + return rows; } function htmlTableRows(html: string): CellRow[] { - const rows: CellRow[] = [] - const tables = html.match(//gi) ?? [] + const rows: CellRow[] = []; + const tables = html.match(//gi) ?? []; for (const table of tables) { - const trs = table.match(//gi) ?? [] + const trs = table.match(//gi) ?? []; for (const tr of trs) { - const rawCells = [...tr.matchAll(//gi)].map((m) => m[0]) - if (rawCells.length === 0) continue + const rawCells = [...tr.matchAll(//gi)].map((m) => m[0]); + if (rawCells.length === 0) continue; const cells = rawCells.map((raw) => { - const inner = raw.replace(/^]*>/i, "").replace(/<\/t[dh]>$/i, "") - return visibleCellText(inner) - }) - const href = tr.match(/href="\/models\/([^"?#]+)"/i)?.[1] - rows.push({ cells, ...(href ? { id: href } : {}) }) + const inner = raw.replace(/^]*>/i, "").replace(/<\/t[dh]>$/i, ""); + return visibleCellText(inner); + }); + const href = tr.match(/href="\/models\/([^"?#]+)"/i)?.[1]; + rows.push({ cells, ...(href ? { id: href } : {}) }); } } - return rows + return rows; } function rowsFromGroup(group: CellRow[]): DocCostRow[] { - if (group.length === 0) return [] + if (group.length === 0) return []; - let modelI = 0 - let inputI = 2 - let outputI = 3 - let cacheReadI = 4 - let cacheWriteI = 5 - let start = 0 + let modelI = 0; + let inputI = 2; + let outputI = 3; + let cacheReadI = 4; + let cacheWriteI = 5; + let start = 0; - const headerIdx = group.findIndex((r) => /^model\b/i.test(headerName(r.cells[0] ?? ""))) + const headerIdx = group.findIndex((r) => /^model\b/i.test(headerName(r.cells[0] ?? ""))); if (headerIdx >= 0) { - const headers = group[headerIdx]!.cells.map(headerName) - const foundInput = colIndex(headers, "input") - const foundOutput = colIndex(headers, "output") + const headers = group[headerIdx]!.cells.map(headerName); + const foundInput = colIndex(headers, "input"); + const foundOutput = colIndex(headers, "output"); if (foundInput >= 0 && foundOutput >= 0) { - const foundModel = colIndex(headers, "model") - if (foundModel >= 0) modelI = foundModel - inputI = foundInput - outputI = foundOutput - cacheReadI = colIndex(headers, "cache read") - cacheWriteI = colIndex(headers, "cache write") - start = headerIdx + 1 + const foundModel = colIndex(headers, "model"); + if (foundModel >= 0) modelI = foundModel; + inputI = foundInput; + outputI = foundOutput; + cacheReadI = colIndex(headers, "cache read"); + cacheWriteI = colIndex(headers, "cache write"); + start = headerIdx + 1; } } - const rows: DocCostRow[] = [] + const rows: DocCostRow[] = []; for (let i = start; i < group.length; i++) { - const { cells, id } = group[i]! - if (cells.length < 2) continue - const first = headerName(cells[0] ?? "") - if (/^model$/.test(first) || /^---/.test(cells[0] ?? "")) continue - const input = parseMoneyCell(cells[inputI] ?? "") - const output = parseMoneyCell(cells[outputI] ?? "") - if (input === undefined || output === undefined) continue - const name = (cells[modelI] ?? "").trim() - if (!name) continue - const row: DocCostRow = { name, input, output } - if (id) row.id = id + const { cells, id } = group[i]!; + if (cells.length < 2) continue; + const first = headerName(cells[0] ?? ""); + if (first === "model" || (cells[0] ?? "").startsWith("---")) continue; + const input = parseMoneyCell(cells[inputI] ?? ""); + const output = parseMoneyCell(cells[outputI] ?? ""); + if (input === undefined || output === undefined) continue; + const name = (cells[modelI] ?? "").trim(); + if (!name) continue; + const row: DocCostRow = { name, input, output }; + if (id) row.id = id; if (cacheReadI >= 0) { - const cacheRead = parseMoneyCell(cells[cacheReadI] ?? "") - if (cacheRead !== undefined) row.cache_read = cacheRead + const cacheRead = parseMoneyCell(cells[cacheReadI] ?? ""); + if (cacheRead !== undefined) row.cache_read = cacheRead; } if (cacheWriteI >= 0) { - const cacheWrite = parseMoneyCell(cells[cacheWriteI] ?? "") - if (cacheWrite !== undefined) row.cache_write = cacheWrite + const cacheWrite = parseMoneyCell(cells[cacheWriteI] ?? ""); + if (cacheWrite !== undefined) row.cache_write = cacheWrite; } - rows.push(row) + rows.push(row); } - return rows + return rows; } export function parseModelsTable(markdown: string): DocCostRow[] { - return [...rowsFromGroup(pipeRows(markdown)), ...rowsFromGroup(htmlTableRows(markdown))] + return [...rowsFromGroup(pipeRows(markdown)), ...rowsFromGroup(htmlTableRows(markdown))]; } export function applyDocCosts( @@ -142,36 +144,36 @@ export function applyDocCosts( cliIds: Set = new Set(), filledIds?: Set, ): number { - const byName = new Map(rows.map((r) => [r.name.toLowerCase(), r])) - const byId = new Map(rows.filter((r) => r.id).map((r) => [r.id!.toLowerCase(), r])) - let filled = 0 + const byName = new Map(rows.map((r) => [r.name.toLowerCase(), r])); + const byId = new Map(rows.filter((r) => r.id).map((r) => [r.id!.toLowerCase(), r])); + let filled = 0; for (const model of models) { - if (cliIds.has(model.id)) continue - const row = byId.get(model.id.toLowerCase()) ?? byName.get(model.name.toLowerCase()) - if (!row) continue - model.cost = { input: row.input, output: row.output } - if (row.cache_read !== undefined) model.cost.cache_read = row.cache_read - if (row.cache_write !== undefined) model.cost.cache_write = row.cache_write - filledIds?.add(model.id) - filled++ + if (cliIds.has(model.id)) continue; + const row = byId.get(model.id.toLowerCase()) ?? byName.get(model.name.toLowerCase()); + if (!row) continue; + model.cost = { input: row.input, output: row.output }; + if (row.cache_read !== undefined) model.cost.cache_read = row.cache_read; + if (row.cache_write !== undefined) model.cost.cache_write = row.cache_write; + filledIds?.add(model.id); + filled++; } - return filled + return filled; } export async function fetchOfficialModelsMarkdown(): Promise { const urls = [ "https://commandcode.ai/models", "https://commandcode.ai/docs/resources/pricing-limits", - ] + ]; for (const url of urls) { try { - const resp = await fetch(url) - if (!resp.ok) continue - const text = await resp.text() - if (parseModelsTable(text).length > 0) return text + const resp = await fetch(url); + if (!resp.ok) continue; + const text = await resp.text(); + if (parseModelsTable(text).length > 0) return text; } catch { // try next } } - return "" + return ""; } diff --git a/src/costs-models-dev.ts b/src/costs-models-dev.ts new file mode 100644 index 0000000..d216956 --- /dev/null +++ b/src/costs-models-dev.ts @@ -0,0 +1,121 @@ +import type { ModelEntry } from "./catalog.js"; + +export const MODELS_DEV_URL = "https://models.dev/api.json"; +export const FREE_COST = { input: 0, output: 0 } as const; + +export type ModelsDevRow = { + id: string; + name: string; + cost: { input: number; output: number; cache_read?: number; cache_write?: number }; +}; + +type ModelsDevModel = { + id?: string; + name?: string; + cost?: { input?: number; output?: number; cache_read?: number; cache_write?: number }; +}; + +type ModelsDevProvider = { models?: Record }; + +function lastSegment(id: string): string { + const i = id.lastIndexOf("/"); + return i >= 0 ? id.slice(i + 1) : id; +} + +export function isFreeSku(model: { id: string; name: string }): boolean { + if (/-free$/i.test(model.id)) return true; + return /\bfree\b/i.test(`${model.id} ${model.name}`); +} + +export function parseModelsDev(json: string): ModelsDevRow[] { + const data = JSON.parse(json) as Record; + const rows: ModelsDevRow[] = []; + const seen = new Set(); + for (const provider of Object.keys(data).sort()) { + const models = data[provider]?.models ?? {}; + for (const model of Object.values(models)) { + if (!model?.id || model.cost?.input === undefined || model.cost?.output === undefined) + continue; + const key = model.id.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + const cost: ModelsDevRow["cost"] = { input: model.cost.input, output: model.cost.output }; + if (model.cost.cache_read !== undefined) cost.cache_read = model.cost.cache_read; + if (model.cost.cache_write !== undefined) cost.cache_write = model.cost.cache_write; + rows.push({ id: model.id, name: model.name ?? model.id, cost }); + } + } + return rows; +} + +function indexRows(rows: ModelsDevRow[]) { + const byId = new Map(); + const bySegment = new Map(); + const byName = new Map(); + for (const row of rows) { + const idKey = row.id.toLowerCase(); + if (!byId.has(idKey)) byId.set(idKey, row); + const segment = lastSegment(row.id).toLowerCase(); + if (!bySegment.has(segment)) bySegment.set(segment, row); + const nameKey = row.name.toLowerCase(); + if (!byName.has(nameKey)) byName.set(nameKey, row); + } + return { byId, bySegment, byName }; +} + +function findRow(model: ModelEntry, index: ReturnType): ModelsDevRow | undefined { + return ( + index.byId.get(model.id.toLowerCase()) ?? + index.bySegment.get(lastSegment(model.id).toLowerCase()) ?? + index.byName.get(model.name.toLowerCase()) + ); +} + +export function applyFreeCosts( + models: ModelEntry[], + skipIds: Set, + filledIds?: Set, +): number { + let filled = 0; + for (const model of models) { + if (skipIds.has(model.id) || !isFreeSku(model)) continue; + model.cost = { ...FREE_COST }; + filledIds?.add(model.id); + filled++; + } + return filled; +} + +export function applyModelsDevCosts( + models: ModelEntry[], + rows: ModelsDevRow[], + skipIds: Set, + filledIds?: Set, +): number { + const index = indexRows(rows); + let filled = 0; + for (const model of models) { + if (skipIds.has(model.id) || isFreeSku(model)) continue; + const row = findRow(model, index); + if (!row) continue; + model.cost = { input: row.cost.input, output: row.cost.output }; + if (row.cost.cache_read !== undefined) model.cost.cache_read = row.cost.cache_read; + if (row.cost.cache_write !== undefined) model.cost.cache_write = row.cost.cache_write; + filledIds?.add(model.id); + filled++; + } + return filled; +} + +export async function fetchModelsDevJson(): Promise { + const resp = await fetch(MODELS_DEV_URL, { + headers: { + // ponytail: models.dev returns 403 without a browser-like UA; upgrade if they add a real API token + "User-Agent": + "Mozilla/5.0 (compatible; commandcode-go-opencode-provider/0.5; +https://github.com/BrainerVirus/opencode-commandcode-provider)", + Accept: "application/json", + }, + }); + if (!resp.ok) throw new Error(`models.dev returned ${resp.status}`); + return resp.text(); +} diff --git a/src/manifest.ts b/src/manifest.ts index c7d17b0..c372547 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -1,77 +1,87 @@ -import { writeFileSync } from "fs" +import { writeFileSync } from "fs"; -export type CatalogStatus = "healthy" | "degraded" | "broken" -export type CostCatalogBest = "cli" | "docs" | "thirdParty" | "fallback" | "missing" +export type CatalogStatus = "healthy" | "degraded" | "broken"; +export type CostCatalogBest = "cli" | "docs" | "thirdParty" | "free" | "fallback" | "missing"; export type CostSources = { - cli: number - officialDocs: number - thirdParty: number - fallback: number - unmatched: number -} + cli: number; + officialDocs: number; + thirdParty: number; + free: number; + fallback: number; + unmatched: number; +}; + +export type CatalogReview = { + thirdParty: string[]; + free: string[]; + unmatched: string[]; +}; export type CatalogManifest = { - schemaVersion: 1 - generatedAt: string - pluginVersion: string - commandCodeVersion: string - commandCodeTarball: string - modelCount: number - reasoningModelCount: number + schemaVersion: 1; + generatedAt: string; + pluginVersion: string; + commandCodeVersion: string; + commandCodeTarball: string; + modelCount: number; + reasoningModelCount: number; extraction: { - modelCatalog: "ok" | "failed" - costCatalog: CostCatalogBest - costCatalogError: string | null - } - costSources: CostSources - status: CatalogStatus -} + modelCatalog: "ok" | "failed"; + costCatalog: CostCatalogBest; + costCatalogError: string | null; + }; + costSources: CostSources; + review?: CatalogReview; + status: CatalogStatus; +}; export type BuildManifestInput = { - pluginVersion: string - commandCodeVersion: string - commandCodeTarball: string - modelCount: number - reasoningModelCount: number - modelCatalogOk: boolean - costSources: CostSources - generatedAt: string - costCatalogError?: string | null -} + pluginVersion: string; + commandCodeVersion: string; + commandCodeTarball: string; + modelCount: number; + reasoningModelCount: number; + modelCatalogOk: boolean; + costSources: CostSources; + review?: CatalogReview; + generatedAt: string; + costCatalogError?: string | null; +}; export function bumpPatch(version: string): string { - const parts = version.split(".") - const patch = Number(parts[2] ?? "0") - return `${parts[0] ?? "0"}.${parts[1] ?? "0"}.${patch + 1}` + const parts = version.split("."); + const patch = Number(parts[2] ?? "0"); + return `${parts[0] ?? "0"}.${parts[1] ?? "0"}.${patch + 1}`; } export function meetsModelCountFloor(modelCount: number, lastSuccessful: number | null): boolean { - const floor = Math.max(20, Math.floor((lastSuccessful ?? 20) * 0.5)) + const floor = Math.max(20, Math.floor((lastSuccessful ?? 20) * 0.5)); // ponytail: when there is no prior catalog, lastSuccessful is null and the floor is 20 - return modelCount >= (lastSuccessful === null ? 20 : floor) + return modelCount >= (lastSuccessful === null ? 20 : floor); } export function bestCostCatalog(sources: CostSources): CostCatalogBest { - if (sources.cli > 0) return "cli" - if (sources.officialDocs > 0) return "docs" - if (sources.thirdParty > 0) return "thirdParty" - if (sources.fallback > 0) return "fallback" - return "missing" + if (sources.cli > 0) return "cli"; + if (sources.officialDocs > 0) return "docs"; + if (sources.thirdParty > 0) return "thirdParty"; + if (sources.free > 0) return "free"; + if (sources.fallback > 0) return "fallback"; + return "missing"; } export function catalogStatus(modelCatalogOk: boolean, sources: CostSources): CatalogStatus { - if (!modelCatalogOk) return "broken" - if (sources.fallback > 0 || sources.unmatched > 0) return "degraded" - return "healthy" + if (!modelCatalogOk) return "broken"; + if (sources.unmatched > 0) return "degraded"; + return "healthy"; } export function commandCodeTarballUrl(version: string): string { - return `https://registry.npmjs.org/command-code/-/command-code-${version}.tgz` + return `https://registry.npmjs.org/command-code/-/command-code-${version}.tgz`; } export function buildManifest(input: BuildManifestInput): CatalogManifest { - const status = catalogStatus(input.modelCatalogOk, input.costSources) + const status = catalogStatus(input.modelCatalogOk, input.costSources); return { schemaVersion: 1, generatedAt: input.generatedAt, @@ -86,47 +96,52 @@ export function buildManifest(input: BuildManifestInput): CatalogManifest { costCatalogError: input.costCatalogError ?? null, }, costSources: { ...input.costSources }, + ...(input.review ? { review: input.review } : {}), status, - } + }; } export function writeManifest(path: string, manifest: CatalogManifest): void { - writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8") + writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); } export function lastSuccessfulModelCount(manifest: CatalogManifest | null): number | null { - if (!manifest) return null - if (manifest.status === "broken") return null - return manifest.modelCount + if (!manifest) return null; + if (manifest.status === "broken") return null; + return manifest.modelCount; } export function countCostSources(input: { - modelIds: string[] - cliIds: Set - officialDocIds: Set - thirdPartyIds: Set - fallbackIds: Set + modelIds: string[]; + cliIds: Set; + officialDocIds: Set; + thirdPartyIds: Set; + freeIds: Set; + fallbackIds?: Set; }): CostSources { + const fallbackIds = input.fallbackIds ?? new Set(); const sources: CostSources = { cli: 0, officialDocs: 0, thirdParty: 0, + free: 0, fallback: 0, unmatched: 0, - } + }; for (const id of input.modelIds) { - if (input.cliIds.has(id)) sources.cli++ - else if (input.officialDocIds.has(id)) sources.officialDocs++ - else if (input.thirdPartyIds.has(id)) sources.thirdParty++ - else if (input.fallbackIds.has(id)) sources.fallback++ - else sources.unmatched++ + if (input.cliIds.has(id)) sources.cli++; + else if (input.officialDocIds.has(id)) sources.officialDocs++; + else if (input.thirdPartyIds.has(id)) sources.thirdParty++; + else if (input.freeIds.has(id)) sources.free++; + else if (fallbackIds.has(id)) sources.fallback++; + else sources.unmatched++; } - return sources + return sources; } export function bumpPackageVersionField(pkgJson: string): { json: string; version: string } { - const pkg = JSON.parse(pkgJson) as { version: string } - const version = bumpPatch(pkg.version) - const json = pkgJson.replace(/("version"\s*:\s*")([^"]+)(")/, `$1${version}$3`) - return { json, version } + const pkg = JSON.parse(pkgJson) as { version: string }; + const version = bumpPatch(pkg.version); + const json = pkgJson.replace(/("version"\s*:\s*")([^"]+)(")/, `$1${version}$3`); + return { json, version }; } diff --git a/src/model.ts b/src/model.ts index 8d0cbe7..605c98b 100644 --- a/src/model.ts +++ b/src/model.ts @@ -6,35 +6,35 @@ import type { LanguageModelV3Content, LanguageModelV3Usage, LanguageModelV3FinishReason, -} from "@ai-sdk/provider" -import { buildRequest } from "./convert.js" -import { parseStreamEvents } from "./stream.js" +} from "@ai-sdk/provider"; +import { buildRequest } from "./convert.js"; +import { parseStreamEvents } from "./stream.js"; -const DEFAULT_BASE_URL = "https://api.commandcode.ai" +const DEFAULT_BASE_URL = "https://api.commandcode.ai"; // x-command-code-version must match the Command Code CLI version for API compatibility -const CC_VERSION = "0.26.20" +const CC_VERSION = "0.26.20"; export interface CommandCodeModelOptions { - apiKey: string - baseURL?: string - headers?: Record + apiKey: string; + baseURL?: string; + headers?: Record; } export class CommandCodeLanguageModel implements LanguageModelV3 { - readonly specificationVersion = "v3" as const - readonly provider = "commandcode" - readonly modelId: string - supportedUrls: Record = {} + readonly specificationVersion = "v3" as const; + readonly provider = "commandcode"; + readonly modelId: string; + supportedUrls: Record = {}; - private opts: CommandCodeModelOptions + private opts: CommandCodeModelOptions; constructor(modelId: string, opts: CommandCodeModelOptions) { - this.modelId = modelId - this.opts = opts + this.modelId = modelId; + this.opts = opts; } private get baseURL(): string { - return this.opts.baseURL ?? DEFAULT_BASE_URL + return this.opts.baseURL ?? DEFAULT_BASE_URL; } private buildHeaders(): Record { @@ -45,19 +45,22 @@ export class CommandCodeLanguageModel implements LanguageModelV3 { "x-cli-environment": "production", "x-project-slug": "opencode", ...this.opts.headers, - } + }; } async doStream(options: LanguageModelV3CallOptions): Promise { - const body = buildRequest(this.modelId, options) - const requestBody = JSON.stringify(body) - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(new Error("Request timed out after 5 minutes")), 300_000) - const userSignal = options.abortSignal + const body = buildRequest(this.modelId, options); + const requestBody = JSON.stringify(body); + + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new Error("Request timed out after 5 minutes")), + 300_000, + ); + const userSignal = options.abortSignal; if (userSignal) { - const onAbort = () => controller.abort(userSignal.reason) - userSignal.addEventListener("abort", onAbort, { once: true }) + const onAbort = () => controller.abort(userSignal.reason); + userSignal.addEventListener("abort", onAbort, { once: true }); } try { @@ -66,94 +69,100 @@ export class CommandCodeLanguageModel implements LanguageModelV3 { headers: this.buildHeaders(), body: requestBody, signal: controller.signal, - }) + }); if (!response.ok) { - const errorBody = await response.text().catch(() => "") - let errorMessage = `Command Code API error: ${response.status} ${response.statusText}` + const errorBody = await response.text().catch(() => ""); + let errorMessage = `Command Code API error: ${response.status} ${response.statusText}`; try { - const parsed = JSON.parse(errorBody) - if (parsed.error?.message) errorMessage = parsed.error.message - else if (parsed.message) errorMessage = parsed.message + const parsed = JSON.parse(errorBody); + if (parsed.error?.message) errorMessage = parsed.error.message; + else if (parsed.message) errorMessage = parsed.message; } catch { // intentionally silent: error body is not JSON - } throw new Error(`${errorMessage} [model=${this.modelId}]`) + } + throw new Error(`${errorMessage} [model=${this.modelId}]`); } if (!response.body) { - throw new Error(`Command Code API returned no body [model=${this.modelId}]`) + throw new Error(`Command Code API returned no body [model=${this.modelId}]`); } - const responseHeaders: Record = {} + const responseHeaders: Record = {}; response.headers.forEach((v, k) => { - responseHeaders[k] = v - }) + responseHeaders[k] = v; + }); return { stream: parseStreamEvents(response.body as ReadableStream), request: { body: requestBody }, response: { headers: responseHeaders }, - } + }; } finally { - clearTimeout(timeout) + clearTimeout(timeout); } } async doGenerate(options: LanguageModelV3CallOptions): Promise { - const { stream } = await this.doStream(options) + const { stream } = await this.doStream(options); - const textParts: string[] = [] - const reasoningParts: string[] = [] - const content: LanguageModelV3Content[] = [] - let finishReason: LanguageModelV3FinishReason = { unified: "stop", raw: "stop" } + const textParts: string[] = []; + const reasoningParts: string[] = []; + const content: LanguageModelV3Content[] = []; + let finishReason: LanguageModelV3FinishReason = { unified: "stop", raw: "stop" }; let usage: LanguageModelV3Usage = { - inputTokens: { total: undefined, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + inputTokens: { + total: undefined, + noCache: undefined, + cacheRead: undefined, + cacheWrite: undefined, + }, outputTokens: { total: undefined, text: undefined, reasoning: undefined }, - } + }; - const reader = stream.getReader() + const reader = stream.getReader(); try { while (true) { - const { done, value } = await reader.read() - if (done) break + const { done, value } = await reader.read(); + if (done) break; switch (value.type) { case "text-delta": - textParts.push(value.delta) - break + textParts.push(value.delta); + break; case "reasoning-delta": - reasoningParts.push(value.delta) - break + reasoningParts.push(value.delta); + break; case "tool-call": content.push({ type: "tool-call", toolCallId: value.toolCallId, toolName: value.toolName, input: value.input, - }) - break + }); + break; case "finish": - finishReason = value.finishReason - usage = value.usage - break + finishReason = value.finishReason; + usage = value.usage; + break; } } } finally { - reader.releaseLock() - stream.cancel() + reader.releaseLock(); + stream.cancel(); } - const text = textParts.join("") - if (text) content.unshift({ type: "text", text }) + const text = textParts.join(""); + if (text) content.unshift({ type: "text", text }); - const reasoning = reasoningParts.join("") - if (reasoning) content.unshift({ type: "reasoning", text: reasoning }) + const reasoning = reasoningParts.join(""); + if (reasoning) content.unshift({ type: "reasoning", text: reasoning }); return { content, finishReason, usage, warnings: [], - } + }; } } diff --git a/src/publish-policy.ts b/src/publish-policy.ts index a7993b6..e81eff5 100644 --- a/src/publish-policy.ts +++ b/src/publish-policy.ts @@ -1,32 +1,32 @@ export type CatalogSyncDecision = { - extract: boolean - publishRetry: boolean - exit: boolean -} + extract: boolean; + publishRetry: boolean; + exit: boolean; +}; -export type PublishDecision = "publish" | "skip-no-token" | "skip-already-published" +export type PublishDecision = "publish" | "skip-no-token" | "skip-already-published"; export function decideCatalogSync(input: { - force: boolean - latestCommandCodeVersion: string - bundledCommandCodeVersion: string | null - pluginVersion: string - publishedPluginVersions: string[] + force: boolean; + latestCommandCodeVersion: string; + bundledCommandCodeVersion: string | null; + pluginVersion: string; + publishedPluginVersions: string[]; }): CatalogSyncDecision { - const published = input.publishedPluginVersions.includes(input.pluginVersion) + const published = input.publishedPluginVersions.includes(input.pluginVersion); if (input.force || input.latestCommandCodeVersion !== input.bundledCommandCodeVersion) { - return { extract: true, publishRetry: false, exit: false } + return { extract: true, publishRetry: false, exit: false }; } - if (!published) return { extract: false, publishRetry: true, exit: false } - return { extract: false, publishRetry: false, exit: true } + if (!published) return { extract: false, publishRetry: true, exit: false }; + return { extract: false, publishRetry: false, exit: true }; } export function decidePublish(input: { - pluginVersion: string - publishedPluginVersions: string[] - npmTokenSet: boolean + pluginVersion: string; + publishedPluginVersions: string[]; + npmTokenSet: boolean; }): PublishDecision { - if (input.publishedPluginVersions.includes(input.pluginVersion)) return "skip-already-published" - if (!input.npmTokenSet) return "skip-no-token" - return "publish" + if (input.publishedPluginVersions.includes(input.pluginVersion)) return "skip-already-published"; + if (!input.npmTokenSet) return "skip-no-token"; + return "publish"; } diff --git a/src/startup.ts b/src/startup.ts index c65248e..a7a6d0a 100644 --- a/src/startup.ts +++ b/src/startup.ts @@ -1,43 +1,43 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" -import { homedir } from "os" -import { join } from "path" -import type { ModelEntry } from "./catalog.js" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; +import type { ModelEntry } from "./catalog.js"; -export type { ModelEntry } from "./catalog.js" +export type { ModelEntry } from "./catalog.js"; export type StartupSummary = { - catalogSource: "bundled" | "cache" | "opt-in-local" - commandCodeVersion: string | null - modelCount: number - reasoningModelCount: number - degraded: boolean - degradedReason: string | null -} + catalogSource: "bundled" | "cache" | "opt-in-local"; + commandCodeVersion: string | null; + modelCount: number; + reasoningModelCount: number; + degraded: boolean; + degradedReason: string | null; +}; export function pluginStateDir(): string { - const override = process.env.COMMANDCODE_PROVIDER_STATE_DIR?.trim() - if (override) return override - return join(homedir(), ".local/state/opencode/commandcode-provider") + const override = process.env.COMMANDCODE_PROVIDER_STATE_DIR?.trim(); + if (override) return override; + return join(homedir(), ".local/state/opencode/commandcode-provider"); } export function readCatalogCache(dir = pluginStateDir()): ModelEntry[] | null { - const path = join(dir, "catalog-cache.json") - if (!existsSync(path)) return null + const path = join(dir, "catalog-cache.json"); + if (!existsSync(path)) return null; try { - const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown - if (!Array.isArray(parsed) || parsed.length === 0) return null - return parsed as ModelEntry[] + const parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown; + if (!Array.isArray(parsed) || parsed.length === 0) return null; + return parsed as ModelEntry[]; } catch { - return null + return null; } } export function writeCatalogCache(dir: string, models: ModelEntry[]): void { - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, "catalog-cache.json"), JSON.stringify(models) + "\n", "utf-8") + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "catalog-cache.json"), JSON.stringify(models) + "\n", "utf-8"); } export function writeStartupSummary(dir: string, summary: StartupSummary): void { - mkdirSync(dir, { recursive: true }) - writeFileSync(join(dir, "startup.json"), JSON.stringify(summary) + "\n", "utf-8") + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "startup.json"), JSON.stringify(summary) + "\n", "utf-8"); } diff --git a/src/stream.ts b/src/stream.ts index e4f85a3..d10b33d 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -1,63 +1,99 @@ -import type { LanguageModelV3StreamPart, LanguageModelV3Usage, LanguageModelV3FinishReason } from "@ai-sdk/provider" +import type { + LanguageModelV3StreamPart, + LanguageModelV3Usage, + LanguageModelV3FinishReason, +} from "@ai-sdk/provider"; -type RawEvent = Record & { type: string } +type RawEvent = Record & { type: string }; function mapFinishReason(raw: string): LanguageModelV3FinishReason["unified"] { switch (raw) { case "stop": case "end_turn": - return "stop" + return "stop"; case "tool_calls": case "tool-calls": - return "tool-calls" + return "tool-calls"; case "length": case "max_tokens": case "max-tokens": case "max_output_tokens": - return "length" + return "length"; case "content_filter": - return "content-filter" + return "content-filter"; default: - return "other" + return "other"; } } function mapUsage(raw: Record): LanguageModelV3Usage { - const inputDetails = (raw.inputTokenDetails ?? raw.input_token_details ?? {}) as Record - const outputDetails = (raw.outputTokenDetails ?? raw.output_token_details ?? {}) as Record + const inputDetails = (raw.inputTokenDetails ?? raw.input_token_details ?? {}) as Record< + string, + unknown + >; + const outputDetails = (raw.outputTokenDetails ?? raw.output_token_details ?? {}) as Record< + string, + unknown + >; return { inputTokens: { - total: typeof raw.inputTokens === "number" ? raw.inputTokens : typeof raw.prompt_tokens === "number" ? raw.prompt_tokens : undefined, - noCache: typeof inputDetails.noCacheTokens === "number" ? inputDetails.noCacheTokens : undefined, - cacheRead: typeof inputDetails.cacheReadTokens === "number" ? inputDetails.cacheReadTokens : undefined, - cacheWrite: typeof inputDetails.cacheWriteTokens === "number" ? inputDetails.cacheWriteTokens : undefined, + total: + typeof raw.inputTokens === "number" + ? raw.inputTokens + : typeof raw.prompt_tokens === "number" + ? raw.prompt_tokens + : undefined, + noCache: + typeof inputDetails.noCacheTokens === "number" ? inputDetails.noCacheTokens : undefined, + cacheRead: + typeof inputDetails.cacheReadTokens === "number" ? inputDetails.cacheReadTokens : undefined, + cacheWrite: + typeof inputDetails.cacheWriteTokens === "number" + ? inputDetails.cacheWriteTokens + : undefined, }, outputTokens: { - total: typeof raw.outputTokens === "number" ? raw.outputTokens : typeof raw.completion_tokens === "number" ? raw.completion_tokens : undefined, + total: + typeof raw.outputTokens === "number" + ? raw.outputTokens + : typeof raw.completion_tokens === "number" + ? raw.completion_tokens + : undefined, text: typeof outputDetails.textTokens === "number" ? outputDetails.textTokens : undefined, - reasoning: typeof outputDetails.reasoningTokens === "number" ? outputDetails.reasoningTokens : undefined, + reasoning: + typeof outputDetails.reasoningTokens === "number" + ? outputDetails.reasoningTokens + : undefined, }, - } + }; } function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null { switch (event.type) { case "start": - return { type: "stream-start", warnings: [] } + return { type: "stream-start", warnings: [] }; case "text-start": - return { type: "text-start", id: event.id as string } + return { type: "text-start", id: event.id as string }; case "text-delta": - return { type: "text-delta", id: event.id as string, delta: (event.text ?? event.delta ?? "") as string } + return { + type: "text-delta", + id: event.id as string, + delta: (event.text ?? event.delta ?? "") as string, + }; case "text-end": - return { type: "text-end", id: event.id as string } + return { type: "text-end", id: event.id as string }; case "reasoning-start": - return { type: "reasoning-start", id: event.id as string } + return { type: "reasoning-start", id: event.id as string }; case "reasoning-delta": - return { type: "reasoning-delta", id: event.id as string, delta: (event.text ?? event.delta ?? "") as string } + return { + type: "reasoning-delta", + id: event.id as string, + delta: (event.text ?? event.delta ?? "") as string, + }; case "reasoning-end": - return { type: "reasoning-end", id: event.id as string } + return { type: "reasoning-end", id: event.id as string }; case "tool-input-start": return { @@ -65,124 +101,137 @@ function toStreamPart(event: RawEvent): LanguageModelV3StreamPart | null { id: event.id as string, toolName: event.toolName as string, dynamic: event.dynamic as boolean | undefined, - } + }; case "tool-input-delta": - return { type: "tool-input-delta", id: event.id as string, delta: (event.delta ?? "") as string } + return { + type: "tool-input-delta", + id: event.id as string, + delta: (event.delta ?? "") as string, + }; case "tool-input-end": - return { type: "tool-input-end", id: event.id as string } + return { type: "tool-input-end", id: event.id as string }; case "tool-call": { - const input = event.input ?? event.args ?? event.arguments + const input = event.input ?? event.args ?? event.arguments; return { type: "tool-call", toolCallId: (event.toolCallId ?? event.id ?? "") as string, toolName: event.toolName as string, input: typeof input === "string" ? input : JSON.stringify(input ?? {}), - } + }; } case "finish-step": { - const usage = event.usage ?? event.totalUsage ?? {} - const rawReason = (event.finishReason ?? event.rawFinishReason ?? "stop") as string + const usage = event.usage ?? event.totalUsage ?? {}; + const rawReason = (event.finishReason ?? event.rawFinishReason ?? "stop") as string; return { type: "finish", finishReason: { unified: mapFinishReason(rawReason), raw: rawReason }, - usage: mapUsage(typeof usage === "object" && usage !== null ? (usage as Record) : {}), - } + usage: mapUsage( + typeof usage === "object" && usage !== null ? (usage as Record) : {}, + ), + }; } case "finish": - return null + return null; case "response-metadata": return { type: "response-metadata", id: event.id as string | undefined, modelId: event.modelId as string | undefined, - } + }; case "error": - return { type: "error", error: event.error ?? event.message ?? "Unknown error" } + return { type: "error", error: event.error ?? event.message ?? "Unknown error" }; default: - return null + return null; } } // Assumes line-delimited JSON over SSE: one JSON object per `data:` line. // Multi-line `data:` fields are not supported — the buffer splits on `\n`. -export function parseStreamEvents(body: ReadableStream): ReadableStream { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = "" +export function parseStreamEvents( + body: ReadableStream, +): ReadableStream { + const reader = body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; return new ReadableStream({ async pull(controller) { try { while (true) { - const lines = buffer.split("\n") + const lines = buffer.split("\n"); // Strip trailing \r from Windows-style \r\n line endings for (let i = 0; i < lines.length; i++) { - const line = lines[i] - if (line !== undefined && line.endsWith("\r")) lines[i] = line.slice(0, -1) + const line = lines[i]; + if (line !== undefined && line.endsWith("\r")) lines[i] = line.slice(0, -1); } - buffer = lines.pop() ?? "" + buffer = lines.pop() ?? ""; for (const line of lines) { - const trimmed = line.trim() - if (!trimmed || trimmed.startsWith(":") || trimmed === "[DONE]") continue + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith(":") || trimmed === "[DONE]") continue; - let jsonStr = trimmed - if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6) - if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5) - if (!jsonStr || jsonStr === "[DONE]") continue + let jsonStr = trimmed; + if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6); + if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5); + if (!jsonStr || jsonStr === "[DONE]") continue; - let parsed: RawEvent + let parsed: RawEvent; try { - parsed = JSON.parse(jsonStr) + parsed = JSON.parse(jsonStr); } catch { // intentionally silent: skip malformed SSE lines - continue + continue; } - if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string") continue + if (typeof parsed !== "object" || parsed === null || typeof parsed.type !== "string") + continue; - const part = toStreamPart(parsed) - if (part) controller.enqueue(part) + const part = toStreamPart(parsed); + if (part) controller.enqueue(part); } - const { done, value } = await reader.read() + const { done, value } = await reader.read(); if (done) { if (buffer.trim()) { - const trimmed = buffer.trim() + const trimmed = buffer.trim(); if (trimmed && trimmed !== "[DONE]" && !trimmed.startsWith(":")) { - let jsonStr = trimmed - if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6) - if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5) + let jsonStr = trimmed; + if (jsonStr.startsWith("data: ")) jsonStr = jsonStr.slice(6); + if (jsonStr.startsWith("data:")) jsonStr = jsonStr.slice(5); try { - const parsed = JSON.parse(jsonStr) - if (typeof parsed === "object" && parsed !== null && typeof parsed.type === "string") { - const part = toStreamPart(parsed) - if (part) controller.enqueue(part) + const parsed = JSON.parse(jsonStr); + if ( + typeof parsed === "object" && + parsed !== null && + typeof parsed.type === "string" + ) { + const part = toStreamPart(parsed); + if (part) controller.enqueue(part); } } catch { // intentionally silent: skip malformed final buffer } } } - controller.close() - return + controller.close(); + return; } - buffer += decoder.decode(value, { stream: true }) + buffer += decoder.decode(value, { stream: true }); } } catch (err) { - controller.enqueue({ type: "error", error: err }) - controller.close() + controller.enqueue({ type: "error", error: err }); + controller.close(); } }, cancel() { - reader.cancel() + reader.cancel(); }, - }) + }); } diff --git a/tests/fixtures/command-code/models-page.md b/tests/fixtures/command-code/models-page.md index 23a9bbe..954e698 100644 --- a/tests/fixtures/command-code/models-page.md +++ b/tests/fixtures/command-code/models-page.md @@ -1,5 +1,5 @@ -| Model | Context | Input | Output | Cache read | Cache write | -| --- | --- | --- | --- | --- | --- | -| Claude Sonnet 4.6 | 200K | $3.00 | $15.00 | $0.30 | $3.75 | -| Gemini 3.7 Flash-50% | 1M | $1.50$0.75 | $7.50$3.75 | $0.15$0.075 | — | -| Laguna S 2.1FREE | 256K | Free | Free | Free | — | +| Model | Context | Input | Output | Cache read | Cache write | +| -------------------- | ------- | ---------- | ---------- | ----------- | ----------- | +| Claude Sonnet 4.6 | 200K | $3.00 | $15.00 | $0.30 | $3.75 | +| Gemini 3.7 Flash-50% | 1M | $1.50$0.75 | $7.50$3.75 | $0.15$0.075 | — | +| Laguna S 2.1FREE | 256K | Free | Free | Free | — | diff --git a/tests/fixtures/models-dev/api.subset.json b/tests/fixtures/models-dev/api.subset.json new file mode 100644 index 0000000..80fa860 --- /dev/null +++ b/tests/fixtures/models-dev/api.subset.json @@ -0,0 +1,38 @@ +{ + "google": { + "models": { + "google/gemini-3.5-flash": { + "id": "google/gemini-3.5-flash", + "name": "Gemini 3.5 Flash", + "cost": { "input": 1.5, "output": 9, "cache_read": 0.15 } + } + } + }, + "zai": { + "models": { + "zai-org/GLM-5.1": { + "id": "zai-org/GLM-5.1", + "name": "GLM-5.1", + "cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 } + } + } + }, + "vercel": { + "models": { + "minimax/minimax-m2.7-free": { + "id": "minimax/minimax-m2.7-free", + "name": "Minimax M2.7 (Free)", + "cost": { "input": 0, "output": 0 } + } + } + }, + "qwen": { + "models": { + "Qwen3.6-Plus": { + "id": "Qwen3.6-Plus", + "name": "Qwen 3.6 Plus", + "cost": { "input": 0.5, "output": 3, "cache_read": 0.1 } + } + } + } +} diff --git a/tests/helpers/mocks.ts b/tests/helpers/mocks.ts index 5fd4e46..0c81d7a 100644 --- a/tests/helpers/mocks.ts +++ b/tests/helpers/mocks.ts @@ -2,15 +2,15 @@ import type { LanguageModelV3StreamPart, LanguageModelV3CallOptions, LanguageModelV3Message, -} from "@ai-sdk/provider" +} from "@ai-sdk/provider"; export type MockFetchCall = { - url: string - options: RequestInit -} + url: string; + options: RequestInit; +}; export function mockFetch(response: Partial): { restore: () => void } { - const original = globalThis.fetch + const original = globalThis.fetch; globalThis.fetch = ((_input: RequestInfo | URL, _options?: RequestInit) => { return Promise.resolve({ ok: true, @@ -21,19 +21,19 @@ export function mockFetch(response: Partial): { restore: () => void } text: () => Promise.resolve(""), json: () => Promise.resolve({}), ...response, - } as Response) - }) as typeof globalThis.fetch + } as Response); + }) as typeof globalThis.fetch; return { restore: () => { - globalThis.fetch = original + globalThis.fetch = original; }, - } + }; } export function mockFetchTrack(): { - calls: MockFetchCall[] - restore: () => void - respondWith: (resp: Partial) => void + calls: MockFetchCall[]; + restore: () => void; + respondWith: (resp: Partial) => void; } { let nextResponse: Partial = { ok: true, @@ -42,14 +42,14 @@ export function mockFetchTrack(): { headers: new Headers(), body: null, text: () => Promise.resolve(""), - } - const calls: MockFetchCall[] = [] - const original = globalThis.fetch + }; + const calls: MockFetchCall[] = []; + const original = globalThis.fetch; globalThis.fetch = ((input: RequestInfo | URL, options?: RequestInit) => { calls.push({ url: typeof input === "string" ? input : input instanceof URL ? input.href : input.url, options: options ?? {}, - }) + }); return Promise.resolve({ ok: true, status: 200, @@ -59,38 +59,38 @@ export function mockFetchTrack(): { text: () => Promise.resolve(""), json: () => Promise.resolve({}), ...nextResponse, - } as Response) - }) as typeof globalThis.fetch + } as Response); + }) as typeof globalThis.fetch; return { calls, respondWith: (resp: Partial) => { - nextResponse = resp + nextResponse = resp; }, restore: () => { - globalThis.fetch = original + globalThis.fetch = original; }, - } + }; } export function mockFetchStream( chunks: string[], responseOverrides: Partial = {}, ): { restore: () => void } { - const encoder = new TextEncoder() - const original = globalThis.fetch + const encoder = new TextEncoder(); + const original = globalThis.fetch; - globalThis.fetch = ((async () => { - let chunkIndex = 0 + globalThis.fetch = (async () => { + let chunkIndex = 0; const stream = new ReadableStream({ async pull(controller) { if (chunkIndex >= chunks.length) { - controller.close() - return + controller.close(); + return; } - controller.enqueue(encoder.encode(chunks[chunkIndex])) - chunkIndex++ + controller.enqueue(encoder.encode(chunks[chunkIndex])); + chunkIndex++; }, - }) + }); return { ok: true, status: 200, @@ -99,50 +99,68 @@ export function mockFetchStream( body: stream, text: () => Promise.resolve(chunks.join("")), ...responseOverrides, - } as Response - }) as typeof globalThis.fetch) + } as Response; + }) as typeof globalThis.fetch; return { restore: () => { - globalThis.fetch = original + globalThis.fetch = original; }, - } + }; } -export function mockFetchError(status: number, statusText: string, body?: string): { restore: () => void } { - const original = globalThis.fetch - globalThis.fetch = ((async () => ({ - ok: false, - status, - statusText, - headers: new Headers(), - body: null, - text: () => Promise.resolve(body ?? ""), - json: () => Promise.resolve(body ? JSON.parse(body) : {}), - } as Response)) as typeof globalThis.fetch) +export function mockFetchError( + status: number, + statusText: string, + body?: string, +): { restore: () => void } { + const original = globalThis.fetch; + globalThis.fetch = (async () => + ({ + ok: false, + status, + statusText, + headers: new Headers(), + body: null, + text: () => Promise.resolve(body ?? ""), + json: () => Promise.resolve(body ? JSON.parse(body) : {}), + }) as Response) as typeof globalThis.fetch; return { restore: () => { - globalThis.fetch = original + globalThis.fetch = original; }, - } + }; } export function sseEvent(data: Record): string { - return `data: ${JSON.stringify(data)}\n\n` + return `data: ${JSON.stringify(data)}\n\n`; } export function sseEvents(events: Record[]): string { - return events.map(sseEvent).join("") + return events.map(sseEvent).join(""); } -export function makeStreamPart(type: LanguageModelV3StreamPart["type"], overrides: Record = {}): LanguageModelV3StreamPart { +export function makeStreamPart( + type: LanguageModelV3StreamPart["type"], + overrides: Record = {}, +): LanguageModelV3StreamPart { switch (type) { case "stream-start": - return { type: "stream-start", warnings: [] } as LanguageModelV3StreamPart + return { type: "stream-start", warnings: [] } as LanguageModelV3StreamPart; case "text-delta": - return { type: "text-delta", id: "t1", delta: "hello", ...overrides } as LanguageModelV3StreamPart + return { + type: "text-delta", + id: "t1", + delta: "hello", + ...overrides, + } as LanguageModelV3StreamPart; case "reasoning-delta": - return { type: "reasoning-delta", id: "r1", delta: "thinking...", ...overrides } as LanguageModelV3StreamPart + return { + type: "reasoning-delta", + id: "r1", + delta: "thinking...", + ...overrides, + } as LanguageModelV3StreamPart; case "tool-call": return { type: "tool-call", @@ -150,7 +168,7 @@ export function makeStreamPart(type: LanguageModelV3StreamPart["type"], override toolName: "bash", input: "{}", ...overrides, - } as LanguageModelV3StreamPart + } as LanguageModelV3StreamPart; case "finish": return { type: "finish", @@ -160,18 +178,18 @@ export function makeStreamPart(type: LanguageModelV3StreamPart["type"], override outputTokens: { total: 20 }, }, ...overrides, - } as LanguageModelV3StreamPart + } as LanguageModelV3StreamPart; case "error": - return { type: "error", error: "test error", ...overrides } as LanguageModelV3StreamPart + return { type: "error", error: "test error", ...overrides } as LanguageModelV3StreamPart; default: - return { type, ...overrides } as LanguageModelV3StreamPart + return { type, ...overrides } as LanguageModelV3StreamPart; } } export const sampleUserMessage: LanguageModelV3Message = { role: "user", content: [{ type: "text", text: "hello" }], -} +}; export const sampleAssistantMessage: LanguageModelV3Message = { role: "assistant", @@ -180,19 +198,26 @@ export const sampleAssistantMessage: LanguageModelV3Message = { { type: "reasoning", text: "let me consider..." }, { type: "tool-call", toolCallId: "tc1", toolName: "bash", input: "ls" }, ], -} +}; export const sampleToolMessage: LanguageModelV3Message = { role: "tool", content: [ - { type: "tool-result", toolCallId: "tc1", toolName: "bash", output: { type: "text", value: "file.ts" } }, + { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + output: { type: "text", value: "file.ts" }, + }, ], -} +}; -export function makeCallOptions(overrides: Partial = {}): LanguageModelV3CallOptions { +export function makeCallOptions( + overrides: Partial = {}, +): LanguageModelV3CallOptions { return { prompt: [sampleUserMessage], maxOutputTokens: 1000, ...overrides, - } + }; } diff --git a/tests/integration/model.integration.test.ts b/tests/integration/model.integration.test.ts index ae1b168..64ca681 100644 --- a/tests/integration/model.integration.test.ts +++ b/tests/integration/model.integration.test.ts @@ -1,58 +1,59 @@ -import { describe, expect, test } from "bun:test" -import { CommandCodeLanguageModel } from "../../src/model.js" -import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" +import { describe, expect, test } from "bun:test"; +import { CommandCodeLanguageModel } from "../../src/model.js"; -const API_KEY = process.env.COMMANDCODE_API_KEY -const hasApiKey = !!API_KEY +const API_KEY = process.env.COMMANDCODE_API_KEY; +const hasApiKey = !!API_KEY; -const skipIfNoKey = hasApiKey ? describe : describe.skip +const skipIfNoKey = hasApiKey ? describe : describe.skip; skipIfNoKey("integration: CommandCodeLanguageModel", () => { const model = new CommandCodeLanguageModel("deepseek/deepseek-v4-flash", { apiKey: API_KEY!, - }) + }); test("doStream returns parseable stream parts for a simple prompt", async () => { const result = await model.doStream({ prompt: [{ role: "user", content: "Say exactly the word: hello" }], maxOutputTokens: 100, - }) - const reader = result.stream.getReader() - const parts: any[] = [] + }); + const reader = result.stream.getReader(); + const parts: any[] = []; try { while (true) { - const { done, value } = await reader.read() - if (done) break - parts.push(value) + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); } } finally { - reader.releaseLock() + reader.releaseLock(); } - expect(parts.length).toBeGreaterThan(0) - const finalPart = parts.find((p) => p.type === "finish") - expect(finalPart).toBeDefined() - expect(finalPart.finishReason.unified).toMatch(/stop|length/) - }) + expect(parts.length).toBeGreaterThan(0); + const finalPart = parts.find((p) => p.type === "finish"); + expect(finalPart).toBeDefined(); + expect(finalPart.finishReason.unified).toMatch(/stop|length/); + }); test("doGenerate returns a complete response", async () => { const result = await model.doGenerate({ prompt: [{ role: "user", content: "Say exactly the word: hello" }], maxOutputTokens: 100, - }) + }); - expect(result.content.length).toBeGreaterThan(0) - const textPart = result.content.find((c) => c.type === "text") as { text: string } | undefined - expect(textPart).toBeDefined() - expect(textPart!.text.toLowerCase()).toContain("hello") - expect(result.finishReason.unified).toMatch(/stop|length/) - expect(result.usage.inputTokens.total).toBeGreaterThan(0) - expect(result.usage.outputTokens.total).toBeGreaterThan(0) - }) + expect(result.content.length).toBeGreaterThan(0); + const textPart = result.content.find((c) => c.type === "text") as { text: string } | undefined; + expect(textPart).toBeDefined(); + expect(textPart!.text.toLowerCase()).toContain("hello"); + expect(result.finishReason.unified).toMatch(/stop|length/); + expect(result.usage.inputTokens.total).toBeGreaterThan(0); + expect(result.usage.outputTokens.total).toBeGreaterThan(0); + }); test("doGenerate with tool calling returns tool-call parts", async () => { const result = await model.doGenerate({ - prompt: [{ role: "user", content: "What files are in the current directory? Use bash to check." }], + prompt: [ + { role: "user", content: "What files are in the current directory? Use bash to check." }, + ], maxOutputTokens: 500, tools: [ { @@ -68,36 +69,36 @@ skipIfNoKey("integration: CommandCodeLanguageModel", () => { }, }, ], - }) + }); - const toolCalls = result.content.filter((c) => c.type === "tool-call") - expect(toolCalls.length).toBeGreaterThan(0) + const toolCalls = result.content.filter((c) => c.type === "tool-call"); + expect(toolCalls.length).toBeGreaterThan(0); expect(toolCalls[0]).toMatchObject({ type: "tool-call", toolName: "bash", - }) - }) + }); + }); test("doStream with invalid model ID returns meaningful error", async () => { const badModel = new CommandCodeLanguageModel("nonexistent-model-xyz", { apiKey: API_KEY!, - }) + }); try { await badModel.doStream({ prompt: [{ role: "user", content: "hi" }], maxOutputTokens: 10, - }) - expect.unreachable("Should have thrown") + }); + expect.unreachable("Should have thrown"); } catch (err) { - const message = err instanceof Error ? err.message : String(err) - expect(message).not.toBe("Command Code API error: 404 Not Found") + const message = err instanceof Error ? err.message : String(err); + expect(message).not.toBe("Command Code API error: 404 Not Found"); } - }) -}) + }); +}); test("integration tests are skipped when COMMANDCODE_API_KEY is not set", () => { if (!hasApiKey) { - console.log("Skipping integration tests: COMMANDCODE_API_KEY not set") + console.log("Skipping integration tests: COMMANDCODE_API_KEY not set"); } -}) +}); diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index 0ae763b..14128b0 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,40 +1,40 @@ -import { expect, test, beforeEach } from "bun:test" -import { resolveApiKey } from "../../src/auth.js" +import { expect, test, beforeEach } from "bun:test"; +import { resolveApiKey } from "../../src/auth.js"; beforeEach(() => { - delete process.env.COMMANDCODE_API_KEY -}) + delete process.env.COMMANDCODE_API_KEY; +}); test("resolves from explicit apiKey option", () => { - const result = resolveApiKey({ apiKey: "sk-explicit" }) - expect(result).toBe("sk-explicit") -}) + const result = resolveApiKey({ apiKey: "sk-explicit" }); + expect(result).toBe("sk-explicit"); +}); test("resolves from COMMANDCODE_API_KEY env var", () => { - process.env.COMMANDCODE_API_KEY = "sk-from-env" - const result = resolveApiKey({}) - expect(result).toBe("sk-from-env") -}) + process.env.COMMANDCODE_API_KEY = "sk-from-env"; + const result = resolveApiKey({}); + expect(result).toBe("sk-from-env"); +}); test("explicit apiKey takes priority over env var", () => { - process.env.COMMANDCODE_API_KEY = "sk-from-env" - const result = resolveApiKey({ apiKey: "sk-explicit" }) - expect(result).toBe("sk-explicit") -}) + process.env.COMMANDCODE_API_KEY = "sk-from-env"; + const result = resolveApiKey({ apiKey: "sk-explicit" }); + expect(result).toBe("sk-explicit"); +}); test("returns undefined when no key found", () => { - const result = resolveApiKey({ authPaths: [] }) - expect(result).toBeUndefined() -}) + const result = resolveApiKey({ authPaths: [] }); + expect(result).toBeUndefined(); +}); test("uses env override from options.env over process.env", () => { - process.env.COMMANDCODE_API_KEY = "sk-from-process-env" - const result = resolveApiKey({ env: { COMMANDCODE_API_KEY: "sk-from-options-env" } }) - expect(result).toBe("sk-from-options-env") -}) + process.env.COMMANDCODE_API_KEY = "sk-from-process-env"; + const result = resolveApiKey({ env: { COMMANDCODE_API_KEY: "sk-from-options-env" } }); + expect(result).toBe("sk-from-options-env"); +}); test("falls through to process.env when options.env is missing key", () => { - process.env.COMMANDCODE_API_KEY = "sk-from-process-env" - const result = resolveApiKey({ env: {} }) - expect(result).toBe("sk-from-process-env") -}) + process.env.COMMANDCODE_API_KEY = "sk-from-process-env"; + const result = resolveApiKey({ env: {} }); + expect(result).toBe("sk-from-process-env"); +}); diff --git a/tests/unit/catalog-break.test.ts b/tests/unit/catalog-break.test.ts index 36334ed..2f9f1ef 100644 --- a/tests/unit/catalog-break.test.ts +++ b/tests/unit/catalog-break.test.ts @@ -1,17 +1,17 @@ -import { expect, test, describe } from "bun:test" +import { expect, test, describe } from "bun:test"; import { catalogBreakResolvedComment, catalogBreakTitle, renderCatalogBreakBody, -} from "../../src/catalog-break.ts" +} from "../../src/catalog-break.ts"; describe("catalogBreakTitle", () => { test("includes the failed command-code version", () => { expect(catalogBreakTitle("1.39.0")).toBe( "[catalog-break] command-code@1.39.0 — model extraction failed", - ) - }) -}) + ); + }); +}); describe("renderCatalogBreakBody", () => { test("includes version, error, workflow URL, and bundled version still in use", () => { @@ -20,22 +20,24 @@ describe("renderCatalogBreakBody", () => { error: "Could not evaluate model catalog", workflowUrl: "https://github.com/BrainerVirus/opencode-commandcode-provider/actions/runs/1", bundledCommandCodeVersion: "1.38.1", - }) - expect(body).toContain("command-code@1.39.0") - expect(body).toContain("Could not evaluate model catalog") - expect(body).toContain("https://github.com/BrainerVirus/opencode-commandcode-provider/actions/runs/1") - expect(body).toContain("1.38.1") - expect(body).toContain("src/catalog.ts") - }) -}) + }); + expect(body).toContain("command-code@1.39.0"); + expect(body).toContain("Could not evaluate model catalog"); + expect(body).toContain( + "https://github.com/BrainerVirus/opencode-commandcode-provider/actions/runs/1", + ); + expect(body).toContain("1.38.1"); + expect(body).toContain("src/catalog.ts"); + }); +}); describe("catalogBreakResolvedComment", () => { test("points at the fix commit and release tag", () => { const comment = catalogBreakResolvedComment({ commit: "abc1234", tag: "v0.5.1", - }) - expect(comment).toContain("abc1234") - expect(comment).toContain("v0.5.1") - }) -}) + }); + expect(comment).toContain("abc1234"); + expect(comment).toContain("v0.5.1"); + }); +}); diff --git a/tests/unit/catalog-release-notes.test.ts b/tests/unit/catalog-release-notes.test.ts index d483aee..d4c1b7f 100644 --- a/tests/unit/catalog-release-notes.test.ts +++ b/tests/unit/catalog-release-notes.test.ts @@ -1,9 +1,8 @@ -import { expect, test, describe } from "bun:test" -import { renderCatalogReleaseNotes, type CatalogReleaseInput } from "../../src/catalog-release-notes.ts" - -const fallbackCosts = { - "google/gemini-3.5-flash": { input: 1.5, output: 9 }, -} +import { expect, test, describe } from "bun:test"; +import { + renderCatalogReleaseNotes, + type CatalogReleaseInput, +} from "../../src/catalog-release-notes.ts"; function input(partial: Partial = {}): CatalogReleaseInput { return { @@ -12,60 +11,69 @@ function input(partial: Partial = {}): CatalogReleaseInput modelCount: 3, reasoningModelCount: 2, status: "degraded", - costSources: { cli: 0, officialDocs: 1, thirdParty: 0, fallback: 1, unmatched: 1 }, + costSources: { cli: 0, officialDocs: 1, thirdParty: 1, free: 1, fallback: 0, unmatched: 1 }, models: [ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", cost: { input: 3, output: 15 } }, { id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash", cost: { input: 1.5, output: 9 } }, - { id: "tencent/Hy3", name: "Tencent Hy3 (Free)", cost: { input: 0.5, output: 2 } }, + { id: "tencent/Hy3", name: "Tencent Hy3 (Free)", cost: { input: 0, output: 0 } }, + { id: "unknown/x", name: "Unknown X", cost: { input: 0.5, output: 2 } }, ], - fallbackCosts, + review: { + thirdParty: ["google/gemini-3.5-flash"], + free: ["tencent/Hy3"], + unmatched: ["unknown/x"], + }, ...partial, - } + }; } describe("renderCatalogReleaseNotes", () => { test("leads with a usable headline and hides the raw status word", () => { - const md = renderCatalogReleaseNotes(input()) - expect(md).toContain("Safe to use") - expect(md).not.toMatch(/^#.*degraded/m) - expect(md.startsWith("degraded")).toBe(false) - }) + const md = renderCatalogReleaseNotes(input()); + expect(md).toContain("Safe to use"); + expect(md).not.toMatch(/^#.*degraded/m); + expect(md.startsWith("degraded")).toBe(false); + }); test("puts counts in a compact table", () => { - const md = renderCatalogReleaseNotes(input()) - expect(md).toContain("| Command Code | 1.38.1 |") - expect(md).toContain("| Models | 3 (2 with reasoning) |") - expect(md).toContain("1 official docs") - expect(md).toContain("1 known fallback") - expect(md).toContain("1 no listed price") - }) + const md = renderCatalogReleaseNotes(input()); + expect(md).toContain("| Command Code | 1.38.1 |"); + expect(md).toContain("| Models | 3 (2 with reasoning) |"); + expect(md).toContain("1 official docs"); + expect(md).toContain("1 models.dev"); + expect(md).toContain("1 free"); + expect(md).toContain("1 no listed price"); + }); - test("collapses fallback and unmatched model lists", () => { - const md = renderCatalogReleaseNotes(input()) - expect(md).toContain("
") - expect(md).toContain("1 model with a known fallback price") - expect(md).toContain("1 model with no official price ($0.50 / $2)") - expect(md).toContain("Gemini 3.5 Flash") - expect(md).toContain("Tencent Hy3 (Free)") - expect(md).toContain("tencent/Hy3") - }) + test("collapses models.dev, free, and unmatched lists", () => { + const md = renderCatalogReleaseNotes(input()); + expect(md).toContain("
"); + expect(md).toContain("1 model with a models.dev reference price"); + expect(md).toContain("1 free model ($0)"); + expect(md).toContain("1 model with no listed price ($0.50 / $2)"); + expect(md).toContain("Gemini 3.5 Flash"); + expect(md).toContain("Tencent Hy3 (Free)"); + expect(md).toContain("tencent/Hy3"); + expect(md).toContain("unknown/x"); + }); test("healthy catalog skips the extra lists", () => { const md = renderCatalogReleaseNotes( input({ status: "healthy", - costSources: { cli: 0, officialDocs: 3, thirdParty: 0, fallback: 0, unmatched: 0 }, + costSources: { cli: 0, officialDocs: 3, thirdParty: 0, free: 0, fallback: 0, unmatched: 0 }, models: [ { id: "a", name: "A", cost: { input: 1, output: 2 } }, { id: "b", name: "B", cost: { input: 1, output: 2 } }, { id: "c", name: "C", cost: { input: 1, output: 2 } }, ], + review: { thirdParty: [], free: [], unmatched: [] }, }), - ) - expect(md).toContain("All listed prices come from Command Code or official docs") - expect(md).not.toContain("known fallback") - expect(md).not.toContain("no official price") - }) + ); + expect(md).toContain("Prices come from Command Code, official docs, models.dev, or free SKUs"); + expect(md).not.toContain("models.dev reference"); + expect(md).not.toContain("no listed price"); + }); test("broken catalog is explicit", () => { const md = renderCatalogReleaseNotes( @@ -74,16 +82,17 @@ describe("renderCatalogReleaseNotes", () => { modelCount: 0, reasoningModelCount: 0, models: [], - costSources: { cli: 0, officialDocs: 0, thirdParty: 0, fallback: 0, unmatched: 0 }, + costSources: { cli: 0, officialDocs: 0, thirdParty: 0, free: 0, fallback: 0, unmatched: 0 }, + review: { thirdParty: [], free: [], unmatched: [] }, }), - ) - expect(md).toContain("Do not use this catalog") - }) + ); + expect(md).toContain("Do not use this catalog"); + }); test("includes a collapsed machine-readable block for agents", () => { - const md = renderCatalogReleaseNotes(input()) - expect(md).toContain("Machine-readable catalog") - expect(md).toContain('"status": "degraded"') - expect(md).toContain('"unmatched": 1') - }) -}) + const md = renderCatalogReleaseNotes(input()); + expect(md).toContain("Machine-readable catalog"); + expect(md).toContain('"status": "degraded"'); + expect(md).toContain('"unmatched": 1'); + }); +}); diff --git a/tests/unit/catalog.test.ts b/tests/unit/catalog.test.ts index 5dfd285..7c40eb6 100644 --- a/tests/unit/catalog.test.ts +++ b/tests/unit/catalog.test.ts @@ -1,4 +1,4 @@ -import { expect, test, describe } from "bun:test" +import { expect, test, describe } from "bun:test"; import { buildModelEntry, disambiguateModelNames, @@ -7,16 +7,16 @@ import { resolveCommandCodePackage, type CostEntry, type SnEntry, -} from "../../src/catalog.ts" +} from "../../src/catalog.ts"; function costMapOf(entries: CostEntry[]): Map { - const map = new Map() + const map = new Map(); for (const entry of entries) { - const colonIdx = entry.id.indexOf(":") - const bareId = colonIdx >= 0 ? entry.id.slice(colonIdx + 1) : entry.id - map.set(bareId, entry) + const colonIdx = entry.id.indexOf(":"); + const bareId = colonIdx >= 0 ? entry.id.slice(colonIdx + 1) : entry.id; + map.set(bareId, entry); } - return map + return map; } const sampleCost: CostEntry = { @@ -28,7 +28,7 @@ const sampleCost: CostEntry = { cacheWrite5mCost: 3.75, cacheWrite1hCost: 6, cacheHitCost: 0.3, -} +}; describe("buildModelEntry", () => { test("preserves reasoningEfforts and sets reasoning true", () => { @@ -40,13 +40,13 @@ describe("buildModelEntry", () => { name: "Claude Sonnet 4.6", description: "test", reasoningEfforts: ["low", "medium", "high", "xhigh", "max"], - } - const entry = buildModelEntry(sn, costMapOf([sampleCost])) - expect(entry).not.toBeNull() - expect(entry!.reasoning).toBe(true) - expect(entry!.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]) - expect(entry!.tier).toBe("premium") - }) + }; + const entry = buildModelEntry(sn, costMapOf([sampleCost])); + expect(entry).not.toBeNull(); + expect(entry!.reasoning).toBe(true); + expect(entry!.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh", "max"]); + expect(entry!.tier).toBe("premium"); + }); test("omits reasoningEfforts when absent and reasoning false", () => { const sn: SnEntry = { @@ -56,12 +56,12 @@ describe("buildModelEntry", () => { label: "Sonnet", name: "Claude Sonnet 4.6", description: "test", - } - const entry = buildModelEntry(sn, costMapOf([sampleCost])) - expect(entry).not.toBeNull() - expect(entry!.reasoning).toBe(false) - expect(entry!.reasoningEfforts).toBeUndefined() - }) + }; + const entry = buildModelEntry(sn, costMapOf([sampleCost])); + expect(entry).not.toBeNull(); + expect(entry!.reasoning).toBe(false); + expect(entry!.reasoningEfforts).toBeUndefined(); + }); test("sets reasoning true from explicit flag without efforts", () => { const sn: SnEntry = { @@ -72,11 +72,11 @@ describe("buildModelEntry", () => { name: "Claude Sonnet 4.6", description: "test", reasoning: true, - } - const entry = buildModelEntry(sn, costMapOf([sampleCost])) - expect(entry!.reasoning).toBe(true) - expect(entry!.reasoningEfforts).toBeUndefined() - }) + }; + const entry = buildModelEntry(sn, costMapOf([sampleCost])); + expect(entry!.reasoning).toBe(true); + expect(entry!.reasoningEfforts).toBeUndefined(); + }); test("keeps models without cost data using default cost", () => { const sn: SnEntry = { @@ -87,14 +87,27 @@ describe("buildModelEntry", () => { name: "X", description: "x", reasoningEfforts: ["low", "high"], - } - const entry = buildModelEntry(sn, new Map()) - expect(entry).not.toBeNull() - expect(entry!.reasoning).toBe(true) - expect(entry!.reasoningEfforts).toEqual(["low", "high"]) - expect(entry!.cost).toEqual({ input: 0.5, output: 2 }) - }) -}) + }; + const entry = buildModelEntry(sn, new Map()); + expect(entry).not.toBeNull(); + expect(entry!.reasoning).toBe(true); + expect(entry!.reasoningEfforts).toEqual(["low", "high"]); + expect(entry!.cost).toEqual({ input: 0.5, output: 2 }); + }); + + test("does not invent a billed rate for models missing from the CLI cost map", () => { + const sn: SnEntry = { + id: "google/gemini-3.5-flash", + provider: "openrouter", + spec: "chatComplete", + label: "Gemini", + name: "Gemini 3.5 Flash", + description: "x", + }; + const entry = buildModelEntry(sn, new Map()); + expect(entry!.cost).toEqual({ input: 0.5, output: 2 }); + }); +}); describe("disambiguateModelNames", () => { test("distinguishes models with the same upstream display name", () => { @@ -117,12 +130,12 @@ describe("disambiguateModelNames", () => { cost: { input: 0.5, output: 2 }, limit: { context: 1000000, output: 65536 }, }, - ] + ]; - disambiguateModelNames(entries) - expect(entries.map((entry) => entry.name).sort()).toEqual(["MiniMax M3", "MiniMax M3 Free"]) - }) -}) + disambiguateModelNames(entries); + expect(entries.map((entry) => entry.name).sort()).toEqual(["MiniMax M3", "MiniMax M3 Free"]); + }); +}); describe("generateOpencodeModels", () => { test("emits variants from reasoningEfforts", () => { @@ -137,15 +150,15 @@ describe("generateOpencodeModels", () => { cost: { input: 3, output: 15 }, limit: { context: 200000, output: 16000 }, }, - ]) - const entry = models["claude-sonnet-4-6"] as Record - expect(entry.reasoningEfforts).toEqual(["low", "high"]) + ]); + const entry = models["claude-sonnet-4-6"] as Record; + expect(entry.reasoningEfforts).toEqual(["low", "high"]); expect(entry.variants).toEqual({ low: { reasoningEffort: "low" }, high: { reasoningEffort: "high" }, - }) - }) -}) + }); + }); +}); describe("loadCatalogFromBundle", () => { test("extracts models and reasoningEfforts from minified-like fixture", () => { @@ -153,79 +166,79 @@ describe("loadCatalogFromBundle", () => { const source = [ '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});', 'var Aa="chatComplete",Ba="responses",qt=Vt[0];', - 'var Sn=(Wt=>({', + "var Sn=(Wt=>({", 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5},', 'GPT_X:{id:"gpt-5.5",provider:Wt.OPENAI,spec:Ba,label:"GPT",name:"GPT-5.5",description:"d",reasoningEfforts:["low","high"],contextWindow:256000}', - '}))(Wt);', + "}))(Wt);", 'var costs={anthropic:[{id:"anthropic:claude-sonnet-4-6",provider:"anthropic",category:"p",promptCost:3,completionCost:15,cacheWrite5mCost:3.75,cacheWrite1hCost:6,cacheHitCost:.3}],openai:[{id:"openai:gpt-5.5",provider:"openai",category:"p",promptCost:1,completionCost:2,cacheWrite5mCost:0,cacheWrite1hCost:0,cacheHitCost:0}]};', - ].join("") + ].join(""); - const entries = loadCatalogFromBundle(source) - expect(entries.length).toBeGreaterThanOrEqual(2) + const entries = loadCatalogFromBundle(source); + expect(entries.length).toBeGreaterThanOrEqual(2); - const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6") - expect(sonnet).toBeDefined() - expect(sonnet!.reasoning).toBe(true) - expect(sonnet!.reasoningEfforts).toEqual(["low", "medium", "high"]) - expect(sonnet!.tier).toBe("premium") - expect(sonnet!.limit.context).toBe(200000) + const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6"); + expect(sonnet).toBeDefined(); + expect(sonnet!.reasoning).toBe(true); + expect(sonnet!.reasoningEfforts).toEqual(["low", "medium", "high"]); + expect(sonnet!.tier).toBe("premium"); + expect(sonnet!.limit.context).toBe(200000); - const gpt = entries.find((e) => e.id === "gpt-5.5") - expect(gpt).toBeDefined() - expect(gpt!.reasoning).toBe(true) - expect(gpt!.reasoningEfforts).toEqual(["low", "high"]) - }) + const gpt = entries.find((e) => e.id === "gpt-5.5"); + expect(gpt).toBeDefined(); + expect(gpt!.reasoning).toBe(true); + expect(gpt!.reasoningEfforts).toEqual(["low", "high"]); + }); test("returns models when cost extraction fails", () => { const source = [ '(Wt={ANTHROPIC:"anthropic",OPENAI:"openai",VERCEL_AI_GATEWAY:"vercel-ai-gateway"});', 'var Aa="chatComplete",Ba="responses",qt=Vt[0];', - 'var Sn=(Wt=>({', + "var Sn=(Wt=>({", 'SONNET_4_6:{id:"claude-sonnet-4-6",provider:Wt.ANTHROPIC,spec:Aa,label:"Sonnet",name:"Claude Sonnet 4.6",description:"d",reasoning:!0,reasoningEfforts:["low","medium","high"],contextWindow:2e5},', 'GPT_X:{id:"gpt-5.5",provider:Wt.OPENAI,spec:Ba,label:"GPT",name:"GPT-5.5",description:"d",reasoningEfforts:["low","high"],contextWindow:256000}', - '}))(Wt);', - ].join("") + "}))(Wt);", + ].join(""); - const entries = loadCatalogFromBundle(source) - const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6") - expect(sonnet).toBeDefined() - expect(sonnet!.reasoningEfforts).toEqual(["low", "medium", "high"]) - expect(sonnet!.cost).toEqual({ input: 0.5, output: 2 }) - }) -}) + const entries = loadCatalogFromBundle(source); + const sonnet = entries.find((e) => e.id === "claude-sonnet-4-6"); + expect(sonnet).toBeDefined(); + expect(sonnet!.reasoningEfforts).toEqual(["low", "medium", "high"]); + expect(sonnet!.cost).toEqual({ input: 0.5, output: 2 }); + }); +}); describe("resolveCommandCodePackage", () => { test("returns null for missing explicit path without throwing", () => { - const prev = process.env.COMMANDCODE_PACKAGE_PATH - delete process.env.COMMANDCODE_PACKAGE_PATH + const prev = process.env.COMMANDCODE_PACKAGE_PATH; + delete process.env.COMMANDCODE_PACKAGE_PATH; try { const result = resolveCommandCodePackage({ packagePath: "D:\\definitely-not-a-real-command-code-path-xyz", - }) + }); // May still find a global install; explicit bad path alone is ignored after fail // and resolution continues. Ensure no throw and shape is valid when present. if (result) { - expect(result.root).toBeTruthy() - expect(result.bundlePath).toBeTruthy() - expect(result.version).toBeTruthy() + expect(result.root).toBeTruthy(); + expect(result.bundlePath).toBeTruthy(); + expect(result.version).toBeTruthy(); } else { - expect(result).toBeNull() + expect(result).toBeNull(); } } finally { - if (prev !== undefined) process.env.COMMANDCODE_PACKAGE_PATH = prev + if (prev !== undefined) process.env.COMMANDCODE_PACKAGE_PATH = prev; } - }) + }); test("resolves explicit package root when valid", () => { // If command-code is installed globally/locally, resolve it and re-resolve via path - const found = resolveCommandCodePackage() + const found = resolveCommandCodePackage(); if (!found) { - expect(found).toBeNull() - return + expect(found).toBeNull(); + return; } - const again = resolveCommandCodePackage({ packagePath: found.root }) - expect(again).not.toBeNull() - expect(again!.root).toBe(found.root) - expect(again!.bundlePath).toBe(found.bundlePath) - }) -}) + const again = resolveCommandCodePackage({ packagePath: found.root }); + expect(again).not.toBeNull(); + expect(again!.root).toBe(found.root); + expect(again!.bundlePath).toBe(found.bundlePath); + }); +}); diff --git a/tests/unit/convert.test.ts b/tests/unit/convert.test.ts index 18ab381..4913772 100644 --- a/tests/unit/convert.test.ts +++ b/tests/unit/convert.test.ts @@ -1,227 +1,298 @@ -import { expect, test } from "bun:test" -import { buildRequest } from "../../src/convert.js" -import type { LanguageModelV3CallOptions } from "@ai-sdk/provider" +import { expect, test } from "bun:test"; +import { buildRequest } from "../../src/convert.js"; +import type { LanguageModelV3CallOptions } from "@ai-sdk/provider"; function makeOpts(overrides: Partial = {}): LanguageModelV3CallOptions { return { prompt: [], maxOutputTokens: 1000, ...overrides, - } + }; } test("builds minimal request envelope", () => { - const req = buildRequest("test-model", makeOpts()) - expect(req.params.model).toBe("test-model") - expect(req.params.stream).toBe(true) - expect(req.params.messages).toEqual([]) - expect(req.params.tools).toEqual([]) - expect(req.params.system).toBe("") - expect(req.params.max_tokens).toBe(1000) -}) + const req = buildRequest("test-model", makeOpts()); + expect(req.params.model).toBe("test-model"); + expect(req.params.stream).toBe(true); + expect(req.params.messages).toEqual([]); + expect(req.params.tools).toEqual([]); + expect(req.params.system).toBe(""); + expect(req.params.max_tokens).toBe(1000); +}); test("concatenates system prompts", () => { - const req = buildRequest("m", makeOpts({ - prompt: [ - { role: "system", content: "You are a helpful assistant." }, - { role: "system", content: "You write TypeScript." }, - ], - })) - expect(req.params.system).toBe("You are a helpful assistant.\n\nYou write TypeScript.") -}) + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { role: "system", content: "You are a helpful assistant." }, + { role: "system", content: "You write TypeScript." }, + ], + }), + ); + expect(req.params.system).toBe("You are a helpful assistant.\n\nYou write TypeScript."); +}); test("converts user message with string content", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ role: "user", content: "hello" }], - })) - expect(req.params.messages).toHaveLength(1) - expect(req.params.messages[0]).toEqual({ role: "user", content: "hello" }) -}) + const req = buildRequest( + "m", + makeOpts({ + prompt: [{ role: "user", content: "hello" }], + }), + ); + expect(req.params.messages).toHaveLength(1); + expect(req.params.messages[0]).toEqual({ role: "user", content: "hello" }); +}); test("converts user message with array of text parts", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ - role: "user", - content: [ - { type: "text", text: "line1" }, - { type: "text", text: "line2" }, + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { + role: "user", + content: [ + { type: "text", text: "line1" }, + { type: "text", text: "line2" }, + ], + }, ], - }], - })) - expect(req.params.messages).toHaveLength(1) - expect(req.params.messages[0]).toEqual({ role: "user", content: "line1\nline2" }) -}) + }), + ); + expect(req.params.messages).toHaveLength(1); + expect(req.params.messages[0]).toEqual({ role: "user", content: "line1\nline2" }); +}); test("user message with non-text parts drops them silently", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ - role: "user", - content: [ - { type: "text", text: "hello" }, - { type: "image", url: "https://example.com/img.png" }, + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { + role: "user", + content: [ + { type: "text", text: "hello" }, + { type: "image", url: "https://example.com/img.png" }, + ], + }, ], - }], - })) - expect(req.params.messages).toHaveLength(1) - const msg = req.params.messages[0] as { role: "user"; content: string } - expect(msg.content).toBe("hello") -}) + }), + ); + expect(req.params.messages).toHaveLength(1); + const msg = req.params.messages[0] as { role: "user"; content: string }; + expect(msg.content).toBe("hello"); +}); test("converts assistant message with text, reasoning, and tool-call parts", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ - role: "assistant", - content: [ - { type: "text", text: "I think" }, - { type: "reasoning", text: "hmm..." }, - { type: "tool-call", toolCallId: "tc1", toolName: "bash", input: { cmd: "ls" } }, + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { + role: "assistant", + content: [ + { type: "text", text: "I think" }, + { type: "reasoning", text: "hmm..." }, + { type: "tool-call", toolCallId: "tc1", toolName: "bash", input: { cmd: "ls" } }, + ], + }, ], - }], - })) - expect(req.params.messages).toHaveLength(1) - const msg = req.params.messages[0] as { role: "assistant"; content: unknown[] } - expect(msg.content).toHaveLength(3) - expect(msg.content[0]).toEqual({ type: "text", text: "I think" }) - expect(msg.content[1]).toEqual({ type: "reasoning", text: "hmm..." }) - expect(msg.content[2]).toEqual({ type: "tool-call", toolCallId: "tc1", toolName: "bash", input: { cmd: "ls" } }) -}) + }), + ); + expect(req.params.messages).toHaveLength(1); + const msg = req.params.messages[0] as { role: "assistant"; content: unknown[] }; + expect(msg.content).toHaveLength(3); + expect(msg.content[0]).toEqual({ type: "text", text: "I think" }); + expect(msg.content[1]).toEqual({ type: "reasoning", text: "hmm..." }); + expect(msg.content[2]).toEqual({ + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + input: { cmd: "ls" }, + }); +}); test("converts tool result message with text output", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ - role: "tool", - content: [{ - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - output: { type: "text", value: "file.ts" }, - }], - }], - })) - const msg = req.params.messages[0] as { role: "tool"; content: unknown[] } + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + output: { type: "text", value: "file.ts" }, + }, + ], + }, + ], + }), + ); + const msg = req.params.messages[0] as { role: "tool"; content: unknown[] }; expect(msg.content[0]).toEqual({ type: "tool-result", toolCallId: "tc1", toolName: "bash", output: { type: "text", value: "file.ts" }, - }) -}) + }); +}); test("converts tool result with error-text output", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ - role: "tool", - content: [{ - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - output: { type: "error-text", value: "command not found" }, - }], - }], - })) - const msg = req.params.messages[0] as { role: "tool"; content: unknown[] } - const out = msg.content[0] as { output: { type: string; value: string } } - expect(out.output.type).toBe("error-text") - expect(out.output.value).toBe("command not found") -}) + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + output: { type: "error-text", value: "command not found" }, + }, + ], + }, + ], + }), + ); + const msg = req.params.messages[0] as { role: "tool"; content: unknown[] }; + const out = msg.content[0] as { output: { type: string; value: string } }; + expect(out.output.type).toBe("error-text"); + expect(out.output.value).toBe("command not found"); +}); test("converts tool result with json output", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ - role: "tool", - content: [{ - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - output: { type: "json", value: { key: "val" } }, - }], - }], - })) - const msg = req.params.messages[0] as { role: "tool"; content: unknown[] } - const out = msg.content[0] as { output: { type: string; value: string } } - expect(out.output.type).toBe("text") - expect(JSON.parse(out.output.value)).toEqual({ key: "val" }) -}) + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + output: { type: "json", value: { key: "val" } }, + }, + ], + }, + ], + }), + ); + const msg = req.params.messages[0] as { role: "tool"; content: unknown[] }; + const out = msg.content[0] as { output: { type: string; value: string } }; + expect(out.output.type).toBe("text"); + expect(JSON.parse(out.output.value)).toEqual({ key: "val" }); +}); test("converts tool result with execution-denied output", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ - role: "tool", - content: [{ - type: "tool-result", - toolCallId: "tc1", - toolName: "bash", - output: { type: "execution-denied", reason: "not allowed" }, - }], - }], - })) - const msg = req.params.messages[0] as { role: "tool"; content: unknown[] } - const out = msg.content[0] as { output: { type: string; value: string } } - expect(out.output.type).toBe("error-text") - expect(out.output.value).toBe("not allowed") -}) + const req = buildRequest( + "m", + makeOpts({ + prompt: [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "tc1", + toolName: "bash", + output: { type: "execution-denied", reason: "not allowed" }, + }, + ], + }, + ], + }), + ); + const msg = req.params.messages[0] as { role: "tool"; content: unknown[] }; + const out = msg.content[0] as { output: { type: string; value: string } }; + expect(out.output.type).toBe("error-text"); + expect(out.output.value).toBe("not allowed"); +}); test("skips unknown message roles", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ role: "unknown" as never, content: "test" }], - })) - expect(req.params.messages).toHaveLength(0) -}) + const req = buildRequest( + "m", + makeOpts({ + prompt: [{ role: "unknown" as never, content: "test" }], + }), + ); + expect(req.params.messages).toHaveLength(0); +}); test("converts function tools", () => { - const req = buildRequest("m", makeOpts({ - tools: [ - { - type: "function", - name: "my_tool", - description: "A test tool", - inputSchema: { type: "object", properties: {} }, - }, - ], - })) - expect(req.params.tools).toHaveLength(1) + const req = buildRequest( + "m", + makeOpts({ + tools: [ + { + type: "function", + name: "my_tool", + description: "A test tool", + inputSchema: { type: "object", properties: {} }, + }, + ], + }), + ); + expect(req.params.tools).toHaveLength(1); expect(req.params.tools[0]).toEqual({ type: "function", name: "my_tool", description: "A test tool", input_schema: { type: "object", properties: {} }, - }) -}) + }); +}); test("filters out provider tools", () => { - const req = buildRequest("m", makeOpts({ - tools: [ - { type: "function", name: "func_tool", description: "", inputSchema: {} }, - { type: "provider", id: "provider.tool" as `${string}.${string}`, name: "prov_tool", args: {} }, - ], - })) - expect(req.params.tools).toHaveLength(1) - expect(req.params.tools[0].name).toBe("func_tool") -}) + const req = buildRequest( + "m", + makeOpts({ + tools: [ + { type: "function", name: "func_tool", description: "", inputSchema: {} }, + { + type: "provider", + id: "provider.tool" as `${string}.${string}`, + name: "prov_tool", + args: {}, + }, + ], + }), + ); + expect(req.params.tools).toHaveLength(1); + expect(req.params.tools[0].name).toBe("func_tool"); +}); test("passes through temperature, topP, topK", () => { - const req = buildRequest("m", makeOpts({ - prompt: [{ role: "user", content: "hi" }], - temperature: 0.5, - topP: 0.9, - topK: 40, - })) - expect(req.params.temperature).toBe(0.5) - expect(req.params.top_p).toBe(0.9) - expect(req.params.top_k).toBe(40) -}) + const req = buildRequest( + "m", + makeOpts({ + prompt: [{ role: "user", content: "hi" }], + temperature: 0.5, + topP: 0.9, + topK: 40, + }), + ); + expect(req.params.temperature).toBe(0.5); + expect(req.params.top_p).toBe(0.9); + expect(req.params.top_k).toBe(40); +}); test("defaults max_tokens to 16384 when not provided", () => { - const req = buildRequest("m", makeOpts({ maxOutputTokens: undefined })) - expect(req.params.max_tokens).toBe(16384) -}) + const req = buildRequest("m", makeOpts({ maxOutputTokens: undefined })); + expect(req.params.max_tokens).toBe(16384); +}); test("envelope has correct top-level shape", () => { - const req = buildRequest("m", makeOpts()) - expect(req).toHaveProperty("config") - expect(req).toHaveProperty("memory", "") - expect(req).toHaveProperty("taste", "") - expect(req).toHaveProperty("skills", null) - expect(req).toHaveProperty("permissionMode", "standard") - expect(req).toHaveProperty("params") -}) + const req = buildRequest("m", makeOpts()); + expect(req).toHaveProperty("config"); + expect(req).toHaveProperty("memory", ""); + expect(req).toHaveProperty("taste", ""); + expect(req).toHaveProperty("skills", null); + expect(req).toHaveProperty("permissionMode", "standard"); + expect(req).toHaveProperty("params"); +}); diff --git a/tests/unit/costs-docs.test.ts b/tests/unit/costs-docs.test.ts index e18d5b1..6a51425 100644 --- a/tests/unit/costs-docs.test.ts +++ b/tests/unit/costs-docs.test.ts @@ -1,39 +1,45 @@ -import { expect, test, describe } from "bun:test" -import { readFileSync } from "fs" -import { join } from "path" -import { applyDocCosts, parseModelsTable, parseMoneyCell } from "../../src/costs-docs.ts" -import type { ModelEntry } from "../../src/catalog.ts" +import { expect, test, describe } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { applyDocCosts, parseModelsTable, parseMoneyCell } from "../../src/costs-docs.ts"; +import type { ModelEntry } from "../../src/catalog.ts"; describe("parseMoneyCell", () => { test("reads billed amount when a deal shows two prices", () => { - expect(parseMoneyCell("$1.50$0.75")).toBe(0.75) - expect(parseMoneyCell("$3.00")).toBe(3) - expect(parseMoneyCell("Free")).toBe(0) - expect(parseMoneyCell("—")).toBeUndefined() - }) -}) + expect(parseMoneyCell("$1.50$0.75")).toBe(0.75); + expect(parseMoneyCell("$3.00")).toBe(3); + expect(parseMoneyCell("Free")).toBe(0); + expect(parseMoneyCell("—")).toBeUndefined(); + }); +}); describe("parseModelsTable", () => { test("parses display names and billed rates", () => { - const md = readFileSync(join(import.meta.dir, "../fixtures/command-code/models-page.md"), "utf-8") - const rows = parseModelsTable(md) + const md = readFileSync( + join(import.meta.dir, "../fixtures/command-code/models-page.md"), + "utf-8", + ); + const rows = parseModelsTable(md); expect(rows.find((r) => r.name === "Claude Sonnet 4.6")).toEqual({ name: "Claude Sonnet 4.6", input: 3, output: 15, cache_read: 0.3, cache_write: 3.75, - }) - const gemini = rows.find((r) => r.name === "Gemini 3.7 Flash-50%") - expect(gemini?.input).toBe(0.75) - expect(gemini?.output).toBe(3.75) - expect(rows.find((r) => r.name === "Laguna S 2.1FREE")?.input).toBe(0) - }) + }); + const gemini = rows.find((r) => r.name === "Gemini 3.7 Flash-50%"); + expect(gemini?.input).toBe(0.75); + expect(gemini?.output).toBe(3.75); + expect(rows.find((r) => r.name === "Laguna S 2.1FREE")?.input).toBe(0); + }); test("parses live HTML using header names not column indexes", () => { - const html = readFileSync(join(import.meta.dir, "../fixtures/command-code/models-page.live.md"), "utf-8") - const rows = parseModelsTable(html) - const sonnet = rows.find((r) => r.name === "Claude Sonnet 4.6") + const html = readFileSync( + join(import.meta.dir, "../fixtures/command-code/models-page.live.md"), + "utf-8", + ); + const rows = parseModelsTable(html); + const sonnet = rows.find((r) => r.name === "Claude Sonnet 4.6"); expect(sonnet).toEqual({ name: "Claude Sonnet 4.6", id: "claude-sonnet-4-6", @@ -41,16 +47,16 @@ describe("parseModelsTable", () => { output: 15, cache_read: 0.3, cache_write: 3.75, - }) - const gemini = rows.find((r) => r.name === "Gemini 3.7 Flash") - expect(gemini?.input).toBe(0.75) - expect(gemini?.output).toBe(3.75) - expect(rows.find((r) => r.name === "Laguna S 2.1")?.input).toBe(0) - const hy4 = rows.find((r) => r.name === "Tencent Hy4 Preview") - expect(hy4?.input).toBe(0.834) - expect(hy4?.output).toBe(2.501) - }) -}) + }); + const gemini = rows.find((r) => r.name === "Gemini 3.7 Flash"); + expect(gemini?.input).toBe(0.75); + expect(gemini?.output).toBe(3.75); + expect(rows.find((r) => r.name === "Laguna S 2.1")?.input).toBe(0); + const hy4 = rows.find((r) => r.name === "Tencent Hy4 Preview"); + expect(hy4?.input).toBe(0.834); + expect(hy4?.output).toBe(2.501); + }); +}); describe("applyDocCosts", () => { test("fills by exact name and skips models that already have cli costs", () => { @@ -73,12 +79,16 @@ describe("applyDocCosts", () => { cost: { input: 9, output: 9 }, limit: { context: 1000, output: 1000 }, }, - ] - const filled = applyDocCosts(models, parseModelsTable( - readFileSync(join(import.meta.dir, "../fixtures/command-code/models-page.md"), "utf-8"), - ), new Set(["keep/cli"])) - expect(filled).toBe(1) - expect(models[0].cost).toEqual({ input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }) - expect(models[1].cost).toEqual({ input: 9, output: 9 }) - }) -}) + ]; + const filled = applyDocCosts( + models, + parseModelsTable( + readFileSync(join(import.meta.dir, "../fixtures/command-code/models-page.md"), "utf-8"), + ), + new Set(["keep/cli"]), + ); + expect(filled).toBe(1); + expect(models[0].cost).toEqual({ input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 }); + expect(models[1].cost).toEqual({ input: 9, output: 9 }); + }); +}); diff --git a/tests/unit/costs-models-dev.test.ts b/tests/unit/costs-models-dev.test.ts new file mode 100644 index 0000000..d0a1489 --- /dev/null +++ b/tests/unit/costs-models-dev.test.ts @@ -0,0 +1,90 @@ +import { expect, test, describe } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; +import { + applyFreeCosts, + applyModelsDevCosts, + isFreeSku, + parseModelsDev, +} from "../../src/costs-models-dev.ts"; +import type { ModelEntry } from "../../src/catalog.ts"; + +function model(partial: Partial & Pick): ModelEntry { + return { + tier: "open-source", + reasoning: false, + tool_call: true, + cost: { input: 0.5, output: 2 }, + limit: { context: 200000, output: 65536 }, + ...partial, + }; +} + +describe("isFreeSku", () => { + test("detects free from name or id suffix", () => { + expect(isFreeSku({ id: "tencent/Hy3", name: "Tencent Hy3 (Free)" })).toBe(true); + expect(isFreeSku({ id: "inclusionai/ling-3.0-flash-free", name: "Ling 3.0 Flash" })).toBe(true); + expect(isFreeSku({ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" })).toBe(false); + }); +}); + +describe("parseModelsDev + applyModelsDevCosts", () => { + const rows = parseModelsDev( + readFileSync(join(import.meta.dir, "../fixtures/models-dev/api.subset.json"), "utf-8"), + ); + + test("fills by exact id and skips already priced models", () => { + const models = [ + model({ + id: "google/gemini-3.5-flash", + name: "Gemini 3.5 Flash", + cost: { input: 0.5, output: 2 }, + }), + model({ id: "kept", name: "Kept", cost: { input: 3, output: 15 } }), + ]; + const filled = new Set(); + const n = applyModelsDevCosts(models, rows, new Set(["kept"]), filled); + expect(n).toBe(1); + expect(models[0].cost).toEqual({ input: 1.5, output: 9, cache_read: 0.15 }); + expect(models[1].cost).toEqual({ input: 3, output: 15 }); + expect([...filled]).toEqual(["google/gemini-3.5-flash"]); + }); + + test("does not apply models.dev prices to Command Code free SKUs", () => { + const models = [ + model({ + id: "minimax/minimax-m2.7-free", + name: "MiniMax M2.7 Free", + cost: { input: 0.5, output: 2 }, + }), + ]; + const filled = new Set(); + expect(applyModelsDevCosts(models, rows, new Set(), filled)).toBe(0); + expect(models[0].cost).toEqual({ input: 0.5, output: 2 }); + expect(filled.size).toBe(0); + }); + + test("matches the last path segment of a catalog id", () => { + const models = [ + model({ id: "Qwen/Qwen3.6-Plus", name: "Qwen 3.6 Plus", cost: { input: 0.5, output: 2 } }), + ]; + const filled = new Set(); + expect(applyModelsDevCosts(models, rows, new Set(), filled)).toBe(1); + expect(models[0].cost).toEqual({ input: 0.5, output: 3, cache_read: 0.1 }); + expect([...filled]).toEqual(["Qwen/Qwen3.6-Plus"]); + }); +}); + +describe("applyFreeCosts", () => { + test("sets free SKUs to zero and leaves paid models alone", () => { + const models = [ + model({ id: "tencent/Hy3", name: "Tencent Hy3 (Free)" }), + model({ id: "google/gemini-3.5-flash", name: "Gemini 3.5 Flash" }), + ]; + const filled = new Set(); + expect(applyFreeCosts(models, new Set(), filled)).toBe(1); + expect(models[0].cost).toEqual({ input: 0, output: 0 }); + expect(models[1].cost).toEqual({ input: 0.5, output: 2 }); + expect([...filled]).toEqual(["tencent/Hy3"]); + }); +}); diff --git a/tests/unit/index.test.ts b/tests/unit/index.test.ts index b620898..435c4aa 100644 --- a/tests/unit/index.test.ts +++ b/tests/unit/index.test.ts @@ -1,68 +1,69 @@ -import { expect, test, beforeAll, afterAll } from "bun:test" -import { createCommandCode } from "../../index.js" +import { expect, test, beforeAll, afterAll } from "bun:test"; +import { createCommandCode } from "../../index.js"; -let originalEnv: Record = {} +let originalEnv: Record = {}; beforeAll(() => { - originalEnv.COMMANDCODE_API_KEY = process.env.COMMANDCODE_API_KEY - delete process.env.COMMANDCODE_API_KEY -}) + originalEnv.COMMANDCODE_API_KEY = process.env.COMMANDCODE_API_KEY; + delete process.env.COMMANDCODE_API_KEY; +}); afterAll(() => { - if (originalEnv.COMMANDCODE_API_KEY) process.env.COMMANDCODE_API_KEY = originalEnv.COMMANDCODE_API_KEY -}) + if (originalEnv.COMMANDCODE_API_KEY) + process.env.COMMANDCODE_API_KEY = originalEnv.COMMANDCODE_API_KEY; +}); -const noAuthPaths = { authPaths: [] as string[] } +const noAuthPaths = { authPaths: [] as string[] }; test("throws when no API key is available", () => { - expect(() => createCommandCode(noAuthPaths)).toThrow("Command Code API key not found") -}) + expect(() => createCommandCode(noAuthPaths)).toThrow("Command Code API key not found"); +}); test("throws with descriptive message listing all options", () => { try { - createCommandCode(noAuthPaths) - expect.unreachable("Should have thrown") + createCommandCode(noAuthPaths); + expect.unreachable("Should have thrown"); } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - expect(msg).toContain("COMMANDCODE_API_KEY") - expect(msg).toContain("auth.json") - expect(msg).toContain("apiKey") + const msg = err instanceof Error ? err.message : String(err); + expect(msg).toContain("COMMANDCODE_API_KEY"); + expect(msg).toContain("auth.json"); + expect(msg).toContain("apiKey"); } -}) +}); test("returns provider with languageModel when apiKey is provided", () => { - const provider = createCommandCode({ apiKey: "sk-test" }) - expect(provider.languageModel).toBeDefined() - expect(typeof provider.languageModel).toBe("function") -}) + const provider = createCommandCode({ apiKey: "sk-test" }); + expect(provider.languageModel).toBeDefined(); + expect(typeof provider.languageModel).toBe("function"); +}); test("languageModel returns a model with correct provider and modelId", () => { - const provider = createCommandCode({ apiKey: "sk-test" }) - const model = provider.languageModel("gpt-5.5") - expect(model.provider).toBe("commandcode") - expect(model.modelId).toBe("gpt-5.5") - expect(model.specificationVersion).toBe("v3") -}) + const provider = createCommandCode({ apiKey: "sk-test" }); + const model = provider.languageModel("gpt-5.5"); + expect(model.provider).toBe("commandcode"); + expect(model.modelId).toBe("gpt-5.5"); + expect(model.specificationVersion).toBe("v3"); +}); test("resolves API key from env var", () => { - process.env.COMMANDCODE_API_KEY = "sk-from-env" - const provider = createCommandCode() - const model = provider.languageModel("test") - expect(model).toBeDefined() - delete process.env.COMMANDCODE_API_KEY -}) + process.env.COMMANDCODE_API_KEY = "sk-from-env"; + const provider = createCommandCode(); + const model = provider.languageModel("test"); + expect(model).toBeDefined(); + delete process.env.COMMANDCODE_API_KEY; +}); test("explicit apiKey takes priority over env var", () => { - process.env.COMMANDCODE_API_KEY = "sk-from-env" - const provider = createCommandCode({ apiKey: "sk-explicit" }) - const model = provider.languageModel("test") - expect(model).toBeDefined() - delete process.env.COMMANDCODE_API_KEY -}) + process.env.COMMANDCODE_API_KEY = "sk-from-env"; + const provider = createCommandCode({ apiKey: "sk-explicit" }); + const model = provider.languageModel("test"); + expect(model).toBeDefined(); + delete process.env.COMMANDCODE_API_KEY; +}); test("each languageModel call returns a new instance", () => { - const provider = createCommandCode({ apiKey: "sk-test" }) - const m1 = provider.languageModel("a") - const m2 = provider.languageModel("b") - expect(m1).not.toBe(m2) - expect(m1.modelId).toBe("a") - expect(m2.modelId).toBe("b") -}) + const provider = createCommandCode({ apiKey: "sk-test" }); + const m1 = provider.languageModel("a"); + const m2 = provider.languageModel("b"); + expect(m1).not.toBe(m2); + expect(m1.modelId).toBe("a"); + expect(m2.modelId).toBe("b"); +}); diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts index 4912f1f..3c6e985 100644 --- a/tests/unit/manifest.test.ts +++ b/tests/unit/manifest.test.ts @@ -1,7 +1,7 @@ -import { expect, test, describe } from "bun:test" -import { mkdtempSync, readFileSync, rmSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" +import { expect, test, describe } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { buildManifest, bumpPatch, @@ -9,35 +9,36 @@ import { meetsModelCountFloor, writeManifest, type CostSources, -} from "../../src/manifest.ts" +} from "../../src/manifest.ts"; const sources = (partial: Partial = {}): CostSources => ({ cli: 0, officialDocs: 0, thirdParty: 0, + free: 0, fallback: 0, unmatched: 0, ...partial, -}) +}); describe("bumpPatch", () => { test("increments the patch segment", () => { - expect(bumpPatch("0.5.0")).toBe("0.5.1") - expect(bumpPatch("1.2.9")).toBe("1.2.10") - }) -}) + expect(bumpPatch("0.5.0")).toBe("0.5.1"); + expect(bumpPatch("1.2.9")).toBe("1.2.10"); + }); +}); describe("meetsModelCountFloor", () => { test("uses 20 when there is no prior successful catalog", () => { - expect(meetsModelCountFloor(20, null)).toBe(true) - expect(meetsModelCountFloor(19, null)).toBe(false) - }) + expect(meetsModelCountFloor(20, null)).toBe(true); + expect(meetsModelCountFloor(19, null)).toBe(false); + }); test("uses half of the last successful count when that is above 20", () => { - expect(meetsModelCountFloor(32, 65)).toBe(true) - expect(meetsModelCountFloor(31, 65)).toBe(false) - }) -}) + expect(meetsModelCountFloor(32, 65)).toBe(true); + expect(meetsModelCountFloor(31, 65)).toBe(false); + }); +}); describe("buildManifest", () => { test("marks healthy when every cost came from cli, docs, or third-party", () => { @@ -50,15 +51,15 @@ describe("buildManifest", () => { modelCatalogOk: true, costSources: sources({ cli: 5, officialDocs: 60 }), generatedAt: "2026-08-28T17:00:00.000Z", - }) - expect(manifest.status).toBe("healthy") - expect(manifest.extraction.modelCatalog).toBe("ok") - expect(manifest.extraction.costCatalog).toBe("cli") - expect(manifest.schemaVersion).toBe(1) - expect(manifest.gitCommit).toBeUndefined() - }) + }); + expect(manifest.status).toBe("healthy"); + expect(manifest.extraction.modelCatalog).toBe("ok"); + expect(manifest.extraction.costCatalog).toBe("cli"); + expect(manifest.schemaVersion).toBe(1); + expect(manifest.gitCommit).toBeUndefined(); + }); - test("marks degraded when any model used fallback or unmatched costs", () => { + test("marks healthy when leftover costs come from models.dev or free SKUs", () => { const manifest = buildManifest({ pluginVersion: "0.5.0", commandCodeVersion: "1.38.1", @@ -66,12 +67,27 @@ describe("buildManifest", () => { modelCount: 65, reasoningModelCount: 54, modelCatalogOk: true, - costSources: sources({ officialDocs: 60, fallback: 3, unmatched: 2 }), + costSources: sources({ officialDocs: 53, thirdParty: 7, free: 5 }), generatedAt: "2026-08-28T17:00:00.000Z", - }) - expect(manifest.status).toBe("degraded") - expect(manifest.extraction.costCatalog).toBe("docs") - }) + }); + expect(manifest.status).toBe("healthy"); + expect(manifest.extraction.costCatalog).toBe("docs"); + }); + + test("marks degraded only when unmatched costs remain", () => { + const manifest = buildManifest({ + pluginVersion: "0.5.0", + commandCodeVersion: "1.38.1", + commandCodeTarball: "https://registry.npmjs.org/command-code/-/command-code-1.38.1.tgz", + modelCount: 65, + reasoningModelCount: 54, + modelCatalogOk: true, + costSources: sources({ officialDocs: 60, thirdParty: 3, unmatched: 2 }), + generatedAt: "2026-08-28T17:00:00.000Z", + }); + expect(manifest.status).toBe("degraded"); + expect(manifest.extraction.costCatalog).toBe("docs"); + }); test("marks broken when the model catalog failed", () => { const manifest = buildManifest({ @@ -84,15 +100,15 @@ describe("buildManifest", () => { costSources: sources(), generatedAt: "2026-08-28T17:00:00.000Z", costCatalogError: "Could not evaluate model catalog", - }) - expect(manifest.status).toBe("broken") - expect(manifest.extraction.modelCatalog).toBe("failed") - }) -}) + }); + expect(manifest.status).toBe("broken"); + expect(manifest.extraction.modelCatalog).toBe("failed"); + }); +}); describe("writeManifest", () => { test("round-trips JSON with a trailing newline", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-manifest-")) + const dir = mkdtempSync(join(tmpdir(), "cc-manifest-")); try { const manifest = buildManifest({ pluginVersion: "0.5.0", @@ -103,32 +119,33 @@ describe("writeManifest", () => { modelCatalogOk: true, costSources: sources({ officialDocs: 65 }), generatedAt: "2026-08-28T17:00:00.000Z", - }) - const path = join(dir, "manifest.json") - writeManifest(path, manifest) - expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(manifest) - expect(readFileSync(path, "utf-8").endsWith("\n")).toBe(true) + }); + const path = join(dir, "manifest.json"); + writeManifest(path, manifest); + expect(JSON.parse(readFileSync(path, "utf-8"))).toEqual(manifest); + expect(readFileSync(path, "utf-8").endsWith("\n")).toBe(true); } finally { - rmSync(dir, { recursive: true, force: true }) + rmSync(dir, { recursive: true, force: true }); } - }) -}) + }); +}); describe("countCostSources", () => { - test("classifies first-hit cli then docs then fallback then unmatched", () => { + test("classifies first-hit cli then docs then models.dev then free then unmatched", () => { const counted = countCostSources({ - modelIds: ["a", "b", "c", "d"], + modelIds: ["a", "b", "c", "d", "e"], cliIds: new Set(["a"]), officialDocIds: new Set(["b"]), - thirdPartyIds: new Set(), - fallbackIds: new Set(["c"]), - }) + thirdPartyIds: new Set(["c"]), + freeIds: new Set(["d"]), + }); expect(counted).toEqual({ cli: 1, officialDocs: 1, - thirdParty: 0, - fallback: 1, + thirdParty: 1, + free: 1, + fallback: 0, unmatched: 1, - }) - }) -}) + }); + }); +}); diff --git a/tests/unit/model.test.ts b/tests/unit/model.test.ts index 0d7e188..648569d 100644 --- a/tests/unit/model.test.ts +++ b/tests/unit/model.test.ts @@ -1,169 +1,181 @@ -import { expect, test, beforeAll, afterAll } from "bun:test" -import { CommandCodeLanguageModel } from "../../src/model.js" -import { mockFetchTrack, mockFetchError, mockFetchStream, makeCallOptions } from "../helpers/mocks.js" - -const MODEL_ID = "test-model" -const API_KEY = "sk-test-key" +import { expect, test, beforeAll, afterAll } from "bun:test"; +import { CommandCodeLanguageModel } from "../../src/model.js"; +import { + mockFetchTrack, + mockFetchError, + mockFetchStream, + makeCallOptions, +} from "../helpers/mocks.js"; + +const MODEL_ID = "test-model"; +const API_KEY = "sk-test-key"; function makeModel(baseURL?: string) { return new CommandCodeLanguageModel(MODEL_ID, { apiKey: API_KEY, baseURL, - }) + }); } -let originalEnv: Record = {} +let originalEnv: Record = {}; beforeAll(() => { - originalEnv.COMMANDCODE_API_KEY = process.env.COMMANDCODE_API_KEY - delete process.env.COMMANDCODE_API_KEY -}) + originalEnv.COMMANDCODE_API_KEY = process.env.COMMANDCODE_API_KEY; + delete process.env.COMMANDCODE_API_KEY; +}); afterAll(() => { - if (originalEnv.COMMANDCODE_API_KEY) process.env.COMMANDCODE_API_KEY = originalEnv.COMMANDCODE_API_KEY -}) + if (originalEnv.COMMANDCODE_API_KEY) + process.env.COMMANDCODE_API_KEY = originalEnv.COMMANDCODE_API_KEY; +}); test("provider and modelId are correct", () => { - const model = makeModel() - expect(model.provider).toBe("commandcode") - expect(model.modelId).toBe(MODEL_ID) - expect(model.specificationVersion).toBe("v3") -}) + const model = makeModel(); + expect(model.provider).toBe("commandcode"); + expect(model.modelId).toBe(MODEL_ID); + expect(model.specificationVersion).toBe("v3"); +}); test("doStream sends correct headers", async () => { - const { calls, restore, respondWith } = mockFetchTrack() + const { calls, restore, respondWith } = mockFetchTrack(); respondWith({ ok: true, status: 200, headers: new Headers({ "content-type": "text/event-stream" }), body: new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')) - controller.close() + controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')); + controller.close(); }, }), - }) + }); - const model = makeModel() - await model.doStream(makeCallOptions()) - restore() + const model = makeModel(); + await model.doStream(makeCallOptions()); + restore(); - expect(calls).toHaveLength(1) - const headers = calls[0].options.headers as Record - expect(headers["Authorization"]).toBe("Bearer sk-test-key") - expect(headers["Content-Type"]).toBe("application/json") - expect(headers["x-command-code-version"]).toBe("0.26.20") - expect(headers["x-cli-environment"]).toBe("production") - expect(headers["x-project-slug"]).toBe("opencode") -}) + expect(calls).toHaveLength(1); + const headers = calls[0].options.headers as Record; + expect(headers["Authorization"]).toBe("Bearer sk-test-key"); + expect(headers["Content-Type"]).toBe("application/json"); + expect(headers["x-command-code-version"]).toBe("0.26.20"); + expect(headers["x-cli-environment"]).toBe("production"); + expect(headers["x-project-slug"]).toBe("opencode"); +}); test("doStream sends request body with model and messages", async () => { - const { calls, restore, respondWith } = mockFetchTrack() + const { calls, restore, respondWith } = mockFetchTrack(); respondWith({ ok: true, status: 200, headers: new Headers(), body: new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')) - controller.close() + controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')); + controller.close(); }, }), - }) + }); - const model = makeModel() - await model.doStream(makeCallOptions({ prompt: [{ role: "user", content: "hi" }] })) - restore() + const model = makeModel(); + await model.doStream(makeCallOptions({ prompt: [{ role: "user", content: "hi" }] })); + restore(); - const body = JSON.parse(calls[0].options.body as string) - expect(body.params.model).toBe(MODEL_ID) - expect(body.params.messages).toHaveLength(1) - expect(body.params.messages[0].content).toBe("hi") -}) + const body = JSON.parse(calls[0].options.body as string); + expect(body.params.model).toBe(MODEL_ID); + expect(body.params.messages).toHaveLength(1); + expect(body.params.messages[0].content).toBe("hi"); +}); test("doStream uses correct URL", async () => { - const { calls, restore, respondWith } = mockFetchTrack() + const { calls, restore, respondWith } = mockFetchTrack(); respondWith({ ok: true, body: new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')) - controller.close() + controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')); + controller.close(); }, }), - }) + }); - const model = makeModel() - await model.doStream(makeCallOptions()) - restore() + const model = makeModel(); + await model.doStream(makeCallOptions()); + restore(); - expect(calls[0].url).toBe("https://api.commandcode.ai/alpha/generate") -}) + expect(calls[0].url).toBe("https://api.commandcode.ai/alpha/generate"); +}); test("doStream uses custom baseURL when provided", async () => { - const { calls, restore, respondWith } = mockFetchTrack() + const { calls, restore, respondWith } = mockFetchTrack(); respondWith({ ok: true, body: new ReadableStream({ start(controller) { - controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')) - controller.close() + controller.enqueue(new TextEncoder().encode('data: {"type":"start"}\n\n')); + controller.close(); }, }), - }) + }); - const model = makeModel("https://custom.example.com") - await model.doStream(makeCallOptions()) - restore() + const model = makeModel("https://custom.example.com"); + await model.doStream(makeCallOptions()); + restore(); - expect(calls[0].url).toBe("https://custom.example.com/alpha/generate") -}) + expect(calls[0].url).toBe("https://custom.example.com/alpha/generate"); +}); test("doStream throws descriptive error on non-OK response", async () => { - const { restore } = mockFetchError(401, "Unauthorized", JSON.stringify({ - error: { message: "Invalid API key" }, - })) - const model = makeModel() - expect(model.doStream(makeCallOptions())).rejects.toThrow("Invalid API key") - restore() -}) + const { restore } = mockFetchError( + 401, + "Unauthorized", + JSON.stringify({ + error: { message: "Invalid API key" }, + }), + ); + const model = makeModel(); + expect(model.doStream(makeCallOptions())).rejects.toThrow("Invalid API key"); + restore(); +}); test("doStream throws on HTTP error without JSON body", async () => { - const { restore } = mockFetchError(500, "Internal Server Error") - const model = makeModel() - expect(model.doStream(makeCallOptions())).rejects.toThrow("Command Code API error: 500 Internal Server Error") - restore() -}) + const { restore } = mockFetchError(500, "Internal Server Error"); + const model = makeModel(); + expect(model.doStream(makeCallOptions())).rejects.toThrow( + "Command Code API error: 500 Internal Server Error", + ); + restore(); +}); test("doStream throws when response body is null", async () => { - const { restore: r1 } = mockFetchError(200, "OK") - const track = mockFetchTrack() - track.respondWith({ ok: true, body: null }) - const model = makeModel() - expect(model.doStream(makeCallOptions())).rejects.toThrow("Command Code API returned no body") - track.restore() - r1() -}) + const { restore: r1 } = mockFetchError(200, "OK"); + const track = mockFetchTrack(); + track.respondWith({ ok: true, body: null }); + const model = makeModel(); + expect(model.doStream(makeCallOptions())).rejects.toThrow("Command Code API returned no body"); + track.restore(); + r1(); +}); test("doStream streams returned parts", async () => { const { restore } = mockFetchStream([ 'data: {"type":"start"}\n\n', 'data: {"type":"text-delta","id":"t1","delta":"hello"}\n\n', - ]) - const model = makeModel() - const result = await model.doStream(makeCallOptions()) - const reader = result.stream.getReader() - const parts: any[] = [] + ]); + const model = makeModel(); + const result = await model.doStream(makeCallOptions()); + const reader = result.stream.getReader(); + const parts: any[] = []; while (true) { - const { done, value } = await reader.read() - if (done) break - parts.push(value) + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); } - reader.releaseLock() - restore() + reader.releaseLock(); + restore(); - expect(parts).toHaveLength(2) - expect(parts[0].type).toBe("stream-start") - expect(parts[1].type).toBe("text-delta") -}) + expect(parts).toHaveLength(2); + expect(parts[0].type).toBe("stream-start"); + expect(parts[1].type).toBe("text-delta"); +}); test("doGenerate returns complete response", async () => { const { restore } = mockFetchStream([ @@ -171,18 +183,18 @@ test("doGenerate returns complete response", async () => { 'data: {"type":"text-delta","id":"t1","delta":"Hello "}\n\n', 'data: {"type":"text-delta","id":"t1","delta":"world"}\n\n', 'data: {"type":"finish-step","finishReason":"stop","usage":{"inputTokens":5,"outputTokens":10}}\n\n', - ]) - const model = makeModel() - const result = await model.doGenerate(makeCallOptions()) - restore() - - expect(result.content).toHaveLength(1) - expect(result.content[0]).toMatchObject({ type: "text", text: "Hello world" }) - expect(result.finishReason.unified).toBe("stop") - expect(result.finishReason.raw).toBe("stop") - expect(result.usage.inputTokens.total).toBe(5) - expect(result.usage.outputTokens.total).toBe(10) -}) + ]); + const model = makeModel(); + const result = await model.doGenerate(makeCallOptions()); + restore(); + + expect(result.content).toHaveLength(1); + expect(result.content[0]).toMatchObject({ type: "text", text: "Hello world" }); + expect(result.finishReason.unified).toBe("stop"); + expect(result.finishReason.raw).toBe("stop"); + expect(result.usage.inputTokens.total).toBe(5); + expect(result.usage.outputTokens.total).toBe(10); +}); test("doGenerate includes reasoning before text", async () => { const { restore } = mockFetchStream([ @@ -190,15 +202,15 @@ test("doGenerate includes reasoning before text", async () => { 'data: {"type":"reasoning-delta","id":"r1","text":"think"}\n\n', 'data: {"type":"text-delta","id":"t1","delta":"answer"}\n\n', 'data: {"type":"finish-step","finishReason":"stop","usage":{"inputTokens":1,"outputTokens":1}}\n\n', - ]) - const model = makeModel() - const result = await model.doGenerate(makeCallOptions()) - restore() + ]); + const model = makeModel(); + const result = await model.doGenerate(makeCallOptions()); + restore(); - expect(result.content).toHaveLength(2) - expect(result.content[0]).toMatchObject({ type: "reasoning", text: "think" }) - expect(result.content[1]).toMatchObject({ type: "text", text: "answer" }) -}) + expect(result.content).toHaveLength(2); + expect(result.content[0]).toMatchObject({ type: "reasoning", text: "think" }); + expect(result.content[1]).toMatchObject({ type: "text", text: "answer" }); +}); test("doGenerate handles tool calls", async () => { const { restore } = mockFetchStream([ @@ -206,28 +218,36 @@ test("doGenerate handles tool calls", async () => { 'data: {"type":"text-delta","id":"t1","delta":"Let me run..."}\n\n', `data: ${JSON.stringify({ type: "tool-call", toolCallId: "tc1", toolName: "bash", input: { cmd: "ls" } })}\n\n`, 'data: {"type":"finish-step","finishReason":"tool_calls","usage":{"inputTokens":5,"outputTokens":3}}\n\n', - ]) - const model = makeModel() - const result = await model.doGenerate(makeCallOptions()) - restore() - - expect(result.content).toHaveLength(2) - expect(result.content[0]).toMatchObject({ type: "text", text: "Let me run..." }) - expect(result.content[1]).toMatchObject({ type: "tool-call", toolCallId: "tc1", toolName: "bash" }) - expect(result.finishReason.unified).toBe("tool-calls") -}) + ]); + const model = makeModel(); + const result = await model.doGenerate(makeCallOptions()); + restore(); + + expect(result.content).toHaveLength(2); + expect(result.content[0]).toMatchObject({ type: "text", text: "Let me run..." }); + expect(result.content[1]).toMatchObject({ + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + }); + expect(result.finishReason.unified).toBe("tool-calls"); +}); test("doStream includes model ID in error messages", async () => { - const { restore } = mockFetchError(500, "Server Error", JSON.stringify({ - error: { message: "Something broke" }, - })) - const model = makeModel() + const { restore } = mockFetchError( + 500, + "Server Error", + JSON.stringify({ + error: { message: "Something broke" }, + }), + ); + const model = makeModel(); try { - await model.doStream(makeCallOptions()) - expect.unreachable("Should have thrown") + await model.doStream(makeCallOptions()); + expect.unreachable("Should have thrown"); } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - expect(msg).toContain("[model=test-model]") + const msg = err instanceof Error ? err.message : String(err); + expect(msg).toContain("[model=test-model]"); } - restore() -}) + restore(); +}); diff --git a/tests/unit/plugin.test.ts b/tests/unit/plugin.test.ts index 031bcaf..76dea1c 100644 --- a/tests/unit/plugin.test.ts +++ b/tests/unit/plugin.test.ts @@ -1,32 +1,36 @@ -import { expect, test, beforeAll, afterAll } from "bun:test" -import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync } from "fs" -import { tmpdir } from "os" -import { dirname, join } from "path" -import { fileURLToPath } from "url" -import { writeCatalogCache, type ModelEntry } from "../../src/startup.ts" +import { expect, test, beforeAll, afterAll } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, renameSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { dirname, join } from "path"; +import { fileURLToPath } from "url"; +import { writeCatalogCache, type ModelEntry } from "../../src/startup.ts"; type PluginResult = { - config: (config: Record) => Promise + config: (config: Record) => Promise; auth: { - provider: string + provider: string; methods: Array<{ - type: string - label: string - authorize: (inputs: Record | undefined) => Promise<{ type: string; key?: string }> - }> - loader: (getAuth: () => Promise<{ type: string; key?: string } | null>) => Promise> - } -} - -type PluginModule = { default: () => Promise } - -let pluginFn: PluginModule["default"] -let testStateDir: string -let prevStateDir: string | undefined - -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..") -const modelsPath = join(repoRoot, "models.json") -const modelsBackup = join(repoRoot, "models.json.test-bak") + type: string; + label: string; + authorize: ( + inputs: Record | undefined, + ) => Promise<{ type: string; key?: string }>; + }>; + loader: ( + getAuth: () => Promise<{ type: string; key?: string } | null>, + ) => Promise>; + }; +}; + +type PluginModule = { default: () => Promise }; + +let pluginFn: PluginModule["default"]; +let testStateDir: string; +let prevStateDir: string | undefined; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); +const modelsPath = join(repoRoot, "models.json"); +const modelsBackup = join(repoRoot, "models.json.test-bak"); const cacheSample: ModelEntry[] = [ { @@ -38,257 +42,263 @@ const cacheSample: ModelEntry[] = [ cost: { input: 3, output: 15 }, limit: { context: 200000, output: 16000 }, }, -] +]; function hideBundledCatalog(): void { - if (existsSync(modelsPath)) renameSync(modelsPath, modelsBackup) + if (existsSync(modelsPath)) renameSync(modelsPath, modelsBackup); } function restoreBundledCatalog(): void { - if (existsSync(modelsBackup)) renameSync(modelsBackup, modelsPath) + if (existsSync(modelsBackup)) renameSync(modelsBackup, modelsPath); } beforeAll(async () => { - testStateDir = mkdtempSync(join(tmpdir(), "cc-plugin-state-")) - prevStateDir = process.env.COMMANDCODE_PROVIDER_STATE_DIR - process.env.COMMANDCODE_PROVIDER_STATE_DIR = testStateDir - const mod = await import("../../plugin.ts") - pluginFn = mod.default -}) + testStateDir = mkdtempSync(join(tmpdir(), "cc-plugin-state-")); + prevStateDir = process.env.COMMANDCODE_PROVIDER_STATE_DIR; + process.env.COMMANDCODE_PROVIDER_STATE_DIR = testStateDir; + const mod = await import("../../plugin.ts"); + pluginFn = mod.default; +}); afterAll(() => { - if (prevStateDir === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR - else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prevStateDir - rmSync(testStateDir, { recursive: true, force: true }) - restoreBundledCatalog() -}) + if (prevStateDir === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR; + else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prevStateDir; + rmSync(testStateDir, { recursive: true, force: true }); + restoreBundledCatalog(); +}); test("plugin returns correct provider name", async () => { - const plugin = await pluginFn() - expect(plugin.auth.provider).toBe("commandcode") -}) + const plugin = await pluginFn(); + expect(plugin.auth.provider).toBe("commandcode"); +}); test("authorize returns success with valid key", async () => { - const plugin = await pluginFn() - const result = await plugin.auth.methods[0].authorize({ key: "sk-valid-key" }) - expect(result.type).toBe("success") - expect((result as Record).key).toBe("sk-valid-key") -}) + const plugin = await pluginFn(); + const result = await plugin.auth.methods[0].authorize({ key: "sk-valid-key" }); + expect(result.type).toBe("success"); + expect((result as Record).key).toBe("sk-valid-key"); +}); test("authorize returns failed with empty key", async () => { - const plugin = await pluginFn() - const result = await plugin.auth.methods[0].authorize({ key: " " }) - expect(result.type).toBe("failed") -}) + const plugin = await pluginFn(); + const result = await plugin.auth.methods[0].authorize({ key: " " }); + expect(result.type).toBe("failed"); +}); test("authorize returns failed with undefined key", async () => { - const plugin = await pluginFn() - const result = await plugin.auth.methods[0].authorize({ key: undefined }) - expect(result.type).toBe("failed") -}) + const plugin = await pluginFn(); + const result = await plugin.auth.methods[0].authorize({ key: undefined }); + expect(result.type).toBe("failed"); +}); test("authorize returns failed with missing inputs", async () => { - const plugin = await pluginFn() - const result = await plugin.auth.methods[0].authorize(undefined) - expect(result.type).toBe("failed") -}) + const plugin = await pluginFn(); + const result = await plugin.auth.methods[0].authorize(undefined); + expect(result.type).toBe("failed"); +}); test("authorize handles non-string key", async () => { - const plugin = await pluginFn() - const result = await plugin.auth.methods[0].authorize({ key: 123 as unknown as string }) - expect(result.type).toBe("failed") -}) + const plugin = await pluginFn(); + const result = await plugin.auth.methods[0].authorize({ key: 123 as unknown as string }); + expect(result.type).toBe("failed"); +}); test("loader returns apiKey on successful auth", async () => { - const plugin = await pluginFn() + const plugin = await pluginFn(); const result = await plugin.auth.loader(async () => ({ type: "api", key: "sk-loaded-key", - })) - expect(result).toEqual({ apiKey: "sk-loaded-key" }) -}) + })); + expect(result).toEqual({ apiKey: "sk-loaded-key" }); +}); test("loader returns empty object on null auth", async () => { - const plugin = await pluginFn() - const result = await plugin.auth.loader(async () => null) - expect(result).toEqual({}) -}) + const plugin = await pluginFn(); + const result = await plugin.auth.loader(async () => null); + expect(result).toEqual({}); +}); test("loader returns empty object on wrong auth type", async () => { - const plugin = await pluginFn() - const result = await plugin.auth.loader(async () => ({ - type: "oauth", - key: "some-token", - } as Record)) - expect(result).toEqual({}) -}) + const plugin = await pluginFn(); + const result = await plugin.auth.loader( + async () => + ({ + type: "oauth", + key: "some-token", + }) as Record, + ); + expect(result).toEqual({}); +}); test("loader returns empty object when getAuth throws", async () => { - const plugin = await pluginFn() + const plugin = await pluginFn(); const result = await plugin.auth.loader(async () => { - throw new Error("auth failed") - }) - expect(result).toEqual({}) -}) + throw new Error("auth failed"); + }); + expect(result).toEqual({}); +}); test("config hook registers provider with npm and models", async () => { - const plugin = await pluginFn() + const plugin = await pluginFn(); const config: Record = { provider: { commandcode: {} }, - } - await plugin.config(config) - - const cc = (config.provider as Record>).commandcode - expect(cc.npm).toBe("commandcode-go-opencode-provider") - expect(cc.name).toBe("Command Code") - expect(cc.env).toEqual(["COMMANDCODE_API_KEY"]) - expect(cc.models).toBeDefined() - const models = cc.models as Record - expect(Object.keys(models).length).toBeGreaterThan(0) -}) + }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect(cc.npm).toBe("commandcode-go-opencode-provider"); + expect(cc.name).toBe("Command Code"); + expect(cc.env).toEqual(["COMMANDCODE_API_KEY"]); + expect(cc.models).toBeDefined(); + const models = cc.models as Record; + expect(Object.keys(models).length).toBeGreaterThan(0); +}); test("config hook does not overwrite existing npm field", async () => { - const plugin = await pluginFn() + const plugin = await pluginFn(); const config: Record = { provider: { commandcode: { npm: "custom-package" } }, - } - await plugin.config(config) + }; + await plugin.config(config); - const cc = (config.provider as Record>).commandcode - expect(cc.npm).toBe("custom-package") -}) + const cc = (config.provider as Record>).commandcode; + expect(cc.npm).toBe("custom-package"); +}); test("config hook does not overwrite existing models", async () => { - const plugin = await pluginFn() + const plugin = await pluginFn(); const config: Record = { provider: { commandcode: { models: { "my-model": { id: "my-model" } } } }, - } - await plugin.config(config) + }; + await plugin.config(config); - const cc = (config.provider as Record>).commandcode - const models = cc.models as Record - expect(Object.keys(models)).toEqual(["my-model"]) -}) + const cc = (config.provider as Record>).commandcode; + const models = cc.models as Record; + expect(Object.keys(models)).toEqual(["my-model"]); +}); test("config hook creates provider block if missing", async () => { - const plugin = await pluginFn() - const config: Record = {} - await plugin.config(config) - - expect(config.provider).toBeDefined() - const cc = (config.provider as Record>).commandcode - expect(cc).toBeDefined() - expect(cc.npm).toBe("commandcode-go-opencode-provider") -}) - -test("startup summary uses bundled manifest version and degraded status", async () => { - const plugin = await pluginFn() - const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) - const summary = JSON.parse(readFileSync(join(testStateDir, "startup.json"), "utf-8")) - expect(summary.catalogSource).toBe("bundled") - expect(summary.commandCodeVersion).toBe("1.38.1") - expect(summary.degraded).toBe(true) - expect(summary.modelCount).toBeGreaterThan(20) -}) + const plugin = await pluginFn(); + const config: Record = {}; + await plugin.config(config); + + expect(config.provider).toBeDefined(); + const cc = (config.provider as Record>).commandcode; + expect(cc).toBeDefined(); + expect(cc.npm).toBe("commandcode-go-opencode-provider"); +}); + +test("startup summary uses bundled manifest version and status", async () => { + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + const summary = JSON.parse(readFileSync(join(testStateDir, "startup.json"), "utf-8")); + const manifest = JSON.parse(readFileSync(join(repoRoot, "manifest.json"), "utf-8")) as { + status: string; + }; + expect(summary.catalogSource).toBe("bundled"); + expect(summary.commandCodeVersion).toBe("1.38.1"); + expect(summary.degraded).toBe(manifest.status === "degraded" || manifest.status === "broken"); + expect(summary.modelCount).toBeGreaterThan(20); +}); test("config does not write to stdout or stderr by default", async () => { - const logs: unknown[][] = [] - const warns: unknown[][] = [] - const origLog = console.log - const origWarn = console.warn + const logs: unknown[][] = []; + const warns: unknown[][] = []; + const origLog = console.log; + const origWarn = console.warn; console.log = (...args: unknown[]) => { - logs.push(args) - } + logs.push(args); + }; console.warn = (...args: unknown[]) => { - warns.push(args) - } + warns.push(args); + }; try { - const plugin = await pluginFn() - const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) - expect(logs).toEqual([]) - expect(warns).toEqual([]) - const cc = (config.provider as Record>).commandcode - expect(cc.models).toBeDefined() - expect(Object.keys(cc.models as object).length).toBeGreaterThan(0) + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + expect(logs).toEqual([]); + expect(warns).toEqual([]); + const cc = (config.provider as Record>).commandcode; + expect(cc.models).toBeDefined(); + expect(Object.keys(cc.models as object).length).toBeGreaterThan(0); } finally { - console.log = origLog - console.warn = origWarn + console.log = origLog; + console.warn = origWarn; } -}) +}); test("config hook attaches reasoning effort variants when available", async () => { - const plugin = await pluginFn() + const plugin = await pluginFn(); const config: Record = { provider: { commandcode: {} }, - } - await plugin.config(config) + }; + await plugin.config(config); - const cc = (config.provider as Record>).commandcode - const models = cc.models as Record> + const cc = (config.provider as Record>).commandcode; + const models = cc.models as Record>; const withVariants = Object.values(models).filter( (m) => m.variants && typeof m.variants === "object", - ) + ); // Bundled models.json or local command-code should expose at least one effort list - expect(withVariants.length).toBeGreaterThan(0) - const sample = withVariants[0] - const variants = sample.variants as Record - const keys = Object.keys(variants) - expect(keys.length).toBeGreaterThan(0) - expect(variants[keys[0]].reasoningEffort).toBe(keys[0]) -}) + expect(withVariants.length).toBeGreaterThan(0); + const sample = withVariants[0]; + const variants = sample.variants as Record; + const keys = Object.keys(variants); + expect(keys.length).toBeGreaterThan(0); + expect(variants[keys[0]].reasoningEffort).toBe(keys[0]); +}); test("CA-04: registers npm/env with empty models when bundled and cache miss", async () => { - const emptyDir = mkdtempSync(join(tmpdir(), "cc-ca04-")) - const prev = process.env.COMMANDCODE_PROVIDER_STATE_DIR - process.env.COMMANDCODE_PROVIDER_STATE_DIR = emptyDir - hideBundledCatalog() + const emptyDir = mkdtempSync(join(tmpdir(), "cc-ca04-")); + const prev = process.env.COMMANDCODE_PROVIDER_STATE_DIR; + process.env.COMMANDCODE_PROVIDER_STATE_DIR = emptyDir; + hideBundledCatalog(); try { - const plugin = await pluginFn() - const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) - - const cc = (config.provider as Record>).commandcode - expect(cc.npm).toBe("commandcode-go-opencode-provider") - expect(cc.name).toBe("Command Code") - expect(cc.env).toEqual(["COMMANDCODE_API_KEY"]) - expect(cc.models).toEqual({}) - - const summary = JSON.parse(readFileSync(join(emptyDir, "startup.json"), "utf-8")) - expect(summary.degraded).toBe(true) - expect(summary.modelCount).toBe(0) + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect(cc.npm).toBe("commandcode-go-opencode-provider"); + expect(cc.name).toBe("Command Code"); + expect(cc.env).toEqual(["COMMANDCODE_API_KEY"]); + expect(cc.models).toEqual({}); + + const summary = JSON.parse(readFileSync(join(emptyDir, "startup.json"), "utf-8")); + expect(summary.degraded).toBe(true); + expect(summary.modelCount).toBe(0); } finally { - restoreBundledCatalog() - if (prev === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR - else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prev - rmSync(emptyDir, { recursive: true, force: true }) + restoreBundledCatalog(); + if (prev === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR; + else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prev; + rmSync(emptyDir, { recursive: true, force: true }); } -}) +}); test("CA-05: falls back to last-good cache when bundled catalog unreadable", async () => { - const cacheDir = mkdtempSync(join(tmpdir(), "cc-ca05-")) - writeCatalogCache(cacheDir, cacheSample) - const prev = process.env.COMMANDCODE_PROVIDER_STATE_DIR - process.env.COMMANDCODE_PROVIDER_STATE_DIR = cacheDir - hideBundledCatalog() + const cacheDir = mkdtempSync(join(tmpdir(), "cc-ca05-")); + writeCatalogCache(cacheDir, cacheSample); + const prev = process.env.COMMANDCODE_PROVIDER_STATE_DIR; + process.env.COMMANDCODE_PROVIDER_STATE_DIR = cacheDir; + hideBundledCatalog(); try { - const plugin = await pluginFn() - const config: Record = { provider: { commandcode: {} } } - await plugin.config(config) - - const cc = (config.provider as Record>).commandcode - expect(Object.keys(cc.models as object).length).toBeGreaterThan(0) - - const summary = JSON.parse(readFileSync(join(cacheDir, "startup.json"), "utf-8")) - expect(summary.catalogSource).toBe("cache") - expect(summary.degraded).toBe(true) - expect(summary.degradedReason).toContain("last-good cache") - expect(summary.modelCount).toBe(1) + const plugin = await pluginFn(); + const config: Record = { provider: { commandcode: {} } }; + await plugin.config(config); + + const cc = (config.provider as Record>).commandcode; + expect(Object.keys(cc.models as object).length).toBeGreaterThan(0); + + const summary = JSON.parse(readFileSync(join(cacheDir, "startup.json"), "utf-8")); + expect(summary.catalogSource).toBe("cache"); + expect(summary.degraded).toBe(true); + expect(summary.degradedReason).toContain("last-good cache"); + expect(summary.modelCount).toBe(1); } finally { - restoreBundledCatalog() - if (prev === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR - else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prev - rmSync(cacheDir, { recursive: true, force: true }) + restoreBundledCatalog(); + if (prev === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR; + else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prev; + rmSync(cacheDir, { recursive: true, force: true }); } -}) +}); diff --git a/tests/unit/publish-policy.test.ts b/tests/unit/publish-policy.test.ts index 11a65a9..5a669a6 100644 --- a/tests/unit/publish-policy.test.ts +++ b/tests/unit/publish-policy.test.ts @@ -1,5 +1,5 @@ -import { expect, test, describe } from "bun:test" -import { decideCatalogSync, decidePublish } from "../../src/publish-policy.ts" +import { expect, test, describe } from "bun:test"; +import { decideCatalogSync, decidePublish } from "../../src/publish-policy.ts"; describe("decideCatalogSync", () => { test("extracts when force is true even if versions match", () => { @@ -11,8 +11,8 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: ["0.5.0"], }), - ).toEqual({ extract: true, publishRetry: false, exit: false }) - }) + ).toEqual({ extract: true, publishRetry: false, exit: false }); + }); test("extracts when command-code latest differs from the bundle", () => { expect( @@ -23,8 +23,8 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: ["0.5.0"], }), - ).toEqual({ extract: true, publishRetry: false, exit: false }) - }) + ).toEqual({ extract: true, publishRetry: false, exit: false }); + }); test("skips extraction and retries publish when the plugin version is unpublished", () => { expect( @@ -35,8 +35,8 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: [], }), - ).toEqual({ extract: false, publishRetry: true, exit: false }) - }) + ).toEqual({ extract: false, publishRetry: true, exit: false }); + }); test("exits when command-code is unchanged and the plugin version is already on npm", () => { expect( @@ -47,9 +47,9 @@ describe("decideCatalogSync", () => { pluginVersion: "0.5.0", publishedPluginVersions: ["0.5.0"], }), - ).toEqual({ extract: false, publishRetry: false, exit: true }) - }) -}) + ).toEqual({ extract: false, publishRetry: false, exit: true }); + }); +}); describe("decidePublish", () => { test("publishes when the version is unpublished and a token is set", () => { @@ -59,8 +59,8 @@ describe("decidePublish", () => { publishedPluginVersions: [], npmTokenSet: true, }), - ).toBe("publish") - }) + ).toBe("publish"); + }); test("skips when the token is missing", () => { expect( @@ -69,8 +69,8 @@ describe("decidePublish", () => { publishedPluginVersions: [], npmTokenSet: false, }), - ).toBe("skip-no-token") - }) + ).toBe("skip-no-token"); + }); test("skips when the version is already on npm", () => { expect( @@ -79,6 +79,6 @@ describe("decidePublish", () => { publishedPluginVersions: ["0.5.0"], npmTokenSet: true, }), - ).toBe("skip-already-published") - }) -}) + ).toBe("skip-already-published"); + }); +}); diff --git a/tests/unit/release-candidate.test.ts b/tests/unit/release-candidate.test.ts index 304b798..91a2959 100644 --- a/tests/unit/release-candidate.test.ts +++ b/tests/unit/release-candidate.test.ts @@ -1,14 +1,14 @@ -import { expect, test } from "bun:test" -import { spawnSync } from "node:child_process" -import { join } from "node:path" +import { expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; -const ROOT = join(import.meta.dir, "../..") +const ROOT = join(import.meta.dir, "../.."); test("verify:release-candidate packs without publishing", () => { const result = spawnSync("bun", ["run", "verify:release-candidate"], { cwd: ROOT, encoding: "utf-8", - }) - expect(result.status, result.stderr + result.stdout).toBe(0) - expect(result.stdout).toContain("verified") -}) + }); + expect(result.status, result.stderr + result.stdout).toBe(0); + expect(result.stdout).toContain("verified"); +}); diff --git a/tests/unit/release-workflow.test.ts b/tests/unit/release-workflow.test.ts index 867203a..ebad1c8 100644 --- a/tests/unit/release-workflow.test.ts +++ b/tests/unit/release-workflow.test.ts @@ -1,128 +1,151 @@ -import { expect, test, describe } from "bun:test" -import { readFileSync } from "fs" -import { join } from "path" +import { expect, test, describe } from "bun:test"; +import { readFileSync } from "fs"; +import { join } from "path"; -const ROOT = join(import.meta.dir, "../..") -const read = (rel: string) => readFileSync(join(ROOT, rel), "utf-8") -const json = (rel: string) => JSON.parse(read(rel)) as T +const ROOT = join(import.meta.dir, "../.."); +const read = (rel: string) => readFileSync(join(ROOT, rel), "utf-8"); +const json = (rel: string) => JSON.parse(read(rel)) as T; -const NPMJS = "${{ secrets.NPMJS }}" +const NPMJS = "${{ secrets.NPMJS }}"; describe("package.json publish identity", () => { test("is the scoped public package on BrainerVirus/opencode-commandcode-provider", () => { const pkg = json<{ - name: string - publishConfig?: { access?: string } - repository?: { url?: string } - bugs?: { url?: string } - homepage?: string - files?: string[] - scripts?: Record - }>("package.json") - expect(pkg.name).toBe("@brainervirus/commandcode-go-opencode-provider") - expect(pkg.publishConfig?.access).toBe("public") - expect(pkg.repository?.url).toContain("BrainerVirus/opencode-commandcode-provider") - expect(pkg.bugs?.url).toContain("BrainerVirus/opencode-commandcode-provider") - expect(pkg.homepage).toContain("BrainerVirus/opencode-commandcode-provider") - expect(pkg.files).toContain("manifest.json") - expect(pkg.scripts?.["verify:release-candidate"]).toContain("verify-release-candidate.ts") - }) -}) + name: string; + publishConfig?: { access?: string }; + repository?: { url?: string }; + bugs?: { url?: string }; + homepage?: string; + files?: string[]; + scripts?: Record; + }>("package.json"); + expect(pkg.name).toBe("@brainervirus/commandcode-go-opencode-provider"); + expect(pkg.publishConfig?.access).toBe("public"); + expect(pkg.repository?.url).toContain("BrainerVirus/opencode-commandcode-provider"); + expect(pkg.bugs?.url).toContain("BrainerVirus/opencode-commandcode-provider"); + expect(pkg.homepage).toContain("BrainerVirus/opencode-commandcode-provider"); + expect(pkg.files).toContain("manifest.json"); + expect(pkg.scripts?.["verify:release-candidate"]).toContain("verify-release-candidate.ts"); + expect(pkg.scripts?.lint).toContain("oxlint --deny-warnings"); + expect(pkg.scripts?.format).toContain("oxfmt"); + expect(pkg.scripts?.["format:check"]).toContain("oxfmt --check"); + expect(pkg.scripts?.check).toContain("format:check"); + }); +}); describe("ci.yml", () => { test("exposes named check jobs on pull requests to main", () => { const wf = Bun.YAML.parse(read(".github/workflows/ci.yml")) as { - on: { pull_request?: { branches?: string[] }; push?: { branches?: string[] } } - jobs: Record }> - } - expect(wf.on.pull_request?.branches).toContain("main") - expect(wf.on.push?.branches).toContain("main") - expect(wf.jobs.test.name).toBe("check (test)") - expect(wf.jobs.typecheck.name).toBe("check (typecheck)") - expect(wf.jobs.pack.name).toBe("check (pack)") - const blob = Object.values(wf.jobs).flatMap((j) => j.steps.map((s) => s.run ?? "")).join("\n") - expect(blob).toContain("bun test tests/unit/") - expect(blob).toContain("bun run typecheck") - expect(blob).toContain("verify:release-candidate") - expect(blob).not.toMatch(/\bnpm publish\b/) - expect(blob).not.toContain("semantic-release") - }) -}) + on: { pull_request?: { branches?: string[] }; push?: { branches?: string[] } }; + jobs: Record }>; + }; + expect(wf.on.pull_request?.branches).toContain("main"); + expect(wf.on.push?.branches).toContain("main"); + expect(wf.jobs.test.name).toBe("check (test)"); + expect(wf.jobs.typecheck.name).toBe("check (typecheck)"); + expect(wf.jobs.pack.name).toBe("check (pack)"); + expect(wf.jobs.lint.name).toBe("check (lint)"); + expect(wf.jobs.format.name).toBe("check (format)"); + const blob = Object.values(wf.jobs) + .flatMap((j) => j.steps.map((s) => s.run ?? "")) + .join("\n"); + expect(blob).toContain("bun test tests/unit/"); + expect(blob).toContain("bun run typecheck"); + expect(blob).toContain("verify:release-candidate"); + expect(blob).toContain("bun run lint"); + expect(blob).toContain("bun run format:check"); + expect(blob).not.toMatch(/\bnpm publish\b/); + expect(blob).not.toContain("semantic-release"); + }); +}); describe("release.yml", () => { test("runs semantic-release on main with the workit npm token mapping", () => { const wf = Bun.YAML.parse(read(".github/workflows/release.yml")) as { - on: { push?: { branches?: string[] } } + on: { push?: { branches?: string[] } }; jobs: { release: { - name?: string + name?: string; steps: Array<{ - name?: string - uses?: string - with?: Record - run?: string - env?: Record - }> - } - } - } - expect(wf.on.push?.branches).toContain("main") - expect(wf.jobs.release.name).toBe("semantic-release") - const setupNode = wf.jobs.release.steps.find((s) => s.uses?.startsWith("actions/setup-node")) - expect(setupNode?.with?.["registry-url"]).toBe("https://registry.npmjs.org") - const names = wf.jobs.release.steps.map((s) => s.name ?? "") - expect(names.indexOf("Verify release candidate")).toBeGreaterThan(-1) - expect(names.indexOf("Release")).toBeGreaterThan(names.indexOf("Verify release candidate")) - expect(names.indexOf("Sync release manifests to main")).toBeGreaterThan(names.indexOf("Release")) - const release = wf.jobs.release.steps.find((s) => s.name === "Release") - expect(release?.run).toContain("npx semantic-release") - expect(release?.env?.NPM_TOKEN).toBe(NPMJS) - expect(release?.env?.NODE_AUTH_TOKEN).toBe(NPMJS) - const blob = wf.jobs.release.steps.map((s) => s.run ?? "").join("\n") - expect(blob).not.toContain("publish-if-needed") - }) -}) + name?: string; + uses?: string; + with?: Record; + run?: string; + env?: Record; + }>; + }; + }; + }; + expect(wf.on.push?.branches).toContain("main"); + expect(wf.jobs.release.name).toBe("semantic-release"); + const setupNode = wf.jobs.release.steps.find((s) => s.uses?.startsWith("actions/setup-node")); + expect(setupNode?.with?.["registry-url"]).toBe("https://registry.npmjs.org"); + const names = wf.jobs.release.steps.map((s) => s.name ?? ""); + expect(names.indexOf("Verify release candidate")).toBeGreaterThan(-1); + expect(names.indexOf("Release")).toBeGreaterThan(names.indexOf("Verify release candidate")); + expect(names.indexOf("Sync release manifests to main")).toBeGreaterThan( + names.indexOf("Release"), + ); + const release = wf.jobs.release.steps.find((s) => s.name === "Release"); + expect(release?.run).toContain("npx semantic-release"); + expect(release?.env?.NPM_TOKEN).toBe(NPMJS); + expect(release?.env?.NODE_AUTH_TOKEN).toBe(NPMJS); + const blob = wf.jobs.release.steps.map((s) => s.run ?? "").join("\n"); + expect(blob).not.toContain("publish-if-needed"); + }); +}); describe("catalog-sync.yml", () => { test("opens a catalog PR and does not publish", () => { const wf = Bun.YAML.parse(read(".github/workflows/catalog-sync.yml")) as { - on: { schedule?: Array<{ cron: string }>; workflow_dispatch?: { inputs?: { force?: unknown } } } + on: { + schedule?: Array<{ cron: string }>; + workflow_dispatch?: { inputs?: { force?: unknown } }; + }; jobs: { sync: { - name?: string - steps: Array<{ name?: string; run?: string; env?: Record }> - } - } - } - expect(wf.on.schedule?.[0]?.cron).toBe("0 */6 * * *") - expect(wf.on.workflow_dispatch?.inputs?.force).toBeDefined() - expect(wf.jobs.sync.name).toBe("catalog-pr") - const blob = wf.jobs.sync.steps.map((s) => `${s.run ?? ""}\n${JSON.stringify(s.env ?? {})}`).join("\n") - expect(blob).toContain("catalog-sync-ci.ts") - expect(blob).not.toMatch(/\bnpm publish\b/) - expect(blob).not.toContain("semantic-release") - expect(blob).not.toContain("publish-if-needed") - }) -}) + name?: string; + steps: Array<{ name?: string; run?: string; env?: Record }>; + }; + }; + }; + expect(wf.on.schedule?.[0]?.cron).toBe("0 */6 * * *"); + expect(wf.on.workflow_dispatch?.inputs?.force).toBeDefined(); + expect(wf.jobs.sync.name).toBe("catalog-pr"); + const blob = wf.jobs.sync.steps + .map((s) => `${s.run ?? ""}\n${JSON.stringify(s.env ?? {})}`) + .join("\n"); + expect(blob).toContain("catalog-sync-ci.ts"); + expect(blob).not.toMatch(/\bnpm publish\b/); + expect(blob).not.toContain("semantic-release"); + expect(blob).not.toContain("publish-if-needed"); + }); +}); describe("release.config.cjs", () => { test("publishes the root package then GitHub Release", () => { - const cfg = read("release.config.cjs") - expect(cfg).toContain("@semantic-release/commit-analyzer") - expect(cfg).toContain("./scripts/semantic-release-catalog-notes.cjs") - expect(cfg).toContain("@semantic-release/npm") - expect(cfg).toContain("@semantic-release/github") + const cfg = read("release.config.cjs"); + expect(cfg).toContain("@semantic-release/commit-analyzer"); + expect(cfg).toContain("./scripts/semantic-release-catalog-notes.cjs"); + expect(cfg).toContain("@semantic-release/npm"); + expect(cfg).toContain("@semantic-release/github"); expect(cfg.indexOf("semantic-release-catalog-notes.cjs")).toBeLessThan( cfg.indexOf("@semantic-release/release-notes-generator"), - ) - expect(cfg.indexOf("@semantic-release/npm")).toBeLessThan(cfg.indexOf("@semantic-release/github")) - }) -}) + ); + expect(cfg.indexOf("@semantic-release/npm")).toBeLessThan( + cfg.indexOf("@semantic-release/github"), + ); + }); +}); describe("verify-release-candidate.ts", () => { test("is pack-only", () => { - const src = read("scripts/verify-release-candidate.ts").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "") - expect(src).toContain("npm pack") - expect(src).not.toMatch(/\b(?:npm|npx|bun)\s+(?:publish|login|adduser)\b|\bgit\s+(?:push|tag)\b/) - }) -}) + const src = read("scripts/verify-release-candidate.ts") + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); + expect(src).toContain("npm pack"); + expect(src).not.toMatch( + /\b(?:npm|npx|bun)\s+(?:publish|login|adduser)\b|\bgit\s+(?:push|tag)\b/, + ); + }); +}); diff --git a/tests/unit/startup.test.ts b/tests/unit/startup.test.ts index ea388f9..3c61b06 100644 --- a/tests/unit/startup.test.ts +++ b/tests/unit/startup.test.ts @@ -1,14 +1,14 @@ -import { expect, test, describe } from "bun:test" -import { mkdtempSync, readFileSync, rmSync } from "fs" -import { tmpdir } from "os" -import { join } from "path" +import { expect, test, describe } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; import { pluginStateDir, readCatalogCache, writeCatalogCache, writeStartupSummary, type ModelEntry, -} from "../../src/startup.ts" +} from "../../src/startup.ts"; const sample: ModelEntry[] = [ { @@ -20,39 +20,39 @@ const sample: ModelEntry[] = [ cost: { input: 3, output: 15 }, limit: { context: 200000, output: 16000 }, }, -] +]; describe("pluginStateDir", () => { test("honors COMMANDCODE_PROVIDER_STATE_DIR when set", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-state-")) - const prev = process.env.COMMANDCODE_PROVIDER_STATE_DIR - process.env.COMMANDCODE_PROVIDER_STATE_DIR = dir + const dir = mkdtempSync(join(tmpdir(), "cc-state-")); + const prev = process.env.COMMANDCODE_PROVIDER_STATE_DIR; + process.env.COMMANDCODE_PROVIDER_STATE_DIR = dir; try { - expect(pluginStateDir()).toBe(dir) + expect(pluginStateDir()).toBe(dir); } finally { - if (prev === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR - else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prev - rmSync(dir, { recursive: true, force: true }) + if (prev === undefined) delete process.env.COMMANDCODE_PROVIDER_STATE_DIR; + else process.env.COMMANDCODE_PROVIDER_STATE_DIR = prev; + rmSync(dir, { recursive: true, force: true }); } - }) -}) + }); +}); describe("catalog cache", () => { test("round-trips models and returns null for missing file", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-cache-")) + const dir = mkdtempSync(join(tmpdir(), "cc-cache-")); try { - expect(readCatalogCache(dir)).toBeNull() - writeCatalogCache(dir, sample) - expect(readCatalogCache(dir)).toEqual(sample) + expect(readCatalogCache(dir)).toBeNull(); + writeCatalogCache(dir, sample); + expect(readCatalogCache(dir)).toEqual(sample); } finally { - rmSync(dir, { recursive: true, force: true }) + rmSync(dir, { recursive: true, force: true }); } - }) -}) + }); +}); describe("startup summary", () => { test("writes startup.json", () => { - const dir = mkdtempSync(join(tmpdir(), "cc-sum-")) + const dir = mkdtempSync(join(tmpdir(), "cc-sum-")); try { writeStartupSummary(dir, { catalogSource: "bundled", @@ -61,12 +61,12 @@ describe("startup summary", () => { reasoningModelCount: 1, degraded: false, degradedReason: null, - }) - const parsed = JSON.parse(readFileSync(join(dir, "startup.json"), "utf-8")) - expect(parsed.catalogSource).toBe("bundled") - expect(parsed.modelCount).toBe(1) + }); + const parsed = JSON.parse(readFileSync(join(dir, "startup.json"), "utf-8")); + expect(parsed.catalogSource).toBe("bundled"); + expect(parsed.modelCount).toBe(1); } finally { - rmSync(dir, { recursive: true, force: true }) + rmSync(dir, { recursive: true, force: true }); } - }) -}) + }); +}); diff --git a/tests/unit/stream.test.ts b/tests/unit/stream.test.ts index ea36f73..a3c4d28 100644 --- a/tests/unit/stream.test.ts +++ b/tests/unit/stream.test.ts @@ -1,76 +1,82 @@ -import { expect, test } from "bun:test" -import { parseStreamEvents } from "../../src/stream.js" -import { sseEvent } from "../helpers/mocks.js" +import { expect, test } from "bun:test"; +import { parseStreamEvents } from "../../src/stream.js"; +import { sseEvent } from "../helpers/mocks.js"; async function collectStream(stream: ReadableStream): Promise { - const reader = stream.getReader() - const parts: unknown[] = [] + const reader = stream.getReader(); + const parts: unknown[] = []; try { while (true) { - const { done, value } = await reader.read() - if (done) break - parts.push(value) + const { done, value } = await reader.read(); + if (done) break; + parts.push(value); } } finally { - reader.releaseLock() + reader.releaseLock(); } - return parts + return parts; } function streamFromChunks(chunks: string[]): ReadableStream { - const encoder = new TextEncoder() - let i = 0 + const encoder = new TextEncoder(); + let i = 0; return new ReadableStream({ pull(controller) { if (i >= chunks.length) { - controller.close() - return + controller.close(); + return; } - controller.enqueue(encoder.encode(chunks[i])) - i++ + controller.enqueue(encoder.encode(chunks[i])); + i++; }, - }) + }); } test("parses text-delta event", async () => { - const body = streamFromChunks([sseEvent({ type: "text-delta", id: "t1", delta: "hello" })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(1) - expect(parts[0]).toMatchObject({ type: "text-delta", id: "t1", delta: "hello" }) -}) + const body = streamFromChunks([sseEvent({ type: "text-delta", id: "t1", delta: "hello" })]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(1); + expect(parts[0]).toMatchObject({ type: "text-delta", id: "t1", delta: "hello" }); +}); test("parses reasoning-delta event", async () => { - const body = streamFromChunks([sseEvent({ type: "reasoning-delta", id: "r1", text: "thinking..." })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts[0]).toMatchObject({ type: "reasoning-delta", id: "r1", delta: "thinking..." }) -}) + const body = streamFromChunks([ + sseEvent({ type: "reasoning-delta", id: "r1", text: "thinking..." }), + ]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts[0]).toMatchObject({ type: "reasoning-delta", id: "r1", delta: "thinking..." }); +}); test("parses tool-call event", async () => { - const body = streamFromChunks([sseEvent({ - type: "tool-call", - toolCallId: "tc1", - toolName: "bash", - input: { cmd: "ls" }, - })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) + const body = streamFromChunks([ + sseEvent({ + type: "tool-call", + toolCallId: "tc1", + toolName: "bash", + input: { cmd: "ls" }, + }), + ]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); expect(parts[0]).toMatchObject({ type: "tool-call", toolCallId: "tc1", toolName: "bash", - }) -}) + }); +}); test("parses finish-step with usage", async () => { - const body = streamFromChunks([sseEvent({ - type: "finish-step", - finishReason: "stop", - usage: { inputTokens: 10, outputTokens: 20 }, - })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) + const body = streamFromChunks([ + sseEvent({ + type: "finish-step", + finishReason: "stop", + usage: { inputTokens: 10, outputTokens: 20 }, + }), + ]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); expect(parts[0]).toMatchObject({ type: "finish", finishReason: { unified: "stop", raw: "stop" }, @@ -78,8 +84,8 @@ test("parses finish-step with usage", async () => { inputTokens: { total: 10 }, outputTokens: { total: 20 }, }, - }) -}) + }); +}); test("maps finish reasons correctly", async () => { const cases: Array<{ raw: string; expected: string }> = [ @@ -93,131 +99,137 @@ test("maps finish reasons correctly", async () => { { raw: "max_output_tokens", expected: "length" }, { raw: "content_filter", expected: "content-filter" }, { raw: "unknown_reason", expected: "other" }, - ] + ]; for (const { raw, expected } of cases) { - const body = streamFromChunks([sseEvent({ type: "finish-step", finishReason: raw })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect((parts[0] as any).finishReason.unified).toBe(expected) + const body = streamFromChunks([sseEvent({ type: "finish-step", finishReason: raw })]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect((parts[0] as any).finishReason.unified).toBe(expected); } -}) +}); test("maps usage with camelCase fields", async () => { - const body = streamFromChunks([sseEvent({ - type: "finish-step", - finishReason: "stop", - usage: { - inputTokens: 15, - outputTokens: 25, - inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 10 }, - outputTokenDetails: { textTokens: 20, reasoningTokens: 5 }, - }, - })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - const usage = (parts[0] as any).usage - expect(usage.inputTokens.total).toBe(15) - expect(usage.inputTokens.noCache).toBe(5) - expect(usage.inputTokens.cacheRead).toBe(10) - expect(usage.outputTokens.total).toBe(25) - expect(usage.outputTokens.text).toBe(20) - expect(usage.outputTokens.reasoning).toBe(5) -}) + const body = streamFromChunks([ + sseEvent({ + type: "finish-step", + finishReason: "stop", + usage: { + inputTokens: 15, + outputTokens: 25, + inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 10 }, + outputTokenDetails: { textTokens: 20, reasoningTokens: 5 }, + }, + }), + ]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + const usage = (parts[0] as any).usage; + expect(usage.inputTokens.total).toBe(15); + expect(usage.inputTokens.noCache).toBe(5); + expect(usage.inputTokens.cacheRead).toBe(10); + expect(usage.outputTokens.total).toBe(25); + expect(usage.outputTokens.text).toBe(20); + expect(usage.outputTokens.reasoning).toBe(5); +}); test("maps usage with snake_case fields", async () => { - const body = streamFromChunks([sseEvent({ - type: "finish-step", - finishReason: "stop", - usage: { - prompt_tokens: 15, - completion_tokens: 25, - input_token_details: { noCacheTokens: 5, cacheReadTokens: 10 }, - output_token_details: { textTokens: 20, reasoningTokens: 5 }, - }, - })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - const usage = (parts[0] as any).usage - expect(usage.inputTokens.total).toBe(15) - expect(usage.outputTokens.total).toBe(25) - expect(usage.inputTokens.noCache).toBe(5) - expect(usage.inputTokens.cacheRead).toBe(10) -}) + const body = streamFromChunks([ + sseEvent({ + type: "finish-step", + finishReason: "stop", + usage: { + prompt_tokens: 15, + completion_tokens: 25, + input_token_details: { noCacheTokens: 5, cacheReadTokens: 10 }, + output_token_details: { textTokens: 20, reasoningTokens: 5 }, + }, + }), + ]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + const usage = (parts[0] as any).usage; + expect(usage.inputTokens.total).toBe(15); + expect(usage.outputTokens.total).toBe(25); + expect(usage.inputTokens.noCache).toBe(5); + expect(usage.inputTokens.cacheRead).toBe(10); +}); test("skips comments (lines starting with :)", async () => { - const body = streamFromChunks([":comment\n\ndata: {\"type\":\"start\"}\n\n"]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(1) - expect((parts[0] as any).type).toBe("stream-start") -}) + const body = streamFromChunks([':comment\n\ndata: {"type":"start"}\n\n']); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(1); + expect((parts[0] as any).type).toBe("stream-start"); +}); test("skips [DONE] lines", async () => { - const body = streamFromChunks(["data: {\"type\":\"start\"}\n\ndata: [DONE]\n\n"]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(1) -}) + const body = streamFromChunks(['data: {"type":"start"}\n\ndata: [DONE]\n\n']); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(1); +}); test("handles error events", async () => { - const body = streamFromChunks([sseEvent({ type: "error", error: "Something broke" })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts[0]).toMatchObject({ type: "error", error: "Something broke" }) -}) + const body = streamFromChunks([sseEvent({ type: "error", error: "Something broke" })]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts[0]).toMatchObject({ type: "error", error: "Something broke" }); +}); test("handles response-metadata event", async () => { - const body = streamFromChunks([sseEvent({ type: "response-metadata", id: "req-1", modelId: "model-v1" })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts[0]).toMatchObject({ type: "response-metadata", id: "req-1", modelId: "model-v1" }) -}) + const body = streamFromChunks([ + sseEvent({ type: "response-metadata", id: "req-1", modelId: "model-v1" }), + ]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts[0]).toMatchObject({ type: "response-metadata", id: "req-1", modelId: "model-v1" }); +}); test("handles multiple events in one chunk", async () => { const body = streamFromChunks([ sseEvent({ type: "text-delta", id: "t1", delta: "a" }) + - sseEvent({ type: "text-delta", id: "t1", delta: "b" }), - ]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(2) -}) + sseEvent({ type: "text-delta", id: "t1", delta: "b" }), + ]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(2); +}); test("handles event split across chunk boundaries", async () => { - const eventJson = JSON.stringify({ type: "text-delta", id: "t1", delta: "hello" }) - const part1 = `data: ${eventJson.slice(0, 10)}` - const part2 = `${eventJson.slice(10)}\n\n` - const body = streamFromChunks([part1, part2]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(1) - expect((parts[0] as any).delta).toBe("hello") -}) + const eventJson = JSON.stringify({ type: "text-delta", id: "t1", delta: "hello" }); + const part1 = `data: ${eventJson.slice(0, 10)}`; + const part2 = `${eventJson.slice(10)}\n\n`; + const body = streamFromChunks([part1, part2]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(1); + expect((parts[0] as any).delta).toBe("hello"); +}); test("finish event type returns null (no-op)", async () => { - const body = streamFromChunks([sseEvent({ type: "finish" })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(0) -}) + const body = streamFromChunks([sseEvent({ type: "finish" })]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(0); +}); test("unknown event types are silently skipped", async () => { - const body = streamFromChunks([sseEvent({ type: "weird-internal-event" })]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(0) -}) + const body = streamFromChunks([sseEvent({ type: "weird-internal-event" })]); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(0); +}); test("handles \\r\\n line endings", async () => { - const body = streamFromChunks(["data: {\"type\":\"start\"}\r\n\r\n"]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(1) -}) + const body = streamFromChunks(['data: {"type":"start"}\r\n\r\n']); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(1); +}); test("closes stream cleanly at end of data", async () => { - const body = streamFromChunks(["data: {\"type\":\"start\"}\n\n"]) - const stream = parseStreamEvents(body) - const parts = await collectStream(stream) - expect(parts).toHaveLength(1) -}) + const body = streamFromChunks(['data: {"type":"start"}\n\n']); + const stream = parseStreamEvents(body); + const parts = await collectStream(stream); + expect(parts).toHaveLength(1); +});