diff --git a/.github/DISCUSSION_TEMPLATE/bug-reports.yml b/.github/DISCUSSION_TEMPLATE/bug-reports.yml index 58560774e6..3b09c5879c 100644 --- a/.github/DISCUSSION_TEMPLATE/bug-reports.yml +++ b/.github/DISCUSSION_TEMPLATE/bug-reports.yml @@ -51,16 +51,60 @@ body: id: version attributes: label: Prime Agent version - description: Include the output of `prime-agent --version`, or the commit if running from source. - placeholder: "0.0.0 or commit SHA" + description: Include the output of `prime-agent --version`, or the commit SHA when running from source. + placeholder: "e.g. 0.9.1 or 81ae3cb" + validations: + required: true + - type: dropdown + id: install_method + attributes: + label: Installation method + description: How was this Prime Agent version installed? + options: + - Stable release installer + - Beta release installer + - npm package + - From source + - Other + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - macOS + - Linux + - Windows + - Windows Subsystem for Linux (WSL) + - Other + validations: + required: true + - type: input + id: os_version + attributes: + label: Operating system version + description: Include the exact release or build. For WSL, include both the distribution and Windows version. + placeholder: "e.g. macOS 15.6, Ubuntu 24.04, or Windows 11 24H2" + validations: + required: true + - type: dropdown + id: architecture + attributes: + label: CPU architecture + description: Use `uname -m`, or `$env:PROCESSOR_ARCHITECTURE` in PowerShell, if unsure. + options: + - arm64 / aarch64 + - x86_64 / amd64 + - Other validations: required: true - type: input - id: environment + id: shell_terminal attributes: - label: Environment - description: Include your operating system, terminal, and any other relevant environment details. - placeholder: "macOS 15, Terminal.app" + label: Shell and terminal + description: Include both the shell and terminal application where the problem occurred. + placeholder: "e.g. zsh 5.9 in Terminal.app or PowerShell 7.5 in Windows Terminal" validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index f81d6d7ed2..ae1f28a8ad 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -43,8 +43,21 @@ body: id: version attributes: label: Prime Agent version - description: Output of `prime-agent --version`. - placeholder: "e.g. 0.1.0" + description: Output of `prime-agent --version`, or the commit SHA when running from source. + placeholder: "e.g. 0.9.1 or 81ae3cb" + validations: + required: true + - type: dropdown + id: install_method + attributes: + label: Installation method + description: How was this Prime Agent version installed? + options: + - Stable release installer + - Beta release installer + - npm package + - From source + - Other validations: required: true - type: dropdown @@ -55,9 +68,37 @@ body: - macOS - Linux - Windows + - Windows Subsystem for Linux (WSL) + - Other + validations: + required: true + - type: input + id: os_version + attributes: + label: Operating system version + description: Include the exact release or build. For WSL, include both the distribution and Windows version. + placeholder: "e.g. macOS 15.6, Ubuntu 24.04, or Windows 11 24H2" + validations: + required: true + - type: dropdown + id: architecture + attributes: + label: CPU architecture + description: Use `uname -m`, or `$env:PROCESSOR_ARCHITECTURE` in PowerShell, if unsure. + options: + - arm64 / aarch64 + - x86_64 / amd64 - Other validations: required: true + - type: input + id: shell_terminal + attributes: + label: Shell and terminal + description: Include both the shell and terminal application where the problem occurred. + placeholder: "e.g. zsh 5.9 in Terminal.app or PowerShell 7.5 in Windows Terminal" + validations: + required: true - type: textarea id: context attributes: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5788969c58..27f3abbdb3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,7 +6,9 @@ https://github.com/PrimeIntellect-ai/prime-agent/discussions ## Context - + ## Changes diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 946658cbb6..b1e4a2c758 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -1,2 +1,3 @@ # Trusted external contributors, one GitHub username per line without @. # Maintainers, bots, and collaborators with write access are allowed automatically. +sirouk diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml index 95411fae3f..594f74cac0 100644 --- a/.github/workflows/build-binaries.yml +++ b/.github/workflows/build-binaries.yml @@ -172,6 +172,41 @@ jobs: --base-url "$PRIME_AGENT_DOWNLOAD_BASE_URL" \ --out-dir packages/coding-agent/release/beta + - name: Smoke test installer with npm 12 + run: | + npm install --global npm@12.0.2 + npm --version | grep -Eq '^12\.' + + SMOKE_VERSION=0.0.0-installer-smoke + SMOKE_BASE_URL=http://127.0.0.1:18188 + SMOKE_OUT=packages/coding-agent/release/npm12-smoke + SMOKE_ROOT=$(mktemp -d) + + npm run release:pack -- \ + --channel stable \ + --version "$SMOKE_VERSION" \ + --base-url "$SMOKE_BASE_URL" \ + --out-dir "$SMOKE_OUT" + + mkdir -p "$SMOKE_ROOT/releases/v$SMOKE_VERSION" + cp "$SMOKE_OUT/artifacts/"* "$SMOKE_ROOT/releases/v$SMOKE_VERSION/" + sed \ + -e "s|__PRIME_AGENT_DOWNLOAD_BASE_URL__|$SMOKE_BASE_URL|g" \ + -e 's|__PRIME_AGENT_DEFAULT_RELEASE_CHANNEL__|stable|g' \ + install.sh > /tmp/prime-agent-npm12-install.sh + + python3 -m http.server 18188 --bind 127.0.0.1 --directory "$SMOKE_ROOT" >/tmp/prime-agent-npm12-http.log 2>&1 & + server_pid=$! + trap 'kill "$server_pid" 2>/dev/null || true; rm -rf "$SMOKE_ROOT" "$SMOKE_OUT"' EXIT + + curl -fsS --retry 5 --retry-connrefused "$SMOKE_BASE_URL/releases/v$SMOKE_VERSION/SHA256SUMS" + export NPM_CONFIG_PREFIX="$SMOKE_ROOT/npm-prefix" + PATH="$NPM_CONFIG_PREFIX/bin:$PATH" \ + PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL=0 \ + PRIME_AGENT_INSTALLER_PLAIN=1 \ + sh /tmp/prime-agent-npm12-install.sh "$SMOKE_VERSION" + test -x "$NPM_CONFIG_PREFIX/bin/prime-agent" + - name: Upload production artifacts if: env.PUBLISH_PRODUCTION == 'true' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/.github/workflows/changelog-fragment.yml b/.github/workflows/changelog-fragment.yml new file mode 100644 index 0000000000..5ae2967619 --- /dev/null +++ b/.github/workflows/changelog-fragment.yml @@ -0,0 +1,76 @@ +name: Changelog fragment + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + branches: [main] + +permissions: + pull-requests: read + +jobs: + changelog-fragment: + name: Check changelog fragment + runs-on: ubuntu-latest + if: github.event.pull_request.user.type != 'Bot' + steps: + - name: Require a changelog fragment for changed packages or an explicit opt-out + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const pr = context.payload.pull_request; + if (pr.labels.some((label) => label.name === "no-changelog")) { + core.info("Label no-changelog is set; skipping changelog check."); + return; + } + // listFiles caps at 3000 files; beyond that the check cannot see every change. + if (pr.changed_files > 3000) { + core.setFailed("PR changes more than 3000 files; split it or apply the no-changelog label."); + return; + } + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + per_page: 100, + }); + const packages = ["agent", "ai", "coding-agent", "tui"]; + const failing = []; + for (const pkg of packages) { + const srcPrefix = `packages/${pkg}/src/`; + const srcChanged = files.some( + (f) => + f.filename.startsWith(srcPrefix) || + (f.previous_filename !== undefined && f.previous_filename.startsWith(srcPrefix)), + ); + if (!srcChanged) { + continue; + } + // Fragments must be direct children of .changes/ — release.mjs ignores nested paths. + const fragmentRe = new RegExp(`^packages/${pkg}/\\.changes/[^/]+\\.md$`); + const hasFragment = files.some( + (f) => + f.status === "added" && + f.additions > 0 && + fragmentRe.test(f.filename) && + !f.filename.endsWith("/README.md"), + ); + if (!hasFragment) { + failing.push(pkg); + } + } + if (failing.length === 0) { + core.info("Changelog fragments present for all changed packages."); + return; + } + core.setFailed( + failing + .map( + (pkg) => + `packages/${pkg}/src changed but no changelog entry found. ` + + `Add packages/${pkg}/.changes/.md containing e.g. ` + + "`- Fixed the frobnicator dropping input on resize.`, " + + "or apply the no-changelog label.", + ) + .join("\n"), + ); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0701d16c7..695995352f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,10 @@ jobs: package: packages/coding-agent command: npm run test:kernel install_uv: true + - name: runtime python + package: prime-agent-runtime + command: uv run python -m unittest discover -s test + install_uv: true steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 diff --git a/.github/workflows/linear-ticket.yml b/.github/workflows/linear-ticket.yml new file mode 100644 index 0000000000..95fe227058 --- /dev/null +++ b/.github/workflows/linear-ticket.yml @@ -0,0 +1,42 @@ +name: Linear ticket + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + branches: [main] + +permissions: + pull-requests: read + +jobs: + linear-ticket: + name: Check Linear ticket link + runs-on: ubuntu-latest + if: github.event.pull_request.user.type != 'Bot' + steps: + - name: Require a Linear ticket reference or an explicit opt-out + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const pr = context.payload.pull_request; + const text = `${pr.title}\n${pr.body ?? ""}\n${pr.head.ref}`; + const ticket = /\bres-\d+\b|linear\.app\/[\w-]+\/issue\/res-\d+/i; + const optOut = /^\s*No-Ticket:\s*\S.*$/im; + if (ticket.test(text)) { + core.info("Linear ticket reference found."); + return; + } + const match = (pr.body ?? "").match(optOut); + if (match) { + core.info(`No-ticket opt-out present: ${match[0].trim()}`); + return; + } + core.setFailed( + [ + "No Linear ticket is linked in this pull request.", + "Prime Agent tickets should normally use the Research team and the Prime Agent: Long-Horizon research project.", + "Add the ticket ID (e.g. RES-1234) or a linear.app issue link to the PR title or description,", + "or use a branch named after the ticket (e.g. res-1234).", + 'If this change genuinely has no ticket, add a line to the PR description: "No-Ticket: ".', + ].join(" "), + ); diff --git a/AGENTS.md b/AGENTS.md index d8c18dd87e..85e64ff351 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -104,28 +104,23 @@ You, yourself, are often running into a tmux session, so be careful when killing ## Changelog -Location: `packages/*/CHANGELOG.md` (each package has its own) +Location: `packages//.changes/.md` (one fragment file per PR per touched package) ### Format -A flat list of plain bullets under `## [Unreleased]`. No `### Added` / `### Changed` / `### Fixed` / `### Removed` subsections — just one bullet per change, written as a short sentence starting with a past-tense verb (Added, Changed, Fixed, Removed). Keep each bullet to one line; describe the user-visible change, not the implementation. +Do NOT edit `packages/*/CHANGELOG.md` directly. Instead, add a fragment file `packages//.changes/.md` (slug = kebab-case, branch- or ticket-derived, e.g. `eng-1234-fix-resize.md`) containing exactly the bullet line(s) for the change. Bullets are plain `- ...` lines with no `### Added` / `### Changed` / `### Fixed` / `### Removed` subsections — one bullet per change, written as a short sentence starting with a past-tense verb (Added, Changed, Fixed, Removed). Keep each bullet to one line; describe the user-visible change, not the implementation. The release script folds fragments into the release section of CHANGELOG.md and deletes them. -Example of a well-formed `[Unreleased]` section: +Example fragment (`packages/coding-agent/.changes/eng-1234-effort-command.md`): ```markdown -## [Unreleased] - - Added `/effort` to set the reasoning level, with autocomplete for the levels the current model supports. -- Changed `prime-agent` to open a new chat by default instead of resuming the previous session. -- Fixed onboarding showing no models after entering a provider key. -- Removed the interactive `!` / `!!` bash shortcuts; use IPython instead. ``` ### Rules -- Read the full `[Unreleased]` section first so you don't duplicate an existing bullet -- New entries ALWAYS go under `## [Unreleased]` -- NEVER modify already-released version sections (e.g., `## [0.2.1]`) — each is immutable once released +- One fragment file per PR per touched package; a fragment may contain multiple bullets +- NEVER modify already-released version sections in CHANGELOG.md (e.g., `## [0.2.1]`) — each is immutable once released +- Purely internal changes may opt out via the `no-changelog` PR label ### Attribution @@ -183,7 +178,7 @@ Create provider file exporting: ### 7. Documentation - `packages/ai/README.md`: Add to providers table, document options/auth, add env vars -- `packages/ai/CHANGELOG.md`: Add entry under `## [Unreleased]` +- `packages/ai/.changes/.md`: Add a changelog fragment (see Changelog above) ## Releasing @@ -196,7 +191,7 @@ Create provider file exporting: ### Steps -1. **Update CHANGELOGs**: Ensure all changes since last release are documented in the `[Unreleased]` section of each affected package's CHANGELOG.md +1. **Check fragments**: Ensure all changes since last release have fragment files in `packages//.changes/` 2. **Run release script**: ```bash @@ -204,7 +199,7 @@ Create provider file exporting: npm run release:minor # API breaking changes ``` -The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new `[Unreleased]` sections. +The script handles: version bump, folding `.changes/` fragments into the release section, commit, tag, and publish. ## **CRITICAL** Git Rules for Parallel Agents **CRITICAL** @@ -239,7 +234,7 @@ git status # 2. Add ONLY your specific files git add packages/ai/src/providers/transform-messages.ts -git add packages/ai/CHANGELOG.md +git add packages/ai/.changes/eng-1234-fix-resize.md # 3. Commit git commit -m "fix(ai): description" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc84807e67..cb693bd65b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,4 +40,13 @@ If a maintainer has invited a pull request: Development setup and commands are documented in the [development guide](packages/coding-agent/docs/development.md). +## Changelog entries + +Do not edit `packages/*/CHANGELOG.md` directly. Instead, add one fragment +file per PR per touched package: `packages//.changes/.md`, where `` is a kebab-case name +derived from your branch or ticket (e.g. `eng-1234-fix-resize.md`). The file contains exactly the bullet +line(s) that describe the change, e.g. `- Fixed the frobnicator dropping input on resize.`. The release +script aggregates fragments into the release section and deletes them. PRs that change `packages//src` +without a fragment fail CI; apply the `no-changelog` label to opt out. + Maintainers may close a pull request that changes scope, cannot be validated safely, or no longer fits the project roadmap. diff --git a/README.md b/README.md index b4fd502b06..e4b77ad79f 100644 --- a/README.md +++ b/README.md @@ -9,14 +9,13 @@

-Prime Agent: A Self-Improving RLM Agent +Prime Agent: A Self-Improving RLM Harness

DocumentationVerifiers • - PRIME-RL • - pi-mono + PRIME-RL

@@ -26,6 +25,15 @@ Prime Agent: A Self-Improving RLM Agent Build Binaries + + arXiv + +

+ +

+ + PrimeIntellect-ai%2Fprime-agent | Trendshift +

Prime Agent is an open-source coding and research agent for general and long-running work. It is designed around two core abstractions: @@ -35,7 +43,7 @@ Prime Agent is an open-source coding and research agent for general and long-run Prime Agent combines a persistent Python control environment with durable harness state, so useful working context and reusable operating patterns can outlive a single chat window. -- **Everything is programmatic:** persistent IPython is the built-in model tool; file operations, shell commands, tool use, subagents, and context management happen through code. +- **Everything is programmatic:** a persistent Python REPL is the built-in model tool; file operations, shell commands, tool use, subagents, and context management happen through code. - **Subagents are built in:** `rlm(...)` spawns real child agents for parallel or background work and returns their results programmatically. - **The harness can improve:** `/refine` reviews the current trajectory and can apply small, evidence-backed updates to supplemental harness state. It never rewrites the immutable base system prompt, and recorded snapshots support rollback. - **Skills are executable:** skills are importable Python packages, and the built-in skill creator can turn recurring workflows into project or personal skills. @@ -51,7 +59,7 @@ Install the latest stable release on macOS or Linux: curl -fsSL https://app.primeintellect.ai/prime-agent/install.sh | sh ``` -The installer downloads a versioned release, verifies its SHA-256 checksum, installs the `prime-agent` command, and can prepare the IPython runtime used by the agent. +The installer downloads a versioned release, verifies its SHA-256 checksum, installs the `prime-agent` command, and can prepare the Python runtime used by the agent. Start Prime Agent from the repository or directory you want it to work in: @@ -82,7 +90,7 @@ Prime Agent is built for long-running work, especially for evaluations in resear - **Continual Harness:** `/refine` can persist focused, reviewable lessons as supplemental prompts, memories, reusable skill descriptions, or subagent specifications, with recorded refinement history. It does not replace packaging and reviewing new executable skills. - **Direct agent-to-agent communication:** running agents and retained subagents can discover one another, exchange messages, and steer active work. -- **Daemon-backed continuity:** active sessions, IPython state, schedules, and subagents keep running when the terminal detaches and can be reattached later. +- **Daemon-backed continuity:** active sessions, Python REPL state, schedules, and subagents keep running when the terminal detaches and can be reattached later. - **Heartbeats and schedules:** `/heartbeat`, `rlm_heartbeat`, and `prime-agent schedule` can re-enter a session periodically or at a specific time. - **Persistent goals:** `/goal` keeps an objective and its progress active across turns until it is completed, paused, or cleared. - **Bounded autonomous mode:** `/autonomous` continues within configured turn, token, and time budgets and can run user-defined quality gates. A passed gate checks only what that gate verifies; reaching a limit does not imply task success. @@ -92,7 +100,7 @@ Prime Agent is built for long-running work, especially for evaluations in resear - [Quickstart](packages/coding-agent/docs/quickstart.md) — install, authenticate, and run a first session - [Usage and CLI reference](packages/coding-agent/docs/usage.md) — commands, sessions, autonomous limits, and output modes - [Long-running and background agents](packages/coding-agent/docs/long-running-agents.md) — detach and reattach, goals, heartbeats, and schedules -- [RLM programming model](packages/coding-agent/docs/rlm.md) — persistent IPython, subagents, skills, and the trust model +- [RLM programming model](packages/coding-agent/docs/rlm.md) — the persistent Python REPL, subagents, skills, and the trust model - [JSON mode](packages/coding-agent/docs/json.md) and [RPC mode](packages/coding-agent/docs/rpc.md) — headless automation and integrations - [Skills](packages/coding-agent/docs/skills.md) — install and create reusable capabilities - [Provider setup](packages/coding-agent/docs/providers.md) — subscription and API-key providers @@ -112,3 +120,18 @@ Our agent and TUI is built on top of [`pi`](https://github.com/earendil-works/pi ## License Prime Agent is fully open source and released under the [MIT License](LICENSE). + +## Citation + +If you use this codebase in your research, please cite Prime Agent: + +```bibtex +@article{karten2026prime, + title={Prime Agent: A Self-Improving RLM Harness}, + author={Karten, Seth and Zhang, Alex L. and Thomas, Kevin and Müller, Sebastian and Bakouch, Elie and Auras, Daniel and Senghaas, Mika and Obeid, Fares and Dunas, Konstantin and Hagemann, Johannes and Jaghouar, Sami}, + journal={arXiv preprint arXiv:2608.23552}, + year={2026} +} +``` + +Available at [https://arxiv.org/abs/2608.23552](https://arxiv.org/abs/2608.23552). diff --git a/install.sh b/install.sh index 0e76ea5437..9a4ae18ed7 100755 --- a/install.sh +++ b/install.sh @@ -1565,8 +1565,8 @@ confirm_kernel_runtime_setup() { esac if prime_agent_prompt_yes_no \ - "Prepare IPython runtime now?" \ - "Installs uv, Python 3.11, ipykernel, and Prime Agent runtime." \ + "Prepare Python runtime now?" \ + "Installs uv, Python 3.11, and the Prime Agent runtime." \ "Prepare? [Y/n]"; then prime_agent_bootstrap_kernel_on_install=1 return @@ -1575,17 +1575,38 @@ confirm_kernel_runtime_setup() { fi if [ "$prompt_status" -eq 2 ]; then - printf 'No terminal detected; preparing the IPython runtime during install.\n' + printf 'No terminal detected; preparing the Python runtime during install.\n' prime_agent_bootstrap_kernel_on_install=1 return fi prime_agent_bootstrap_kernel_on_install=0 if [ "$prime_agent_screen_enabled" = 1 ]; then - prime_agent_screen "IPython setup skipped" "" "The runtime can be prepared on first ipython use." "" + prime_agent_screen "Python setup skipped" "" "The runtime can be prepared on first ipython use." "" sleep 0.4 else - printf '\nSkipping IPython runtime setup.\n' + printf '\nSkipping Python runtime setup.\n' + fi +} + +prime_agent_npm_requires_remote_policy() { + npm_version=$(npm --version 2>/dev/null) || return 1 + npm_major=${npm_version%%.*} + case "$npm_major" in + ""|*[!0-9]*) return 1 ;; + esac + [ "$npm_major" -ge 12 ] +} + +prime_agent_npm_install() { + tarball_path="$1" + shift + if prime_agent_npm_requires_remote_policy; then + # Limit npm 12's required policy overrides to the verified root package. + env "$@" npm install -g --no-fund --no-audit --loglevel=error --progress=false \ + --allow-remote=all --allow-scripts="$tarball_path" "$tarball_path" + else + env "$@" npm install -g --no-fund --no-audit --loglevel=error --progress=false "$tarball_path" fi } @@ -1596,13 +1617,13 @@ install_prime_agent_package() { Linking command binaries. Installing runtime packages. Preloading search tools. -Preparing IPython kernel. +Preparing Python kernel. Finalizing npm install." prime_agent_run_quiet_with_animation_steps \ "Installing Prime Agent" \ "Installing Prime Agent" \ "$npm_install_details" \ - env PRIME_AGENT_BOOTSTRAP_TOOLS_ON_INSTALL=1 PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL=1 PRIME_AGENT_INSTALL_UV=1 npm install -g --no-fund --no-audit --loglevel=error --progress=false "$tarball_path" + prime_agent_npm_install "$tarball_path" PRIME_AGENT_BOOTSTRAP_TOOLS_ON_INSTALL=1 PRIME_AGENT_BOOTSTRAP_KERNEL_ON_INSTALL=1 PRIME_AGENT_INSTALL_UV=1 else npm_install_details="Preparing global install. Linking command binaries. @@ -1613,7 +1634,7 @@ Finalizing npm install." "Installing Prime Agent" \ "Installing Prime Agent" \ "$npm_install_details" \ - env PRIME_AGENT_BOOTSTRAP_TOOLS_ON_INSTALL=1 npm install -g --no-fund --no-audit --loglevel=error --progress=false "$tarball_path" + prime_agent_npm_install "$tarball_path" PRIME_AGENT_BOOTSTRAP_TOOLS_ON_INSTALL=1 fi } diff --git a/package-lock.json b/package-lock.json index efc01d5cf8..b5fb6acd3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prime-agent", - "version": "0.7.12", + "version": "0.9.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prime-agent", - "version": "0.7.12", + "version": "0.9.1", "workspaces": [ "packages/*", "packages/coding-agent/examples/extensions/with-deps", @@ -15,7 +15,7 @@ "packages/coding-agent/examples/extensions/sandbox" ], "dependencies": { - "@earendil-works/pi-coding-agent": "^0.7.12", + "@earendil-works/pi-coding-agent": "^0.9.1", "get-east-asian-width": "^1.6.0" }, "devDependencies": { @@ -481,9 +481,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -501,9 +498,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -521,9 +515,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -541,9 +532,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -611,43 +599,6 @@ "resolved": "packages/tui", "link": true }, - "node_modules/@emnapi/core": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", - "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "2.0.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "2.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", - "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", - "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -1193,9 +1144,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1212,9 +1160,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1231,9 +1176,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1250,9 +1192,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1269,9 +1208,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1376,15 +1312,6 @@ "node": ">= 8" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@oxc-project/types": { "version": "0.139.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", @@ -1537,9 +1464,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1557,9 +1481,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1577,9 +1498,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1597,9 +1515,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1617,9 +1532,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1637,9 +1549,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2828,15 +2737,6 @@ "node": ">=8" } }, - "node_modules/cmake-ts": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/cmake-ts/-/cmake-ts-1.0.2.tgz", - "integrity": "sha512-5l++JHE7MxFuyV/OwJf3ek7ZZN1aGPFPM5oUz6AnK5inQAPe4TFXRMz5sA2qg2FRgByPWdqO+gSfIPo8GzoKNQ==", - "license": "MIT", - "bin": { - "cmake-ts": "build/main.js" - } - }, "node_modules/color-convert": { "version": "2.0.1", "license": "MIT", @@ -3109,6 +3009,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3519,6 +3420,15 @@ "version": "4.2.11", "license": "ISC" }, + "node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/has-flag": { "version": "4.0.0", "license": "MIT", @@ -3706,6 +3616,7 @@ "node_modules/jiti": { "version": "2.7.0", "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -3897,9 +3808,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3921,9 +3829,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3945,9 +3850,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3969,9 +3871,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -5166,6 +5065,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5232,6 +5132,7 @@ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -5342,6 +5243,7 @@ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", @@ -5632,6 +5534,7 @@ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -5675,34 +5578,12 @@ "fd-slicer": "~1.1.0" } }, - "node_modules/zeromq": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/zeromq/-/zeromq-6.5.0.tgz", - "integrity": "sha512-vWOrt19lvcXTxu5tiHXfEGQuldSlU+qZn2TT+4EbRQzaciWGwNZ99QQTolQOmcwVgZLodv+1QfC6UZs2PX/6pQ==", - "hasInstallScript": true, - "license": "MIT AND MPL-2.0", - "dependencies": { - "cmake-ts": "1.0.2", - "node-addon-api": "^8.3.1" - }, - "engines": { - "node": ">= 12" - } - }, - "node_modules/zeromq/node_modules/node-addon-api": { - "version": "8.9.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.1.tgz", - "integrity": "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -5716,10 +5597,10 @@ }, "packages/agent": { "name": "@earendil-works/pi-agent-core", - "version": "0.7.12", + "version": "0.9.1", "license": "MIT", "dependencies": { - "@earendil-works/pi-ai": "^0.7.12", + "@earendil-works/pi-ai": "^0.9.1", "typebox": "^1.3.9" }, "devDependencies": { @@ -5750,14 +5631,13 @@ }, "packages/ai": { "name": "@earendil-works/pi-ai", - "version": "0.7.12", + "version": "0.9.1", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.91.1", "@aws-sdk/client-bedrock-runtime": "^3.1095.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "^2.2.0", - "@opentelemetry/api": "^1.9.1", "chalk": "^5.6.2", "openai": "6.47.0", "partial-json": "^0.1.7", @@ -5797,14 +5677,14 @@ }, "packages/coding-agent": { "name": "@earendil-works/pi-coding-agent", - "version": "0.7.12", + "version": "0.9.1", "hasInstallScript": true, "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "^1.3.0", - "@earendil-works/pi-agent-core": "^0.7.12", - "@earendil-works/pi-ai": "^0.7.12", - "@earendil-works/pi-tui": "^0.7.12", + "@earendil-works/pi-agent-core": "^0.9.1", + "@earendil-works/pi-ai": "^0.9.1", + "@earendil-works/pi-tui": "^0.9.1", "@silvia-odwyer/photon-node": "^0.3.4", "chalk": "^5.5.0", "cli-highlight": "^2.1.11", @@ -5812,6 +5692,7 @@ "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", + "grok-mermaid": "0.2.3", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", @@ -5822,8 +5703,7 @@ "typebox": "^1.3.9", "undici": "^7.29.0", "uuid": "^14.0.0", - "yaml": "^2.9.0", - "zeromq": "^6.1.2" + "yaml": "^2.9.0" }, "bin": { "pi": "dist/bundle/cli.js" @@ -5848,18 +5728,18 @@ }, "packages/coding-agent/examples/extensions/custom-provider-anthropic": { "name": "pi-extension-custom-provider-anthropic", - "version": "0.0.1", + "version": "0.1.1", "dependencies": { "@anthropic-ai/sdk": "^0.52.0" } }, "packages/coding-agent/examples/extensions/custom-provider-gitlab-duo": { "name": "pi-extension-custom-provider-gitlab-duo", - "version": "0.0.1" + "version": "0.1.1" }, "packages/coding-agent/examples/extensions/sandbox": { "name": "pi-extension-sandbox", - "version": "1.4.0", + "version": "1.5.1", "dependencies": { "@anthropic-ai/sandbox-runtime": "^0.0.55" } @@ -5899,7 +5779,7 @@ }, "packages/coding-agent/examples/extensions/with-deps": { "name": "pi-extension-with-deps", - "version": "0.0.1", + "version": "0.1.1", "dependencies": { "ms": "^2.1.3" }, @@ -5933,7 +5813,7 @@ }, "packages/tui": { "name": "@earendil-works/pi-tui", - "version": "0.7.12", + "version": "0.9.1", "license": "MIT", "dependencies": { "@types/mime-types": "^3.0.1", diff --git a/package.json b/package.json index d7119321e7..810703bbb4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "dev": "concurrently --names \"ai,agent,coding-agent,tui\" --prefix-colors \"cyan,yellow,red,magenta\" \"cd packages/ai && npm run dev\" \"cd packages/agent && npm run dev\" \"cd packages/coding-agent && npm run dev\" \"cd packages/tui && npm run dev\"", "dev:tsc": "cd packages/ai && npm run dev:tsc", "check": "biome check --write --error-on-warnings . && tsgo --noEmit && npm run check:installer && npm run check:browser-smoke && npm run check:prime-runtime", - "check:installer": "node scripts/check-installer-render.mjs", + "check:installer": "node scripts/check-installer.mjs", "check:browser-smoke": "node scripts/check-browser-smoke.mjs", "check:prime-runtime": "PYTHONPATH=prime-agent-runtime/src python3 -m unittest discover -s prime-agent-runtime/test -p test_inspection.py", "test:prime-runtime": "uv run --locked --isolated --project prime-agent-runtime --group test python -m unittest discover -s prime-agent-runtime/test -p test_*.py", @@ -50,9 +50,9 @@ "engines": { "node": ">=22.8.0" }, - "version": "0.7.12", + "version": "0.9.1", "dependencies": { - "@earendil-works/pi-coding-agent": "^0.7.12", + "@earendil-works/pi-coding-agent": "^0.9.1", "get-east-asian-width": "^1.6.0" }, "overrides": { diff --git a/packages/agent/.changes/README.md b/packages/agent/.changes/README.md new file mode 100644 index 0000000000..86744da404 --- /dev/null +++ b/packages/agent/.changes/README.md @@ -0,0 +1,5 @@ +# Changelog fragments + +One `.md` per PR containing the bullet line(s) (e.g. `- Fixed ...`) that describe the change +for this package. `scripts/release.mjs` folds these into the release section of CHANGELOG.md and +deletes them. See CONTRIBUTING.md. diff --git a/packages/agent/CHANGELOG.md b/packages/agent/CHANGELOG.md index 64b97729e2..574e70b055 100644 --- a/packages/agent/CHANGELOG.md +++ b/packages/agent/CHANGELOG.md @@ -1,26 +1,14 @@ # Changelog -## [Unreleased] +## [0.8.0] - 2026-08-21 -## [0.7.12] - 2026-08-19 +- Added `AgentContinueError` with stable codes (`busy`, `nothing-to-continue`) for `Agent.continue()` precondition failures, so callers classify without matching message text. -## [0.7.11] - 2026-08-18 +## [0.7.4] - 2026-08-19 -## [0.7.10] - 2026-08-18 +## [0.7.3] - 2026-08-17 -## [0.7.9] - 2026-08-18 - -## [0.7.8] - 2026-08-17 - -## [0.7.7] - 2026-08-17 - -## [0.7.6] - 2026-08-17 - -## [0.7.5] - 2026-08-17 - -## [0.7.4] - 2026-08-16 - -## [0.7.3] - 2026-08-15 +- Changed explicit `off` reasoning selections to reach providers instead of being omitted, preserving provider-specific disable behavior. ## [0.7.2] - 2026-08-11 diff --git a/packages/agent/package.json b/packages/agent/package.json index 95f1d0fc45..e512344cc3 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-agent-core", - "version": "0.7.12", + "version": "0.9.1", "description": "General-purpose agent with transport abstraction, state management, and attachment support", "type": "module", "main": "./dist/index.js", @@ -17,7 +17,7 @@ "prepublishOnly": "npm run clean && npm run build" }, "dependencies": { - "@earendil-works/pi-ai": "^0.7.12", + "@earendil-works/pi-ai": "^0.9.1", "typebox": "^1.3.9" }, "keywords": [ diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 0d3d7376dd..31b3a15ccc 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -301,9 +301,6 @@ function createAgentStream(): EventStream { ); } -/** - * Main loop logic shared by agentLoop and agentLoopContinue. - */ async function runLoop( currentContext: AgentContext, newMessages: AgentMessage[], @@ -314,17 +311,14 @@ async function runLoop( ): Promise { let firstTurn = true; let lastTurn: Parameters>[0] | undefined; - // Check for steering messages at start (user may have typed while waiting) let pendingMessages: AgentMessage[] = await pollMessagesUnlessAborted(config.getSteeringMessages, signal); const shouldStopBeforeTurn = (): boolean => !firstTurn && (config.shouldStopBeforeTurn?.() ?? false); - // Outer loop: continues when queued follow-up messages arrive after agent would stop while (true) { throwIfAborted(signal); let hasMoreToolCalls = true; - // Inner loop: process tool calls and steering messages while (hasMoreToolCalls || pendingMessages.length > 0) { throwIfAborted(signal); if (!firstTurn) { @@ -333,7 +327,6 @@ async function runLoop( firstTurn = false; } - // Process pending messages (inject before next assistant response) if (pendingMessages.length > 0) { for (const message of pendingMessages) { await emit({ type: "message_start", message }); @@ -344,7 +337,6 @@ async function runLoop( pendingMessages = []; } - // Stream assistant response const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFn); newMessages.push(message); @@ -354,7 +346,6 @@ async function runLoop( return; } - // Check for tool calls const toolCalls = message.content.filter((c) => c.type === "toolCall"); const toolResults: ToolResultMessage[] = []; @@ -419,7 +410,6 @@ async function runLoop( } } - // Agent would stop here. Check for follow-up messages. if (shouldStopBeforeTurn()) break; const followUpMessagesResult = await settlePostTurn( pollMessagesUnlessAborted(config.getFollowUpMessages, signal), @@ -431,7 +421,6 @@ async function runLoop( } const followUpMessages = followUpMessagesResult.value; if (followUpMessages.length > 0) { - // Set as pending so inner loop processes them pendingMessages = followUpMessages; continue; } @@ -453,17 +442,12 @@ async function runLoop( continue; } - // No more messages, exit break; } await emit({ type: "agent_end", messages: newMessages }); } -/** - * Stream an assistant response from the LLM. - * This is where AgentMessage[] gets transformed to Message[] for the LLM. - */ async function streamAssistantResponse( context: AgentContext, config: AgentLoopConfig, @@ -487,24 +471,20 @@ async function streamAssistantResponse( try { throwIfAborted(signal); - // Apply context transform if configured (AgentMessage[] → AgentMessage[]) let messages = context.messages; if (config.transformContext) { messages = await maybePromiseWithAbort(config.transformContext(messages, signal), signal); } - // Convert to LLM-compatible messages (AgentMessage[] → Message[]) const llmMessages = await maybePromiseWithAbort(config.convertToLlm(messages), signal); const streamFunction = streamFn || streamSimple; - // Resolve API key (important for expiring tokens) const resolvedApiKey = (config.getApiKey ? await maybePromiseWithAbort(config.getApiKey(config.model.provider), signal) : undefined) || config.apiKey; - // Build LLM context immediately before starting the provider call. const llmContext: Context = { systemPrompt: config.getSystemPrompt?.() ?? context.systemPrompt, messages: llmMessages, @@ -602,9 +582,6 @@ async function streamAssistantResponse( } } -/** - * Execute tool calls from an assistant message. - */ async function executeToolCalls( currentContext: AgentContext, assistantMessage: AssistantMessage, diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index cdef2db467..0b7b5ae774 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -94,7 +94,6 @@ function createMutableAgentState( }; } -/** Options for constructing an {@link Agent}. */ export interface AgentOptions { initialState?: Partial>; convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise; @@ -173,12 +172,20 @@ type ActiveRun = { abortController: AbortController; }; -/** - * Stateful wrapper around the low-level agent loop. - * - * `Agent` owns the current transcript, emits lifecycle events, executes tools, - * and exposes queueing APIs for steering and follow-up messages. - */ +/** Why {@link Agent.continue} refused to start a continuation. */ +export type AgentContinueErrorCode = "busy" | "nothing-to-continue"; + +/** Typed precondition failure from {@link Agent.continue}, so callers classify by code instead of message text. */ +export class AgentContinueError extends Error { + constructor( + readonly code: AgentContinueErrorCode, + message: string, + ) { + super(message); + this.name = "AgentContinueError"; + } +} + export class Agent { private _state: MutableAgentState; private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise | void>(); @@ -206,15 +213,10 @@ export class Agent { signal?: AbortSignal, ) => Promise; private activeRun?: ActiveRun; - /** Session identifier forwarded to providers for cache-aware backends. */ public sessionId?: string; - /** Optional per-level thinking token budgets forwarded to the stream function. */ public thinkingBudgets?: ThinkingBudgets; - /** Preferred transport forwarded to the stream function. */ public transport: Transport; - /** Optional cap for provider-requested retry delays. */ public maxRetryDelayMs?: number; - /** Tool execution strategy for assistant messages that contain multiple tool calls. */ public toolExecution: ToolExecutionMode; constructor(options: AgentOptions = {}) { @@ -263,7 +265,6 @@ export class Agent { return this._state; } - /** Controls how queued steering messages are drained. */ set steeringMode(mode: QueueMode) { this.steeringQueue.mode = mode; } @@ -272,7 +273,6 @@ export class Agent { return this.steeringQueue.mode; } - /** Controls how queued follow-up messages are drained. */ set followUpMode(mode: QueueMode) { this.followUpQueue.mode = mode; } @@ -291,38 +291,31 @@ export class Agent { this.followUpQueue.enqueue(message); } - /** Remove all queued steering messages. */ clearSteeringQueue(): void { this.steeringQueue.clear(); } - /** Remove all queued follow-up messages. */ clearFollowUpQueue(): void { this.followUpQueue.clear(); } - /** Remove all queued steering and follow-up messages. */ clearAllQueues(): void { this.clearSteeringQueue(); this.clearFollowUpQueue(); } - /** Remove queued batches containing a message matching the predicate from both queues. */ removeQueuedMessages(predicate: (message: AgentMessage) => boolean): AgentMessage[] { return [...this.steeringQueue.removeWhere(predicate), ...this.followUpQueue.removeWhere(predicate)]; } - /** Returns true when either queue still contains pending messages. */ hasQueuedMessages(): boolean { return this.steeringQueue.hasItems() || this.followUpQueue.hasItems(); } - /** Active abort signal for the current run, if any. */ get signal(): AbortSignal | undefined { return this.activeRun?.abortController.signal; } - /** Abort the current run, if one is active. */ abort(): void { this.activeRun?.abortController.abort(); } @@ -336,7 +329,6 @@ export class Agent { return this.activeRun?.promise ?? Promise.resolve(); } - /** Clear transcript state, runtime state, and queued messages. */ reset(): void { this._state.messages = []; this._state.isStreaming = false; @@ -347,7 +339,6 @@ export class Agent { this.clearSteeringQueue(); } - /** Start a new prompt from text, a single message, or a batch of messages. */ async prompt(message: AgentMessage | AgentMessage[]): Promise; async prompt(input: string, images?: ImageContent[]): Promise; async prompt(input: string | AgentMessage | AgentMessage[], images?: ImageContent[]): Promise { @@ -360,10 +351,10 @@ export class Agent { await this.runPromptMessages(messages); } - /** Continue from the current transcript. The last message must be a user or tool-result message. */ + /** The last message must convert to a user or tool-result message. */ async continue(): Promise { if (this.activeRun) { - throw new Error("Agent is already processing. Wait for completion before continuing."); + throw new AgentContinueError("busy", "Agent is already processing. Wait for completion before continuing."); } const runQueuedMessages = (): Promise | undefined => { @@ -388,7 +379,7 @@ export class Agent { return; } - throw new Error("No messages to continue from"); + throw new AgentContinueError("nothing-to-continue", "No messages to continue from"); } if (lastMessage.role === "assistant") { @@ -398,7 +389,7 @@ export class Agent { return; } - throw new Error("Cannot continue from message role: assistant"); + throw new AgentContinueError("nothing-to-continue", "Cannot continue from message role: assistant"); } const lastMessageRole: string = lastMessage.role; diff --git a/packages/agent/src/index.ts b/packages/agent/src/index.ts index d8ed5b8eb5..fdbe00b3ab 100644 --- a/packages/agent/src/index.ts +++ b/packages/agent/src/index.ts @@ -1,8 +1,4 @@ -// Core Agent export * from "./agent.js"; -// Loop functions export * from "./agent-loop.js"; -// Proxy utilities export * from "./proxy.js"; -// Types export * from "./types.js"; diff --git a/packages/agent/src/proxy.ts b/packages/agent/src/proxy.ts index 5f0925c914..69920ce033 100644 --- a/packages/agent/src/proxy.ts +++ b/packages/agent/src/proxy.ts @@ -1,9 +1,3 @@ -/** - * Proxy stream function for apps that route LLM calls through a server. - * The server manages auth and proxies requests to LLM providers. - */ - -// Internal import for JSON parsing utility import { type AssistantMessage, type AssistantMessageEvent, @@ -16,7 +10,6 @@ import { type ToolCall, } from "@earendil-works/pi-ai"; -// Create stream class matching ProxyMessageEventStream class ProxyMessageEventStream extends EventStream { constructor() { super( @@ -30,9 +23,6 @@ class ProxyMessageEventStream extends EventStream; export interface ProxyStreamOptions extends ProxySerializableStreamOptions { - /** Local abort signal for the proxy request */ signal?: AbortSignal; - /** Auth token for the proxy server */ authToken: string; - /** Proxy server URL (e.g., "https://genai.example.com") */ proxyUrl: string; } @@ -117,7 +104,6 @@ export function streamProxy(model: Model, context: Context, options: ProxyS const stream = new ProxyMessageEventStream(); (async () => { - // Initialize the partial message that we'll build up from events const partial: AssistantMessage = { role: "assistant", stopReason: "stop", @@ -171,7 +157,7 @@ export function streamProxy(model: Model, context: Context, options: ProxyS errorMessage = `Proxy error: ${errorData.error}`; } } catch { - // Couldn't parse error response + // Keep the status-text fallback when the error body is not JSON. } throw new Error(errorMessage); } @@ -232,9 +218,6 @@ export function streamProxy(model: Model, context: Context, options: ProxyS return stream; } -/** - * Process a proxy event and update the partial message. - */ function processProxyEvent( proxyEvent: ProxyAssistantMessageEvent, partial: AssistantMessage, diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 92f68a6570..f29074abe0 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -36,7 +36,7 @@ export type StreamFn = ( */ export type ToolExecutionMode = "sequential" | "parallel"; -/** A single tool call content block emitted by an assistant message. */ +/** A tool-call content block emitted by an assistant message. */ export type AgentToolCall = Extract; /** @@ -73,47 +73,46 @@ export interface AfterToolCallResult { terminate?: boolean; } -/** Context passed to `beforeToolCall`. */ +/** Context passed to `beforeToolCall` after arguments are validated. */ export interface BeforeToolCallContext { - /** The assistant message that requested the tool call. */ + /** Assistant message that requested the call. */ assistantMessage: AssistantMessage; - /** The raw tool call block from `assistantMessage.content`. */ + /** Raw tool-call block from `assistantMessage.content`. */ toolCall: AgentToolCall; - /** Validated tool arguments for the target tool schema. */ + /** Validated arguments for the target tool schema. */ args: unknown; - /** Current agent context at the time the tool call is prepared. */ + /** Agent context when this call is prepared. */ context: AgentContext; } /** Context passed to `afterToolCall`. */ export interface AfterToolCallContext { - /** The assistant message that requested the tool call. */ + /** Assistant message that requested the call. */ assistantMessage: AssistantMessage; - /** The raw tool call block from `assistantMessage.content`. */ + /** Raw tool-call block from `assistantMessage.content`. */ toolCall: AgentToolCall; - /** Validated tool arguments for the target tool schema. */ + /** Validated arguments for the target tool schema. */ args: unknown; - /** The executed tool result before any `afterToolCall` overrides are applied. */ + /** Executed result before any `afterToolCall` overrides. */ result: AgentToolResult; - /** Whether the executed tool result is currently treated as an error. */ + /** Whether the executed result is currently treated as an error. */ isError: boolean; - /** Current agent context at the time the tool call is finalized. */ + /** Agent context when this call is finalized. */ context: AgentContext; } -/** Context passed to `shouldStopAfterTurn`. */ +/** Context passed to `shouldStopAfterTurn` and `getContinuationMessages`. */ export interface ShouldStopAfterTurnContext { - /** The assistant message that completed the turn. */ + /** Assistant message that completed the turn. */ message: AssistantMessage; - /** Tool result messages passed to the preceding `turn_end` event. */ + /** Tool-result messages included in the preceding `turn_end` event. */ toolResults: ToolResultMessage[]; - /** Current agent context after the turn's assistant message and tool results have been appended. */ + /** Context after appending the turn's assistant message and tool results. */ context: AgentContext; - /** Messages that this loop invocation will return if it exits at this point. Prompt runs include the initial prompt messages; continuation runs do not include pre-existing context messages. */ + /** Messages returned by this invocation; prompts include initial prompts, continuations exclude prior context. */ newMessages: AgentMessage[]; } -/** Context passed to `getContinuationMessages`. */ export type GetContinuationMessagesContext = ShouldStopAfterTurnContext; export interface AgentLoopConfig extends SimpleStreamOptions { @@ -244,13 +243,9 @@ export interface AgentLoopConfig extends SimpleStreamOptions { getContinuationMessages?: (context: GetContinuationMessagesContext, signal?: AbortSignal) => Promise; /** - * Tool execution mode. - * - "sequential": execute tool calls one by one - * - "parallel": preflight tool calls sequentially, then execute allowed tools concurrently; - * emit `tool_execution_end` in tool completion order after each tool is finalized, - * then emit tool-result message artifacts later in assistant source order - * - * Default: "parallel" + * Tool execution mode. Defaults to `"parallel"`. + * Parallel mode preflights calls sequentially, executes allowed calls concurrently, emits + * `tool_execution_end` in completion order, then emits tool-result messages in assistant source order. */ toolExecution?: ToolExecutionMode; @@ -298,15 +293,8 @@ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhi * } * ``` */ -export interface CustomAgentMessages { - // Empty by default - apps extend via declaration merging -} +export interface CustomAgentMessages {} -/** - * AgentMessage: Union of LLM messages + custom messages. - * This abstraction allows apps to add custom message types while maintaining - * type safety and compatibility with the base LLM messages. - */ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessages]; /** @@ -318,29 +306,25 @@ export type AgentMessage = Message | CustomAgentMessages[keyof CustomAgentMessag export interface AgentState { /** System prompt sent with each model request. */ systemPrompt: string; - /** Active model used for future turns. */ + /** Model used for future turns. */ model: Model; /** Requested reasoning level for future turns. */ thinkingLevel: ThinkingLevel; /** Requested provider service tier for future turns. */ serviceTier: ServiceTier; - /** Available tools. Assigning a new array copies the top-level array. */ + /** Available tools. Assigning a new array copies its top-level array. */ set tools(tools: AgentTool[]); get tools(): AgentTool[]; - /** Conversation transcript. Assigning a new array copies the top-level array. */ + /** Conversation transcript. Assigning a new array copies its top-level array. */ set messages(messages: AgentMessage[]); get messages(): AgentMessage[]; - /** - * True while the agent is processing a prompt or continuation. - * - * This remains true until awaited `agent_end` listeners settle. - */ + /** True while processing a prompt or continuation, including awaited `agent_end` listeners. */ readonly isStreaming: boolean; - /** Partial assistant message for the current streamed response, if any. */ + /** Partial assistant message for the active streamed response, if any. */ readonly streamingMessage?: AgentMessage; - /** Tool call ids currently executing. */ + /** Tool-call IDs currently executing. */ readonly pendingToolCalls: ReadonlySet; - /** Error message from the most recent failed or aborted assistant turn, if any. */ + /** Error from the most recent failed or aborted assistant turn, if any. */ readonly errorMessage?: string; } @@ -348,7 +332,7 @@ export interface AgentState { export interface AgentToolResult { /** Text or image content returned to the model. */ content: (TextContent | ImageContent)[]; - /** Arbitrary structured details for logs or UI rendering. */ + /** Structured details for logs or UI rendering. */ details: T; /** * Hint that the agent should stop after the current tool batch. @@ -357,7 +341,7 @@ export interface AgentToolResult { terminate?: boolean; } -/** Callback used by tools to stream partial execution updates. */ +/** Callback used by tools to publish partial execution updates. */ export type AgentToolUpdateCallback = (partialResult: AgentToolResult) => void; /** Tool definition used by the agent runtime. */ @@ -386,7 +370,7 @@ export interface AgentTool { constructor() { super( @@ -105,7 +104,6 @@ function createUserMessage(text: string): UserMessage { }; } -// Simple identity converter for tests - just passes through standard messages function identityConverter(messages: AgentMessage[]): Message[] { return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; } @@ -136,7 +134,6 @@ describe("agentLoop with AgentMessage", () => { const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn); for await (const _event of stream) { - // consume } const messages = await stream.result(); @@ -261,7 +258,6 @@ describe("agentLoop with AgentMessage", () => { const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn); for await (const _event of stream) { - // consume } const messages = await stream.result(); @@ -358,7 +354,6 @@ describe("agentLoop with AgentMessage", () => { const stream = agentLoop([createUserMessage("Hello")], context, config, controller.signal, streamFn); for await (const _event of stream) { - // consume } await stream.result(); @@ -542,7 +537,6 @@ describe("agentLoop with AgentMessage", () => { }); it("should handle custom message types via convertToLlm", async () => { - // Create a custom message type interface CustomNotification { role: "notification"; text: string; @@ -567,7 +561,6 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: (messages) => { - // Filter out notifications, convert rest convertedMessages = messages .filter((m) => (m as { role: string }).role !== "notification") .filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[]; @@ -591,7 +584,6 @@ describe("agentLoop with AgentMessage", () => { events.push(event); } - // The notification should have been filtered out in convertToLlm expect(convertedMessages.length).toBe(1); // Only user message expect(convertedMessages[0].role).toBe("user"); }); @@ -637,7 +629,6 @@ describe("agentLoop with AgentMessage", () => { ); const consume = (async () => { for await (const _event of stream) { - // consume } })(); @@ -669,7 +660,6 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), transformContext: async (messages) => { - // Keep only last 2 messages (prune old ones) transformedMessages = messages.slice(-2); return transformedMessages; }, @@ -693,12 +683,9 @@ describe("agentLoop with AgentMessage", () => { const stream = agentLoop([userPrompt], context, config, undefined, streamFn); for await (const _ of stream) { - // consume } - // transformContext should have been called first, keeping only last 2 expect(transformedMessages.length).toBe(2); - // Then convertToLlm receives the pruned messages expect(convertedMessages.length).toBe(2); }); @@ -758,7 +745,6 @@ describe("agentLoop with AgentMessage", () => { const stream = agentLoop([userPrompt], context, config, undefined, streamFn); for await (const _event of stream) { - // consume } expect(executed).toEqual([123]); @@ -838,7 +824,6 @@ describe("agentLoop with AgentMessage", () => { const stream = agentLoop([userPrompt], context, config, undefined, streamFn); for await (const _event of stream) { - // consume } expect(executed).toEqual([[{ oldText: "before", newText: "after" }]]); @@ -974,7 +959,6 @@ describe("agentLoop with AgentMessage", () => { convertToLlm: identityConverter, toolExecution: "sequential", getSteeringMessages: async () => { - // Return steering message after tool execution has started. if (executed.length >= 1 && !queuedDelivered) { queuedDelivered = true; return [queuedUserMessage]; @@ -985,7 +969,6 @@ describe("agentLoop with AgentMessage", () => { const events: AgentEvent[] = []; const stream = agentLoop([userPrompt], context, config, undefined, (_model, ctx, _options) => { - // Check if interrupt message is in context on second call if (callIndex === 1) { sawInterruptInContext = ctx.messages.some( (m) => m.role === "user" && typeof m.content === "string" && m.content === "interrupt", @@ -995,7 +978,6 @@ describe("agentLoop with AgentMessage", () => { const mockStream = new MockAssistantStream(); queueMicrotask(() => { if (callIndex === 0) { - // First call: return two tool calls const message = createAssistantMessage( [ { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } }, @@ -1005,7 +987,6 @@ describe("agentLoop with AgentMessage", () => { ); mockStream.push({ type: "done", reason: "toolUse", message }); } else { - // Second call: return final response const message = createAssistantMessage([{ type: "text", text: "done" }]); mockStream.push({ type: "done", reason: "stop", message }); } @@ -1018,7 +999,6 @@ describe("agentLoop with AgentMessage", () => { events.push(event); } - // Both tools should execute before steering is injected expect(executed).toEqual(["first", "second"]); const toolEnds = events.filter( @@ -1028,7 +1008,6 @@ describe("agentLoop with AgentMessage", () => { expect(toolEnds[0].isError).toBe(false); expect(toolEnds[1].isError).toBe(false); - // Queued message should appear in events after both tool result messages const eventSequence = events.flatMap((event) => { if (event.type !== "message_start") return []; if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`]; @@ -1041,7 +1020,6 @@ describe("agentLoop with AgentMessage", () => { expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt")); expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt")); - // Interrupt message should be in context when second LLM call is made expect(sawInterruptInContext).toBe(true); }); @@ -1084,7 +1062,6 @@ describe("agentLoop with AgentMessage", () => { }); for await (const _event of stream) { - // consume } const messages = await stream.result(); @@ -1137,7 +1114,6 @@ describe("agentLoop with AgentMessage", () => { }); for await (const _event of stream) { - // consume } expect(callIndex).toBe(2); @@ -1182,7 +1158,6 @@ describe("agentLoop with AgentMessage", () => { }; const userPrompt: AgentMessage = createUserMessage("run both"); - // config is parallel (default), but tool forces sequential const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, @@ -1216,7 +1191,6 @@ describe("agentLoop with AgentMessage", () => { events.push(event); } - // With sequential execution, second tool should NOT start before first finishes expect(parallelObserved).toBe(false); const toolResultIds = events.flatMap((event) => { @@ -1259,7 +1233,6 @@ describe("agentLoop with AgentMessage", () => { label: "Fast", description: "Fast tool", parameters: toolSchema, - // no executionMode = defaults to parallel async execute(_toolCallId, params) { executionOrder.push(`fast:${params.value}`); return { @@ -1279,7 +1252,6 @@ describe("agentLoop with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: identityConverter, - // parallel by default, but slowTool forces sequential }; let callIndex = 0; @@ -1310,7 +1282,6 @@ describe("agentLoop with AgentMessage", () => { events.push(event); } - // Fast tool should NOT run before slow tool finishes expect(executionOrder[0]).toBe("slow:a"); expect(executionOrder).toContain("fast:b"); }); @@ -1385,7 +1356,6 @@ describe("agentLoop with AgentMessage", () => { events.push(event); } - // With executionMode=parallel, second tool should start before first finishes expect(parallelObserved).toBe(true); }); @@ -1467,7 +1437,6 @@ describe("agentLoop with AgentMessage", () => { }, ); for await (const _event of stream) { - // Drain the stream. } expect(llmCalls).toBe(expected.llmCalls); @@ -1496,7 +1465,6 @@ describe("agentLoop with AgentMessage", () => { convertToLlm: identityConverter, shouldStopBeforeTurn: () => stopRequested, getSteeringMessages: async () => { - // Flip only on the post-tool-batch poll; the pre-loop poll would stop before the recheck runs. if (llmCalls > 0) stopRequested = true; return []; }, @@ -1519,7 +1487,6 @@ describe("agentLoop with AgentMessage", () => { }, ); for await (const _event of stream) { - // Drain the stream. } expect(llmCalls).toBe(1); }); @@ -1541,7 +1508,6 @@ describe("agentLoop with AgentMessage", () => { convertToLlm: identityConverter, shouldStopBeforeTurn: () => stopRequested, getSteeringMessages: async () => { - // Only the post-tool-batch poll returns steering; the stop flips during it. if (llmCalls !== 1 || stopRequested) return []; stopRequested = true; return [createUserMessage("late steer")]; @@ -1617,7 +1583,6 @@ describe("agentLoop with AgentMessage", () => { }, ); for await (const _event of stream) { - // Drain the stream. } expect(execute).toHaveBeenCalledOnce(); @@ -1824,7 +1789,6 @@ describe("agentLoop with AgentMessage", () => { }); for await (const _event of stream) { - // consume } const messages = await stream.result(); @@ -1880,7 +1844,6 @@ describe("agentLoop with AgentMessage", () => { }); for await (const _event of stream) { - // consume } expect(llmCalls).toBe(1); @@ -1935,18 +1898,15 @@ describe("agentLoopContinue with AgentMessage", () => { const messages = await stream.result(); - // Should only return the new assistant message (not the existing user message) expect(messages.length).toBe(1); expect(messages[0].role).toBe("assistant"); - // Should NOT have user message events (that's the key difference from agentLoop) const messageEndEvents = events.filter((e) => e.type === "message_end"); expect(messageEndEvents.length).toBe(1); expect((messageEndEvents[0] as any).message.role).toBe("assistant"); }); it("should allow custom message types as last message (caller responsibility)", async () => { - // Custom message that will be converted to user message by convertToLlm interface CustomMessage { role: "custom"; text: string; @@ -1968,7 +1928,6 @@ describe("agentLoopContinue with AgentMessage", () => { const config: AgentLoopConfig = { model: createModel(), convertToLlm: (messages) => { - // Convert custom to user message return messages .map((m) => { if ((m as any).role === "custom") { @@ -1993,7 +1952,6 @@ describe("agentLoopContinue with AgentMessage", () => { return stream; }; - // Should not throw - the custom message will be converted to user message const stream = agentLoopContinue(context, config, undefined, streamFn); const events: AgentEvent[] = []; diff --git a/packages/agent/test/agent.test.ts b/packages/agent/test/agent.test.ts index 2474a203bd..471ffe3ac3 100644 --- a/packages/agent/test/agent.test.ts +++ b/packages/agent/test/agent.test.ts @@ -10,7 +10,6 @@ import { agentLoop, } from "../src/index.js"; -// Mock stream that mimics AssistantMessageEventStream class MockAssistantStream extends EventStream { constructor() { super( @@ -122,15 +121,12 @@ describe("Agent", () => { eventCount++; }); - // No initial event on subscribe expect(eventCount).toBe(0); - // State mutators don't emit events agent.state.systemPrompt = "Test prompt"; expect(eventCount).toBe(0); expect(agent.state.systemPrompt).toBe("Test prompt"); - // Unsubscribe should work unsubscribe(); agent.state.systemPrompt = "Another prompt"; expect(eventCount).toBe(0); // Should not increase @@ -275,38 +271,31 @@ describe("Agent", () => { it("should update state with mutators", () => { const agent = new Agent(); - // Test setSystemPrompt agent.state.systemPrompt = "Custom prompt"; expect(agent.state.systemPrompt).toBe("Custom prompt"); - // Test setModel const newModel = getModel("google", "gemini-2.5-flash"); agent.state.model = newModel; expect(agent.state.model).toBe(newModel); - // Test setThinkingLevel agent.state.thinkingLevel = "high"; expect(agent.state.thinkingLevel).toBe("high"); - // Test setTools const tools = [{ name: "test", description: "test tool" } as any]; agent.state.tools = tools; expect(agent.state.tools).toEqual(tools); expect(agent.state.tools).not.toBe(tools); // Should be a copy - // Test replaceMessages const messages = [{ role: "user" as const, content: "Hello", timestamp: Date.now() }]; agent.state.messages = messages; expect(agent.state.messages).toEqual(messages); expect(agent.state.messages).not.toBe(messages); // Should be a copy - // Test appendMessage const newMessage = { role: "assistant" as const, content: [{ type: "text" as const, text: "Hi" }] }; agent.state.messages.push(newMessage as any); expect(agent.state.messages).toHaveLength(2); expect(agent.state.messages[1]).toBe(newMessage); - // Test clearMessages agent.state.messages = []; expect(agent.state.messages).toEqual([]); }); @@ -317,7 +306,6 @@ describe("Agent", () => { const message = { role: "user" as const, content: "Steering message", timestamp: Date.now() }; agent.steer(message); - // The message is queued but not yet in state.messages expect(agent.state.messages).not.toContainEqual(message); }); @@ -327,14 +315,12 @@ describe("Agent", () => { const message = { role: "user" as const, content: "Follow-up message", timestamp: Date.now() }; agent.followUp(message); - // The message is queued but not yet in state.messages expect(agent.state.messages).not.toContainEqual(message); }); it("should handle abort controller", () => { const agent = new Agent(); - // Should not throw even if nothing is running expect(() => agent.abort()).not.toThrow(); }); @@ -475,7 +461,6 @@ describe("Agent", () => { controller.signal, ); for await (const _event of stream) { - // Drain the stream. } expect(await stream.result()).toEqual([]); @@ -509,13 +494,11 @@ describe("Agent", () => { it("should throw when prompt() called while streaming", async () => { let abortSignal: AbortSignal | undefined; const agent = new Agent({ - // Use a stream function that responds to abort streamFn: (_model, _context, options) => { abortSignal = options?.signal; const stream = new MockAssistantStream(); queueMicrotask(() => { stream.push({ type: "start", partial: createAssistantMessage("") }); - // Check abort signal periodically const checkAbort = () => { if (abortSignal?.aborted) { stream.push({ type: "error", reason: "aborted", error: createAssistantMessage("Aborted") }); @@ -529,19 +512,15 @@ describe("Agent", () => { }, }); - // Start first prompt (don't await, it will block until abort) const firstPrompt = agent.prompt("First message"); - // Wait a tick for isStreaming to be set await new Promise((resolve) => setTimeout(resolve, 10)); expect(agent.state.isStreaming).toBe(true); - // Second prompt should reject await expect(agent.prompt("Second message")).rejects.toThrow( "Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.", ); - // Cleanup - abort to stop the stream agent.abort(); await firstPrompt.catch(() => {}); // Ignore abort error }); @@ -567,17 +546,16 @@ describe("Agent", () => { }, }); - // Start first prompt const firstPrompt = agent.prompt("First message"); await new Promise((resolve) => setTimeout(resolve, 10)); expect(agent.state.isStreaming).toBe(true); - // continue() should reject - await expect(agent.continue()).rejects.toThrow( - "Agent is already processing. Wait for completion before continuing.", - ); + await expect(agent.continue()).rejects.toMatchObject({ + name: "AgentContinueError", + code: "busy", + message: "Agent is already processing. Wait for completion before continuing.", + }); - // Cleanup agent.abort(); await firstPrompt.catch(() => {}); }); @@ -722,7 +700,6 @@ describe("Agent", () => { await agent.prompt("hello"); expect(receivedSessionId).toBe("session-abc"); - // Test setter agent.sessionId = "session-def"; expect(agent.sessionId).toBe("session-def"); diff --git a/packages/agent/test/e2e.test.ts b/packages/agent/test/e2e.test.ts index b75645b06c..b06838389d 100644 --- a/packages/agent/test/e2e.test.ts +++ b/packages/agent/test/e2e.test.ts @@ -268,7 +268,10 @@ describe("Agent.continue() with faux provider", () => { }, }); - await expect(agent.continue()).rejects.toThrow("No messages to continue from"); + await expect(agent.continue()).rejects.toMatchObject({ + code: "nothing-to-continue", + message: "No messages to continue from", + }); }); it("throws when last message is assistant", async () => { @@ -300,7 +303,10 @@ describe("Agent.continue() with faux provider", () => { }; agent.state.messages = [assistantMessage]; - await expect(agent.continue()).rejects.toThrow("Cannot continue from message role: assistant"); + await expect(agent.continue()).rejects.toMatchObject({ + code: "nothing-to-continue", + message: "Cannot continue from message role: assistant", + }); }); }); diff --git a/packages/ai/.changes/README.md b/packages/ai/.changes/README.md new file mode 100644 index 0000000000..86744da404 --- /dev/null +++ b/packages/ai/.changes/README.md @@ -0,0 +1,5 @@ +# Changelog fragments + +One `.md` per PR containing the bullet line(s) (e.g. `- Fixed ...`) that describe the change +for this package. `scripts/release.mjs` folds these into the release section of CHANGELOG.md and +deletes them. See CONTRIBUTING.md. diff --git a/packages/ai/.changes/res-1257-fable-claude-code-version.md b/packages/ai/.changes/res-1257-fable-claude-code-version.md new file mode 100644 index 0000000000..452c5f5cb5 --- /dev/null +++ b/packages/ai/.changes/res-1257-fable-claude-code-version.md @@ -0,0 +1 @@ +- Fixed Claude Fable 5.x failing over Anthropic OAuth with "Claude Code 2.1.75 does not support this model" by bumping the impersonated Claude Code version to 2.1.257 ([#1962](https://github.com/PrimeIntellect-ai/prime-agent/issues/1962)) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7aecc0d2dc..24dd15e059 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -1,30 +1,30 @@ # Changelog -## [Unreleased] +## [0.9.0] - 2026-09-01 -## [0.7.12] - 2026-08-19 +- Refreshed the model catalog from live provider catalogs (pricing updates, new and removed models); fixed OpenCode Go Qwen routes mislabeled as Anthropic and excluded private dev/ Prime Inference routes. +- Removed the `getOverflowPatterns()` test helper export from `utils/overflow.ts`; use `isContextOverflow()` directly. +- Fixed Anthropic-compatible prompt caching so the rolling cache marker advances to the latest tool result. -## [0.7.11] - 2026-08-18 +## [0.8.1] - 2026-08-26 -## [0.7.10] - 2026-08-18 +- Refreshed the generated model catalog from live provider catalogs: added GLM 5.3 (OpenRouter, Prime Inference), DeepSeek V4 Flash Vision Exp, and Inkling free routes; followed the Vercel AI Gateway `xai/` to `spacexai/` grok rename; picked up repricing for gpt-5.6-sol, Gemini 3.6 Flash, and others. +- Fixed OpenAI-compatible Chat Completions replay dropping opaque `reasoning_details` between turns. +- Refreshed the generated model catalog from live provider catalogs: models.dev rescoped `cloudflare-ai-gateway` to proxied third-party models (the `workers-ai/@cf/...` mirrors and legacy OpenAI ids are gone; Cloudflare-hosted models remain under `cloudflare-workers-ai`); added devstral-2512 and MiniMax M2.7/M3 free routes; picked up repricing for gpt-5.6/gpt-5.6-sol (5/30 -> 4/20), kimi-k2.6, glm-5.1/5.2, and deepseek-v4-pro. -## [0.7.9] - 2026-08-18 +## [0.8.0] - 2026-08-21 -- Fixed Cloudflare AI Gateway model generation to preserve provider-native Anthropic IDs when upstream catalog display IDs use dotted versions. +- Added endpoint binding to MCP OAuth credentials: tokens record the URL they were issued for, and refreshes carry the original binding forward without ever inferring one for unbound legacy credentials. +- Added Fast mode (service_tier `priority`) support for OpenAI API-key models GPT-5.4/GPT-5.5/GPT-5.6, and corrected the GPT-5.6 fast-pricing multiplier from 2.5x to 2x per OpenAI's pricing table ([#1595](https://github.com/PrimeIntellect-ai/prime-agent/discussions/1595)). +- Fixed path-scoped protected-resource discovery and resource-bound refresh for MCP OAuth servers. -## [0.7.8] - 2026-08-17 +## [0.7.4] - 2026-08-19 -- Fixed clean browser bundles of the Mistral provider by declaring its telemetry runtime dependency. +## [0.7.3] - 2026-08-17 -## [0.7.7] - 2026-08-17 - -## [0.7.6] - 2026-08-17 - -## [0.7.5] - 2026-08-17 - -## [0.7.4] - 2026-08-16 - -## [0.7.3] - 2026-08-15 +- Added provider-derived reasoning levels for OpenRouter and Prime Inference models, including sparse, mandatory, toggle-only, and explicit-off capabilities. +- Added Qwen 3.8 Max to the featured Prime Inference catalog ([#1247](https://github.com/PrimeIntellect-ai/prime-agent/pull/1247) by [@eliebak](https://github.com/eliebak)). +- Refreshed generated provider catalogs, removed retired routes, and aligned provider defaults and cross-provider handoff fixtures with models currently served. ## [0.7.2] - 2026-08-11 diff --git a/packages/ai/package.json b/packages/ai/package.json index 5f0849a6d5..3e8400cb5e 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "name": "@earendil-works/pi-ai", - "version": "0.7.12", + "version": "0.9.1", "description": "Unified LLM API with automatic model discovery and provider configuration", "type": "module", "main": "./dist/index.js", @@ -76,12 +76,11 @@ "@aws-sdk/client-bedrock-runtime": "^3.1095.0", "@google/genai": "^1.40.0", "@mistralai/mistralai": "^2.2.0", - "@opentelemetry/api": "^1.9.1", + "typebox": "^1.3.9", "chalk": "^5.6.2", "openai": "6.47.0", "partial-json": "^0.1.7", "proxy-agent": "^6.5.0", - "typebox": "^1.3.9", "undici": "^7.29.0", "zod-to-json-schema": "^3.24.6" }, diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index 503253632f..1b094c63d2 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -159,6 +159,9 @@ const PRIME_INFERENCE_MODEL_METADATA: Record[]> { // OpenCode Go endpoint behaviour. models.dev reports these models // as @ai-sdk/anthropic, but the OpenCode Go endpoints either don't // accept Anthropic SDK auth (MiniMax M2.7) or are served through - // the OpenAI-compatible /v1/chat/completions path (Qwen 3.5/3.6). + // the OpenAI-compatible /v1/chat/completions path (Qwen routes). // Switch them to openai-completions so requests use Bearer auth // and the standard /v1/chat/completions endpoint. if (variant.provider === "opencode-go") { - if (modelId === "minimax-m2.7") { + if (modelId === "minimax-m2.7" || (npm === "@ai-sdk/anthropic" && modelId.startsWith("qwen"))) { api = "openai-completions"; baseUrl = `${variant.basePath}/v1`; } diff --git a/packages/ai/scripts/generate-test-image.ts b/packages/ai/scripts/generate-test-image.ts index 29a473d463..7b50eeaae6 100644 --- a/packages/ai/scripts/generate-test-image.ts +++ b/packages/ai/scripts/generate-test-image.ts @@ -8,25 +8,20 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Create a 200x200 canvas const canvas = createCanvas(200, 200); const ctx = canvas.getContext("2d"); -// Fill background with white ctx.fillStyle = "white"; ctx.fillRect(0, 0, 200, 200); -// Draw a red circle in the center ctx.fillStyle = "red"; ctx.beginPath(); ctx.arc(100, 100, 50, 0, Math.PI * 2); ctx.fill(); -// Save the image const buffer = canvas.toBuffer("image/png"); const outputPath = join(__dirname, "..", "test", "data", "red-circle.png"); -// Ensure the directory exists import { mkdirSync } from "fs"; mkdirSync(join(__dirname, "..", "test", "data"), { recursive: true }); diff --git a/packages/ai/src/env-api-keys.ts b/packages/ai/src/env-api-keys.ts index c52f92f5de..eaeae11a84 100644 --- a/packages/ai/src/env-api-keys.ts +++ b/packages/ai/src/env-api-keys.ts @@ -16,7 +16,6 @@ const NODE_FS_SPECIFIER = "node:" + "fs"; const NODE_OS_SPECIFIER = "node:" + "os"; const NODE_PATH_SPECIFIER = "node:" + "path"; -// Eagerly load in Node.js/Bun environment only if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { dynamicImport(NODE_FS_SPECIFIER).then((m) => { _existsSync = (m as { existsSync: typeof existsSync }).existsSync; @@ -78,12 +77,10 @@ function hasVertexAdcCredentials(): boolean { return false; } - // Check GOOGLE_APPLICATION_CREDENTIALS env var first (standard way) const gacPath = process.env.GOOGLE_APPLICATION_CREDENTIALS || getProcEnv("GOOGLE_APPLICATION_CREDENTIALS"); if (gacPath) { cachedVertexAdcCredentialsExists = _existsSync(gacPath); } else { - // Fall back to default ADC path (lazy evaluation) cachedVertexAdcCredentialsExists = _existsSync( _join(_homedir(), ".config", "gcloud", "application_default_credentials.json"), ); @@ -167,8 +164,6 @@ export function getEnvApiKey(provider: string): string | undefined { return process.env[envKeys[0]] || getProcEnv(envKeys[0]); } - // Vertex AI supports either an explicit API key or Application Default Credentials. - // Auth is configured via `gcloud auth application-default login`. if (provider === "google-vertex") { const hasCredentials = hasVertexAdcCredentials(); const hasProject = !!( @@ -185,13 +180,6 @@ export function getEnvApiKey(provider: string): string | undefined { } if (provider === "amazon-bedrock") { - // Amazon Bedrock supports multiple credential sources: - // 1. AWS_PROFILE - named profile from ~/.aws/credentials - // 2. AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY - standard IAM keys - // 3. AWS_BEARER_TOKEN_BEDROCK - Bedrock bearer token - // 4. AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - ECS task roles - // 5. AWS_CONTAINER_CREDENTIALS_FULL_URI - ECS task roles (full URI) - // 6. AWS_WEB_IDENTITY_TOKEN_FILE - IRSA (IAM Roles for Service Accounts) if ( process.env.AWS_PROFILE || (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) || @@ -213,7 +201,6 @@ export function getEnvApiKey(provider: string): string | undefined { return undefined; } -// PRIME_TEAM_ID env var, falling back to team_id in ~/.prime/config.json. export function getPrimeTeamId(): string | undefined { const fromEnv = process.env.PRIME_TEAM_ID || getProcEnv("PRIME_TEAM_ID"); if (fromEnv?.trim()) return fromEnv.trim(); @@ -228,7 +215,7 @@ export function getPrimeTeamId(): string | undefined { if (typeof teamId === "string" && teamId.trim()) return teamId.trim(); } } catch { - // Unreadable/malformed config.json: behave as if no team is configured. + // Treat unreadable or malformed config as no configured team. } return undefined; } diff --git a/packages/ai/src/mcp/oauth.ts b/packages/ai/src/mcp/oauth.ts index 092add6d2e..525d6b4fb1 100644 --- a/packages/ai/src/mcp/oauth.ts +++ b/packages/ai/src/mcp/oauth.ts @@ -1,6 +1,3 @@ -// Generic OAuth 2.1 (PKCE + dynamic client registration) for remote MCP servers. -// One provider per server, registered as `mcp:` so it reuses auth.json. Node-only (callback server). - import type { Server } from "node:http"; import { oauthErrorHtml, oauthSuccessHtml } from "../utils/oauth/oauth-page.js"; import { generatePKCE } from "../utils/oauth/pkce.js"; @@ -17,78 +14,212 @@ const redirectUriFor = (port: number) => `http://localhost:${port}${CALLBACK_PAT const ALL_REDIRECT_URIS = CALLBACK_PORTS.map(redirectUriFor); const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000; -/** Authorization-server metadata we rely on (RFC 8414 / OAuth 2.1 + DCR). */ interface AuthServerMetadata { - issuer?: string; + issuer: string; authorization_endpoint: string; token_endpoint: string; registration_endpoint?: string; scopes_supported?: string[]; } +interface ProtectedResourceMetadata { + resource: string; + authorization_servers: string[]; +} + +interface Discovery { + metadata: AuthServerMetadata; + resource?: string; + issuer?: string; +} + export interface McpOAuthConfig { /** MCP server name; provider id becomes `mcp:`. */ server: string; - /** Human label for UI. */ + /** Human-readable label shown in OAuth UI; defaults to `server`. */ label?: string; - /** The MCP endpoint URL — discovery is rooted at its origin. */ + /** MCP resource URL used for protected-resource and authorization-server discovery. */ url: string; /** Pre-registered client id (servers without DCR, e.g. Slack). */ clientId?: string; - /** Explicit scopes; falls back to the server's advertised scopes. */ + /** Requested OAuth scopes; defaults to the server's advertised scopes. */ scopes?: string; } -/** Extra fields we persist alongside the standard credential triple. */ interface McpCredentials extends OAuthCredentials { tokenEndpoint?: string; clientId?: string; + /** MCP endpoint the token was issued for; consumers refuse to send it elsewhere. */ + endpoint?: string; + /** RFC 9728 resource indicator. Its presence marks a PRM-based login. */ + resource?: string; + /** RFC 8414/OIDC issuer selected by the protected-resource metadata. */ + issuer?: string; +} + +function validatedHttpsUrl(value: string, name: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error(`${name} must be an absolute HTTPS URL`); + } + if (url.protocol !== "https:" || url.username || url.password || url.hash) { + throw new Error(`${name} must be an absolute HTTPS URL without credentials or a fragment`); + } + return url; +} + +function canonicalResource(url: URL): string { + if (url.pathname === "/" && !url.search) return url.origin; + return `${url.origin}${url.pathname}${url.search}`; +} + +function authorizationServerMetadataUrls(issuer: string): string[] { + const url = validatedHttpsUrl(issuer, "Authorization server issuer"); + if (url.search) throw new Error("Authorization server issuer must not contain a query string"); + const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, ""); + return [ + new URL(`/.well-known/oauth-authorization-server${path}`, url.origin).toString(), + new URL(`${path}/.well-known/openid-configuration`, url.origin).toString(), + ]; +} + +async function fetchResponse(url: string, init?: RequestInit): Promise { + return fetch(url, { ...init, redirect: "error" }); } async function fetchJson(url: string, init?: RequestInit): Promise { - const res = await fetch(url, init); + const res = await fetchResponse(url, init); if (!res.ok) { - throw new Error(`${init?.method ?? "GET"} ${url} failed: ${res.status} ${await res.text()}`); + throw new Error(`${init?.method ?? "GET"} ${url} failed: ${res.status}`); } return res.json(); } -/** Random, URL-safe CSRF `state` value, independent of the PKCE verifier. */ -function randomState(): string { - const bytes = new Uint8Array(32); - crypto.getRandomValues(bytes); - return btoa(String.fromCharCode(...bytes)) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); +function authorizationServerMetadata(value: unknown, issuer: string, requireExactIssuer: boolean): AuthServerMetadata { + if (!value || typeof value !== "object") throw new Error(`Authorization server metadata for ${issuer} is invalid`); + const metadata = value as Partial; + if (typeof metadata.issuer !== "string") { + throw new Error(`Authorization server metadata for ${issuer} is missing its issuer`); + } + if (requireExactIssuer) { + if (metadata.issuer !== issuer) { + throw new Error(`Authorization server metadata issuer does not exactly match ${issuer}`); + } + } else { + const advertisedIssuer = validatedHttpsUrl(metadata.issuer, "Authorization server metadata issuer"); + if (advertisedIssuer.origin !== new URL(issuer).origin || advertisedIssuer.search) { + throw new Error(`Origin authorization server metadata issuer must stay on ${new URL(issuer).origin}`); + } + } + if (typeof metadata.authorization_endpoint !== "string" || typeof metadata.token_endpoint !== "string") { + throw new Error(`Authorization server metadata for ${issuer} is missing required endpoints`); + } + validatedHttpsUrl(metadata.authorization_endpoint, "Authorization endpoint"); + validatedHttpsUrl(metadata.token_endpoint, "Token endpoint"); + if (metadata.registration_endpoint) validatedHttpsUrl(metadata.registration_endpoint, "Registration endpoint"); + return metadata as AuthServerMetadata; } -/** Try the protected-resource and auth-server well-known docs at the URL's origin. */ -async function discover(url: string): Promise { - const origin = new URL(url).origin; - const candidates = [ - `${origin}/.well-known/oauth-authorization-server`, - `${origin}/.well-known/openid-configuration`, - ]; +async function jsonMetadata(response: Response, url: string): Promise { + if (response.status !== 200) throw new Error(`GET ${url} failed: ${response.status}`); + const contentType = response.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase(); + if (contentType !== "application/json") throw new Error(`GET ${url} did not return application/json`); + return response.json(); +} + +async function discoverAuthorizationServer(issuer: string, requireExactIssuer: boolean): Promise { + const candidates = authorizationServerMetadataUrls(issuer); let lastError: unknown; for (const candidate of candidates) { try { - const meta = (await fetchJson(candidate)) as AuthServerMetadata; - if (meta.authorization_endpoint && meta.token_endpoint) { - return meta; - } + const response = await fetchResponse(candidate); + if (response.status === 404) continue; + return authorizationServerMetadata(await jsonMetadata(response, candidate), issuer, requireExactIssuer); } catch (error) { lastError = error; } } throw new Error( - `Could not discover OAuth metadata for ${origin}. ` + - `Tried ${candidates.join(", ")}. Last error: ${String(lastError)}`, + `Could not discover OAuth metadata for ${issuer}. Tried ${candidates.join(", ")}. Last error: ${String(lastError)}`, ); } -/** Dynamic client registration (RFC 7591). Returns the issued client_id. */ +/** Random, URL-safe CSRF `state` value, independent of the PKCE verifier. */ +function randomState(): string { + const bytes = new Uint8Array(32); + crypto.getRandomValues(bytes); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} + +function resourceMetadata(value: unknown, resource: string): ProtectedResourceMetadata { + if (!value || typeof value !== "object") throw new Error("Protected-resource metadata is invalid"); + const metadata = value as Partial; + if (metadata.resource !== resource) + throw new Error(`Protected-resource metadata resource does not exactly match ${resource}`); + if (!Array.isArray(metadata.authorization_servers) || metadata.authorization_servers.length === 0) { + throw new Error("Protected-resource metadata has no authorization_servers"); + } + for (const issuer of metadata.authorization_servers) { + if (typeof issuer !== "string") + throw new Error("Protected-resource metadata has an invalid authorization server"); + validatedHttpsUrl(issuer, "Authorization server issuer"); + } + return metadata as ProtectedResourceMetadata; +} + +function resourceMetadataUrl(resource: URL): string { + const path = resource.pathname === "/" ? "" : resource.pathname; + return `${resource.origin}/.well-known/oauth-protected-resource${path}${resource.search}`; +} + +function headerResourceMetadata(value: string | null): string | undefined { + const match = value?.match(/(?:^|[,\s])resource_metadata\s*=\s*"((?:[^"\\]|\\.)*)"/i); + if (!match) return undefined; + return match[1].replace(/\\(.)/g, "$1"); +} + +async function tryProtectedResourceMetadata(url: string): Promise { + const resource = validatedHttpsUrl(url, "MCP endpoint"); + let headerUrl: string | undefined; + try { + // This probe deliberately has no Authorization header. It must not leak an existing token. + const response = await fetchResponse(resource.toString()); + headerUrl = headerResourceMetadata(response.headers.get("www-authenticate")); + await response.body?.cancel(); + } catch { + // The server need not support a GET probe; use the RFC well-known locations below. + } + + const candidate = headerUrl + ? validatedHttpsUrl(headerUrl, "resource_metadata").toString() + : resourceMetadataUrl(resource); + const response = await fetchResponse(candidate); + if (response.status === 404 && !headerUrl) return undefined; + return resourceMetadata(await jsonMetadata(response, candidate), canonicalResource(resource)); +} + +/** Discover RFC 9728 protected-resource metadata before the origin-level authorization server fallback. */ +async function discover(url: string): Promise { + const protectedResource = await tryProtectedResourceMetadata(url); + if (protectedResource) { + const issuer = protectedResource.authorization_servers[0]; + return { + metadata: await discoverAuthorizationServer(issuer, true), + resource: protectedResource.resource, + issuer, + }; + } + const issuer = validatedHttpsUrl(url, "MCP endpoint").origin; + return { metadata: await discoverAuthorizationServer(issuer, false) }; +} + async function registerClient(registrationEndpoint: string, label: string): Promise { + validatedHttpsUrl(registrationEndpoint, "Registration endpoint"); const body = { client_name: label, redirect_uris: ALL_REDIRECT_URIS, @@ -100,8 +231,8 @@ async function registerClient(registrationEndpoint: string, label: string): Prom method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), - })) as { client_id?: string }; - if (!data.client_id) { + })) as { client_id?: unknown }; + if (typeof data.client_id !== "string" || !data.client_id) { throw new Error(`Dynamic client registration at ${registrationEndpoint} returned no client_id`); } return data.client_id; @@ -216,22 +347,46 @@ async function exchangeToken( tokenEndpoint: string, params: Record, ): Promise<{ access_token: string; refresh_token?: string; expires_in?: number }> { - const res = await fetch(tokenEndpoint, { + validatedHttpsUrl(tokenEndpoint, "Token endpoint"); + const res = await fetchResponse(tokenEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(params).toString(), }); const text = await res.text(); if (!res.ok) { - throw new Error(`Token request to ${tokenEndpoint} failed: ${res.status} ${text}`); + throw new Error(`Token request to ${tokenEndpoint} failed: ${res.status}`); + } + let token: unknown; + try { + token = JSON.parse(text); + } catch { + throw new Error(`Token request to ${tokenEndpoint} returned invalid JSON`); + } + if (!token || typeof token !== "object" || typeof (token as { access_token?: unknown }).access_token !== "string") { + throw new Error(`Token request to ${tokenEndpoint} returned no access_token`); + } + const result = token as { access_token: string; refresh_token?: unknown; expires_in?: unknown }; + if (!result.access_token) throw new Error(`Token request to ${tokenEndpoint} returned no access_token`); + if (result.refresh_token !== undefined && typeof result.refresh_token !== "string") { + throw new Error(`Token request to ${tokenEndpoint} returned an invalid refresh_token`); } - return JSON.parse(text); + if ( + result.expires_in !== undefined && + (typeof result.expires_in !== "number" || !Number.isFinite(result.expires_in)) + ) { + throw new Error(`Token request to ${tokenEndpoint} returned an invalid expires_in`); + } + return result as { access_token: string; refresh_token?: string; expires_in?: number }; } function toCredentials( token: { access_token: string; refresh_token?: string; expires_in?: number }, tokenEndpoint: string, clientId: string, + endpoint: string | undefined, + resource: string | undefined, + issuer: string | undefined, previousRefresh?: string, ): McpCredentials { return { @@ -243,16 +398,19 @@ function toCredentials( : Date.now() + 3600 * 1000 - TOKEN_EXPIRY_BUFFER_MS, tokenEndpoint, clientId, + endpoint, + resource, + issuer, }; } -/** Build a provider for one MCP server. Register it with registerOAuthProvider(). */ export function createMcpOAuthProvider(config: McpOAuthConfig): OAuthProviderInterface { const label = config.label ?? config.server; async function login(callbacks: OAuthLoginCallbacks): Promise { - const meta = await discover(config.url); - callbacks.onProgress?.(`Discovered ${meta.issuer ?? new URL(config.url).origin}`); + const discovery = await discover(config.url); + const { metadata: meta } = discovery; + callbacks.onProgress?.(`Discovered ${discovery.issuer ?? meta.issuer}`); let clientId = config.clientId; if (!clientId) { @@ -282,9 +440,12 @@ export function createMcpOAuthProvider(config: McpOAuthConfig): OAuthProviderInt state, }); if (scope) authParams.set("scope", scope); + if (discovery.resource) authParams.set("resource", discovery.resource); + const authorizationUrl = new URL(meta.authorization_endpoint); + for (const [name, value] of authParams) authorizationUrl.searchParams.set(name, value); callbacks.onAuth({ - url: `${meta.authorization_endpoint}?${authParams.toString()}`, + url: authorizationUrl.toString(), instructions: "Complete login in your browser. If the browser is on another machine, paste the final redirect URL here.", }); @@ -347,8 +508,9 @@ export function createMcpOAuthProvider(config: McpOAuthConfig): OAuthProviderInt redirect_uri: cb.redirectUri, client_id: clientId, code_verifier: verifier, + ...(discovery.resource ? { resource: discovery.resource } : {}), }); - return toCredentials(token, meta.token_endpoint, clientId); + return toCredentials(token, meta.token_endpoint, clientId, config.url, discovery.resource, discovery.issuer); } finally { cb.server.close(); } @@ -356,17 +518,58 @@ export function createMcpOAuthProvider(config: McpOAuthConfig): OAuthProviderInt async function refreshToken(credentials: OAuthCredentials): Promise { const creds = credentials as McpCredentials; - const tokenEndpoint = creds.tokenEndpoint ?? (await discover(config.url)).token_endpoint; - const clientId = creds.clientId ?? config.clientId; + if (creds.endpoint !== config.url) { + throw new Error(`Stored OAuth credentials are not bound to ${config.url}; re-run /mcp login ${config.server}`); + } + const configuredResource = canonicalResource(validatedHttpsUrl(config.url, "MCP endpoint")); + if (creds.resource !== undefined && creds.resource !== configuredResource) { + throw new Error( + `Stored OAuth credentials are not bound to ${configuredResource}; re-run /mcp login ${config.server}`, + ); + } + if ((creds.resource === undefined) !== (creds.issuer === undefined)) { + throw new Error( + `Stored OAuth credentials for ${label} have incomplete resource binding; re-run /mcp login ${config.server}`, + ); + } + if (creds.issuer !== undefined) validatedHttpsUrl(creds.issuer, "Stored authorization server issuer"); if (!creds.refresh) { throw new Error(`No refresh token stored for ${label}; re-run /mcp login ${config.server}`); } + const discovery = await discover(config.url); + if ((creds.resource === undefined) !== (discovery.resource === undefined)) { + throw new Error(`OAuth discovery mode changed for ${config.url}; re-run /mcp login ${config.server}`); + } + if (creds.resource) { + if (discovery.resource !== creds.resource || discovery.issuer !== creds.issuer) { + throw new Error( + `Stored OAuth credentials do not match current protected-resource metadata for ${config.url}`, + ); + } + } + const tokenEndpoint = creds.tokenEndpoint ?? discovery.metadata.token_endpoint; + if (creds.tokenEndpoint && discovery.metadata.token_endpoint !== creds.tokenEndpoint) { + throw new Error( + `Stored OAuth token endpoint does not match current authorization-server metadata for ${config.url}`, + ); + } + const clientId = creds.clientId ?? config.clientId; + if (!tokenEndpoint) throw new Error(`No token endpoint stored for ${label}; re-run /mcp login ${config.server}`); const token = await exchangeToken(tokenEndpoint, { grant_type: "refresh_token", refresh_token: creds.refresh, ...(clientId ? { client_id: clientId } : {}), + ...(creds.resource ? { resource: creds.resource } : {}), }); - return toCredentials(token, tokenEndpoint, clientId ?? "", creds.refresh); + return toCredentials( + token, + tokenEndpoint, + clientId ?? "", + creds.endpoint, + creds.resource, + creds.issuer, + creds.refresh, + ); } return { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index aef5451787..a3945be9a1 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -796,6 +796,60 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, + "global.openai.gpt-5.6-luna": { + id: "global.openai.gpt-5.6-luna", + name: "GPT-5.6 Luna (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "global.openai.gpt-5.6-sol": { + id: "global.openai.gpt-5.6-sol", + name: "GPT-5.6 Sol (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 4, + output: 20, + cacheRead: 0.4, + cacheWrite: 5, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, + "global.openai.gpt-5.6-terra": { + id: "global.openai.gpt-5.6-terra", + name: "GPT-5.6 Terra (Global)", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 2.5, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"bedrock-converse-stream">, "google.gemma-3-27b-it": { id: "google.gemma-3-27b-it", name: "Google Gemma 3 27B Instruct", @@ -1396,7 +1450,7 @@ export const MODELS = { cacheRead: 0.022, cacheWrite: 0.275, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, "openai.gpt-5.6-sol": { @@ -1409,12 +1463,12 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5.5, - output: 33, - cacheRead: 0.55, - cacheWrite: 6.88, + input: 4.4, + output: 22, + cacheRead: 0.44, + cacheWrite: 5.5, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, "openai.gpt-5.6-terra": { @@ -1432,7 +1486,7 @@ export const MODELS = { cacheRead: 0.22, cacheWrite: 2.75, }, - contextWindow: 272000, + contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"bedrock-converse-stream">, "openai.gpt-oss-120b": { @@ -1952,6 +2006,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"bedrock-converse-stream">, + "xai.grok-4.6": { + id: "xai.grok-4.6", + name: "Grok 4.6", + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2.2, + output: 6.6, + cacheRead: 0.55, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 500000, + } satisfies Model<"bedrock-converse-stream">, "zai.glm-4.7": { id: "zai.glm-4.7", name: "GLM-4.7", @@ -2057,40 +2128,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-opus-4-1": { - id: "claude-opus-4-1", - name: "Claude Opus 4.1 (latest)", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-1-20250805": { - id: "claude-opus-4-1-20250805", - name: "Claude Opus 4.1", - api: "anthropic-messages", - provider: "anthropic", - baseUrl: "https://api.anthropic.com", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, "claude-opus-4-5": { id: "claude-opus-4-5", name: "Claude Opus 4.5 (latest)", @@ -2809,10 +2846,10 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 4, + output: 20, + cacheRead: 0.4, + cacheWrite: 5, }, contextWindow: 1050000, maxTokens: 128000, @@ -2845,10 +2882,10 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 4, + output: 20, + cacheRead: 0.4, + cacheWrite: 5, }, contextWindow: 1050000, maxTokens: 128000, @@ -3026,127 +3063,8 @@ export const MODELS = { contextWindow: 131072, maxTokens: 40960, } satisfies Model<"openai-completions">, - "zai-glm-4.7": { - id: "zai-glm-4.7", - name: "Z.AI GLM-4.7", - api: "openai-completions", - provider: "cerebras", - baseUrl: "https://api.cerebras.ai/v1", - reasoning: true, - input: ["text"], - cost: { - input: 2.25, - output: 2.75, - cacheRead: 2.25, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 40960, - } satisfies Model<"openai-completions">, }, "cloudflare-ai-gateway": { - "claude-3-5-haiku": { - id: "claude-3-5-haiku", - name: "Claude Haiku 3.5 (latest)", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.8, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "claude-3-haiku": { - id: "claude-3-haiku", - name: "Claude Haiku 3", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.25, - output: 1.25, - cacheRead: 0.03, - cacheWrite: 0.3, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"anthropic-messages">, - "claude-3-opus": { - id: "claude-3-opus", - name: "Claude Opus 3", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: false, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"anthropic-messages">, - "claude-3-sonnet": { - id: "claude-3-sonnet", - name: "Claude Sonnet 3", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 0.3, - }, - contextWindow: 200000, - maxTokens: 4096, - } satisfies Model<"anthropic-messages">, - "claude-3.5-haiku": { - id: "claude-3.5-haiku", - name: "Claude Haiku 3.5 (latest)", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.8, - output: 4, - cacheRead: 0.08, - cacheWrite: 1, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, - "claude-3.5-sonnet": { - id: "claude-3.5-sonnet", - name: "Claude Sonnet 3.5 v2", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: false, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, - maxTokens: 8192, - } satisfies Model<"anthropic-messages">, "claude-fable-5": { id: "claude-fable-5", name: "Claude Fable 5", @@ -3165,8 +3083,8 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "claude-haiku-4-5": { - id: "claude-haiku-4-5", + "claude-haiku-4.5": { + id: "claude-haiku-4.5", name: "Claude Haiku 4.5 (latest)", api: "anthropic-messages", provider: "cloudflare-ai-gateway", @@ -3182,42 +3100,8 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-opus-4": { - id: "claude-opus-4", - name: "Claude Opus 4 (latest)", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-1": { - id: "claude-opus-4-1", - name: "Claude Opus 4.1 (latest)", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, - "claude-opus-4-5": { - id: "claude-opus-4-5", + "claude-opus-4.5": { + id: "claude-opus-4.5", name: "Claude Opus 4.5 (latest)", api: "anthropic-messages", provider: "cloudflare-ai-gateway", @@ -3233,9 +3117,9 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-opus-4-6": { - id: "claude-opus-4-6", - name: "Claude Opus 4.6 (latest)", + "claude-opus-4.6": { + id: "claude-opus-4.6", + name: "Claude Opus 4.6", api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", @@ -3251,8 +3135,8 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "claude-opus-4-7": { - id: "claude-opus-4-7", + "claude-opus-4.7": { + id: "claude-opus-4.7", name: "Claude Opus 4.7", api: "anthropic-messages", provider: "cloudflare-ai-gateway", @@ -3269,8 +3153,8 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "claude-opus-4-8": { - id: "claude-opus-4-8", + "claude-opus-4.8": { + id: "claude-opus-4.8", name: "Claude Opus 4.8", api: "anthropic-messages", provider: "cloudflare-ai-gateway", @@ -3305,9 +3189,9 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "claude-sonnet-4": { - id: "claude-sonnet-4", - name: "Claude Sonnet 4 (latest)", + "claude-sonnet-4.5": { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5 (latest)", api: "anthropic-messages", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", @@ -3319,28 +3203,11 @@ export const MODELS = { cacheRead: 0.3, cacheWrite: 3.75, }, - contextWindow: 200000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "claude-sonnet-4-5": { - id: "claude-sonnet-4-5", - name: "Claude Sonnet 4.5 (latest)", - api: "anthropic-messages", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic", - reasoning: true, - input: ["text", "image"], - cost: { - input: 3, - output: 15, - cacheRead: 0.3, - cacheWrite: 3.75, - }, - contextWindow: 200000, + contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-sonnet-4-6": { - id: "claude-sonnet-4-6", + "claude-sonnet-4.6": { + id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", api: "anthropic-messages", provider: "cloudflare-ai-gateway", @@ -3355,7 +3222,7 @@ export const MODELS = { cacheWrite: 3.75, }, contextWindow: 1000000, - maxTokens: 64000, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, "claude-sonnet-5": { id: "claude-sonnet-5", @@ -3375,39 +3242,56 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "gpt-4": { - id: "gpt-4", - name: "GPT-4", + "gpt-4.1": { + id: "gpt-4.1", + name: "GPT-4.1", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", reasoning: false, - input: ["text"], + input: ["text", "image"], cost: { - input: 30, - output: 60, - cacheRead: 0, + input: 2, + output: 8, + cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 8192, - maxTokens: 8192, + contextWindow: 1047576, + maxTokens: 32768, } satisfies Model<"openai-responses">, - "gpt-4-turbo": { - id: "gpt-4-turbo", - name: "GPT-4 Turbo", + "gpt-4.1-mini": { + id: "gpt-4.1-mini", + name: "GPT-4.1 mini", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", reasoning: false, input: ["text", "image"], cost: { - input: 10, - output: 30, - cacheRead: 0, + input: 0.4, + output: 1.6, + cacheRead: 0.1, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"openai-responses">, + "gpt-4.1-nano": { + id: "gpt-4.1-nano", + name: "GPT-4.1 nano", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.4, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 32768, } satisfies Model<"openai-responses">, "gpt-4o": { id: "gpt-4o", @@ -3418,9 +3302,9 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 2.5, - output: 10, - cacheRead: 1.25, + input: 1.25, + output: 5, + cacheRead: 0.625, cacheWrite: 0, }, contextWindow: 128000, @@ -3435,17 +3319,17 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.15, - output: 0.6, - cacheRead: 0.08, + input: 0.075, + output: 0.3, + cacheRead: 0.0375, cacheWrite: 0, }, contextWindow: 128000, maxTokens: 16384, } satisfies Model<"openai-responses">, - "gpt-5.1": { - id: "gpt-5.1", - name: "GPT-5.1", + "gpt-5": { + id: "gpt-5", + name: "GPT-5", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", @@ -3455,15 +3339,51 @@ export const MODELS = { cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 400000, + contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "gpt-5.1-codex": { - id: "gpt-5.1-codex", - name: "GPT-5.1 Codex", + "gpt-5-mini": { + id: "gpt-5-mini", + name: "GPT-5 Mini", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.25, + output: 2, + cacheRead: 0.025, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5-nano": { + id: "gpt-5-nano", + name: "GPT-5 Nano", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.05, + output: 0.4, + cacheRead: 0.005, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.1": { + id: "gpt-5.1", + name: "GPT-5.1", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", @@ -3476,12 +3396,12 @@ export const MODELS = { cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 400000, + contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "gpt-5.2": { - id: "gpt-5.2", - name: "GPT-5.2", + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", @@ -3489,17 +3409,17 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, + input: 2.5, + output: 15, + cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 400000, + contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "gpt-5.2-codex": { - id: "gpt-5.2-codex", - name: "GPT-5.2 Codex", + "gpt-5.4-mini": { + id: "gpt-5.4-mini", + name: "GPT-5.4 mini", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", @@ -3507,17 +3427,17 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, + input: 0.75, + output: 4.5, + cacheRead: 0.075, cacheWrite: 0, }, - contextWindow: 400000, + contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "gpt-5.3-codex": { - id: "gpt-5.3-codex", - name: "GPT-5.3 Codex", + "gpt-5.4-nano": { + id: "gpt-5.4-nano", + name: "GPT-5.4 nano", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", @@ -3525,17 +3445,17 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, + input: 0.2, + output: 1.25, + cacheRead: 0.02, cacheWrite: 0, }, - contextWindow: 400000, + contextWindow: 128000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "gpt-5.4": { - id: "gpt-5.4", - name: "GPT-5.4", + "gpt-5.4-pro": { + id: "gpt-5.4-pro", + name: "GPT-5.4 Pro", api: "openai-responses", provider: "cloudflare-ai-gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", @@ -3543,12 +3463,12 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 2.5, - output: 15, - cacheRead: 0.25, + input: 30, + output: 180, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1050000, + contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.5": { @@ -3563,10 +3483,28 @@ export const MODELS = { cost: { input: 5, output: 30, - cacheRead: 0.5, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1050000, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"openai-responses">, + "gpt-5.5-pro": { + id: "gpt-5.5-pro", + name: "GPT-5.5 Pro", + api: "openai-responses", + provider: "cloudflare-ai-gateway", + baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", + reasoning: true, + thinkingLevelMap: {"off":null,"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 30, + output: 180, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-responses">, "gpt-5.6-luna": { @@ -3579,10 +3517,10 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 1, - output: 6, - cacheRead: 0.1, - cacheWrite: 0, + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, }, contextWindow: 1050000, maxTokens: 128000, @@ -3597,10 +3535,10 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 0, + input: 2, + output: 10, + cacheRead: 0.25, + cacheWrite: 3.125, }, contextWindow: 1050000, maxTokens: 128000, @@ -3615,31 +3553,14 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 2.5, - output: 15, - cacheRead: 0.25, - cacheWrite: 0, + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "o1": { - id: "o1", - name: "o1", - api: "openai-responses", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 60, - cacheRead: 7.5, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, "o3": { id: "o3", name: "o3", @@ -3674,23 +3595,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 100000, } satisfies Model<"openai-responses">, - "o3-pro": { - id: "o3-pro", - name: "o3-pro", - api: "openai-responses", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai", - reasoning: true, - input: ["text", "image"], - cost: { - input: 20, - output: 80, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 100000, - } satisfies Model<"openai-responses">, "o4-mini": { id: "o4-mini", name: "o4-mini", @@ -3702,104 +3606,52 @@ export const MODELS = { cost: { input: 1.1, output: 4.4, - cacheRead: 0.28, + cacheRead: 0.275, cacheWrite: 0, }, contextWindow: 200000, maxTokens: 100000, } satisfies Model<"openai-responses">, - "workers-ai/@cf/moonshotai/kimi-k2.5": { - id: "workers-ai/@cf/moonshotai/kimi-k2.5", - name: "Kimi K2.5", - api: "openai-completions", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", - compat: {"sendSessionAffinityHeaders":true}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.6, - output: 3, - cacheRead: 0.1, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"openai-completions">, - "workers-ai/@cf/moonshotai/kimi-k2.6": { - id: "workers-ai/@cf/moonshotai/kimi-k2.6", - name: "Kimi K2.6", - api: "openai-completions", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", - compat: {"sendSessionAffinityHeaders":true}, - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.95, - output: 4, - cacheRead: 0.16, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"openai-completions">, - "workers-ai/@cf/nvidia/nemotron-3-120b-a12b": { - id: "workers-ai/@cf/nvidia/nemotron-3-120b-a12b", - name: "Nemotron 3 Super 120B", - api: "openai-completions", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", - compat: {"sendSessionAffinityHeaders":true}, - reasoning: true, - input: ["text"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"openai-completions">, - "workers-ai/@cf/zai-org/glm-4.7-flash": { - id: "workers-ai/@cf/zai-org/glm-4.7-flash", - name: "GLM-4.7-Flash", + }, + "cloudflare-workers-ai": { + "@cf/deepseek-ai/deepseek-v4-flash-0731": { + id: "@cf/deepseek-ai/deepseek-v4-flash-0731", + name: "DeepSeek V4 Flash 0731", api: "openai-completions", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", - compat: {"sendSessionAffinityHeaders":true}, + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.06, - output: 0.4, - cacheRead: 0, + input: 0.44, + output: 1.32, + cacheRead: 0.014, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 131072, + contextWindow: 1310720, + maxTokens: 1048576, } satisfies Model<"openai-completions">, - "workers-ai/@cf/zai-org/glm-5.2": { - id: "workers-ai/@cf/zai-org/glm-5.2", - name: "Glm 5.2", + "@cf/deepseek-ai/deepseek-v4-pro-0813": { + id: "@cf/deepseek-ai/deepseek-v4-pro-0813", + name: "DeepSeek V4 Pro 0813", api: "openai-completions", - provider: "cloudflare-ai-gateway", - baseUrl: "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat", - compat: {"sendSessionAffinityHeaders":true}, + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true,"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 1.4, - output: 4.4, - cacheRead: 0.26, + input: 1.32, + output: 3.96, + cacheRead: 0.044, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, + contextWindow: 1048576, + maxTokens: 1048576, } satisfies Model<"openai-completions">, - }, - "cloudflare-workers-ai": { "@cf/google/gemma-4-26b-a4b-it": { id: "@cf/google/gemma-4-26b-a4b-it", name: "Gemma 4 26B A4B IT", @@ -3998,6 +3850,24 @@ export const MODELS = { contextWindow: 32768, maxTokens: 32768, } satisfies Model<"openai-completions">, + "@cf/qwen/qwen3.8-27b": { + id: "@cf/qwen/qwen3.8-27b", + name: "Qwen3.8 27B", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.45, + output: 3.2, + cacheRead: 0.05, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 262144, + } satisfies Model<"openai-completions">, "@cf/zai-org/glm-4.7-flash": { id: "@cf/zai-org/glm-4.7-flash", name: "GLM-4.7-Flash", @@ -4032,7 +3902,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 256000, + } satisfies Model<"openai-completions">, + "@cf/zai-org/glm-5.3-flash": { + id: "@cf/zai-org/glm-5.3-flash", + name: "Glm 5.3 Flash", + api: "openai-completions", + provider: "cloudflare-workers-ai", + baseUrl: "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1", + compat: {"sendSessionAffinityHeaders":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1310720, + maxTokens: 1310720, } satisfies Model<"openai-completions">, }, "deepseek": { @@ -4044,7 +3932,7 @@ export const MODELS = { baseUrl: "https://api.deepseek.com", compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { input: 0.14, @@ -4063,7 +3951,7 @@ export const MODELS = { baseUrl: "https://api.deepseek.com", compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { input: 0.435, @@ -4110,18 +3998,18 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 384000, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/deepseek-v4-pro": { - id: "accounts/fireworks/models/deepseek-v4-pro", - name: "DeepSeek V4 Pro", + "accounts/fireworks/models/deepseek-v4-pro-0813": { + id: "accounts/fireworks/models/deepseek-v4-pro-0813", + name: "DeepSeek V4 Pro 0813", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, input: ["text"], cost: { - input: 1.74, - output: 3.48, - cacheRead: 0.145, + input: 1.32, + output: 3.96, + cacheRead: 0.044, cacheWrite: 0, }, contextWindow: 1000000, @@ -4161,22 +4049,22 @@ export const MODELS = { contextWindow: 131072, maxTokens: 32768, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/gpt-oss-20b": { - id: "accounts/fireworks/models/gpt-oss-20b", - name: "GPT OSS 20B", + "accounts/fireworks/models/inkling": { + id: "accounts/fireworks/models/inkling", + name: "Inkling", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.07, - output: 0.3, - cacheRead: 0.035, + input: 1, + output: 4.05, + cacheRead: 0.17, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 32768, + contextWindow: 1048576, + maxTokens: 1048576, } satisfies Model<"anthropic-messages">, "accounts/fireworks/models/kimi-k2p6": { id: "accounts/fireworks/models/kimi-k2p6", @@ -4230,124 +4118,124 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/minimax-m2p7": { - id: "accounts/fireworks/models/minimax-m2p7", - name: "MiniMax-M2.7", + "accounts/fireworks/models/minimax-m3": { + id: "accounts/fireworks/models/minimax-m3", + name: "MiniMax-M3", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, - input: ["text"], + input: ["text", "image"], cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 196608, - maxTokens: 196608, + contextWindow: 512000, + maxTokens: 512000, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/minimax-m3": { - id: "accounts/fireworks/models/minimax-m3", - name: "MiniMax-M3", + "accounts/fireworks/models/muse-glimmer-30b": { + id: "accounts/fireworks/models/muse-glimmer-30b", + name: "Muse Glimmer 30B", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, input: ["text", "image"], cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, + input: 0.35, + output: 1.5, + cacheRead: 0.04, cacheWrite: 0, }, - contextWindow: 512000, - maxTokens: 512000, - } satisfies Model<"anthropic-messages">, - "accounts/fireworks/models/qwen3p7-plus": { - id: "accounts/fireworks/models/qwen3p7-plus", - name: "Qwen 3.7 Plus", + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "accounts/fireworks/models/nemotron-3-ultra-nvfp4": { + id: "accounts/fireworks/models/nemotron-3-ultra-nvfp4", + name: "Nemotron 3 Ultra 550B A55B", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { - input: 0.4, - output: 1.6, - cacheRead: 0.08, + input: 0.6, + output: 2.4, + cacheRead: 0.119, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/routers/glm-5p2-fast": { - id: "accounts/fireworks/routers/glm-5p2-fast", - name: "GLM 5.2 Fast", + "accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b": { + id: "accounts/fireworks/models/nemotron-lightning-3p5-30b-a3b", + name: "Nemotron 3.5 Lightning 30B A3B", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, input: ["text"], cost: { - input: 2.1, - output: 6.6, - cacheRead: 0.21, + input: 0.05, + output: 0.2, + cacheRead: 0.01, cacheWrite: 0, }, - contextWindow: 1048575, - maxTokens: 131072, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/routers/kimi-k2p6-fast": { - id: "accounts/fireworks/routers/kimi-k2p6-fast", - name: "Kimi K2.6 Fast", + "accounts/fireworks/models/qwen3p7-plus": { + id: "accounts/fireworks/models/qwen3p7-plus", + name: "Qwen 3.7 Plus", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, input: ["text", "image"], cost: { - input: 2, - output: 8, - cacheRead: 0.3, + input: 0.4, + output: 1.6, + cacheRead: 0.08, cacheWrite: 0, }, - contextWindow: 262000, - maxTokens: 262000, + contextWindow: 262144, + maxTokens: 65536, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/routers/kimi-k2p6-turbo": { - id: "accounts/fireworks/routers/kimi-k2p6-turbo", - name: "Kimi K2.6 Turbo", + "accounts/fireworks/models/qwen3p8-max": { + id: "accounts/fireworks/models/qwen3p8-max", + name: "Qwen3.8 Max", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { input: 2, - output: 8, - cacheRead: 0.3, + output: 6, + cacheRead: 0.25, cacheWrite: 0, }, - contextWindow: 262000, - maxTokens: 262000, + contextWindow: 262144, + maxTokens: 131072, } satisfies Model<"anthropic-messages">, - "accounts/fireworks/routers/kimi-k2p7-code-fast": { - id: "accounts/fireworks/routers/kimi-k2p7-code-fast", - name: "Kimi K2.7 Code Fast", + "accounts/fireworks/routers/glm-5p2-fast": { + id: "accounts/fireworks/routers/glm-5p2-fast", + name: "GLM 5.2 Fast", api: "anthropic-messages", provider: "fireworks", baseUrl: "https://api.fireworks.ai/inference", reasoning: true, - input: ["text", "image"], + input: ["text"], cost: { - input: 1.9, - output: 8, - cacheRead: 0.38, + input: 2.1, + output: 6.6, + cacheRead: 0.21, cacheWrite: 0, }, - contextWindow: 262000, - maxTokens: 262000, + contextWindow: 1048575, + maxTokens: 131072, } satisfies Model<"anthropic-messages">, "accounts/fireworks/routers/kimi-k3-fast": { id: "accounts/fireworks/routers/kimi-k3-fast", @@ -4577,9 +4465,9 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "gemini-2.5-pro": { - id: "gemini-2.5-pro", - name: "Gemini 2.5 Pro", + "gemini-3.1-pro-preview": { + id: "gemini-3.1-pro-preview", + name: "Gemini 3.1 Pro Preview", api: "openai-completions", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4588,17 +4476,17 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, + input: 2, + output: 12, + cacheRead: 0.2, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview", + "gemini-3.5-flash": { + id: "gemini-3.5-flash", + name: "Gemini 3.5 Flash", api: "openai-completions", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4607,17 +4495,17 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.5, - output: 3, - cacheRead: 0.05, + input: 1.5, + output: 9, + cacheRead: 0.15, cacheWrite: 0, }, - contextWindow: 128000, + contextWindow: 200000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "gemini-3.1-pro-preview": { - id: "gemini-3.1-pro-preview", - name: "Gemini 3.1 Pro Preview", + "gemini-3.6-flash": { + id: "gemini-3.6-flash", + name: "Gemini 3.6 Flash", api: "openai-completions", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4626,17 +4514,17 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 2, - output: 12, - cacheRead: 0.2, + input: 0.75, + output: 3.75, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"openai-completions">, - "gemini-3.5-flash": { - id: "gemini-3.5-flash", - name: "Gemini 3.5 Flash", + "gemini-3.7-flash": { + id: "gemini-3.7-flash", + name: "Gemini 3.7 Flash", api: "openai-completions", provider: "github-copilot", baseUrl: "https://api.individual.githubcopilot.com", @@ -4645,12 +4533,12 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1.5, - output: 9, - cacheRead: 0.15, + input: 0.75, + output: 3.75, + cacheRead: 0.075, cacheWrite: 0, }, - contextWindow: 200000, + contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"openai-completions">, "gpt-4.1": { @@ -4835,10 +4723,10 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 1, - output: 6, - cacheRead: 0.1, - cacheWrite: 1.25, + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, }, contextWindow: 1050000, maxTokens: 128000, @@ -4854,10 +4742,10 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -4873,14 +4761,52 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 2.5, - output: 15, - cacheRead: 0.25, - cacheWrite: 3.125, + input: 2, + output: 12, + cacheRead: 0.2, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, + "grok-4.5": { + id: "grok-4.5", + name: "Grok 4.5", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "grok-4.6": { + id: "grok-4.6", + name: "Grok 4.6", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, "kimi-k2.7-code": { id: "kimi-k2.7-code", name: "Kimi K2.7 Code", @@ -4900,6 +4826,26 @@ export const MODELS = { contextWindow: 256000, maxTokens: 32000, } satisfies Model<"openai-completions">, + "kimi-k3": { + id: "kimi-k3", + name: "Kimi K3", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":null,"xhigh":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 3, + output: 15, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "mai-code-1-flash-picker": { id: "mai-code-1-flash-picker", name: "MAI-Code-1-Flash", @@ -4919,42 +4865,27 @@ export const MODELS = { contextWindow: 256000, maxTokens: 128000, } satisfies Model<"openai-completions">, - }, - "google": { - "gemini-2.0-flash": { - id: "gemini-2.0-flash", - name: "Gemini 2.0 Flash", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.1, - output: 0.4, - cacheRead: 0.025, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, - "gemini-2.0-flash-lite": { - id: "gemini-2.0-flash-lite", - name: "Gemini 2.0 Flash-Lite", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: false, + "mai-code-1.1-flash": { + id: "mai-code-1.1-flash", + name: "MAI-Code-1.1-Flash", + api: "openai-completions", + provider: "github-copilot", + baseUrl: "https://api.individual.githubcopilot.com", + headers: {"User-Agent":"GitHubCopilotChat/0.35.0","Editor-Version":"vscode/1.107.0","Editor-Plugin-Version":"copilot-chat/0.35.0","Copilot-Integration-Id":"vscode-chat"}, + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false}, + reasoning: true, input: ["text", "image"], cost: { - input: 0.075, - output: 0.3, - cacheRead: 0, + input: 0.2, + output: 1.2, + cacheRead: 0.02, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 8192, - } satisfies Model<"google-generative-ai">, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + }, + "google": { "gemini-2.5-flash": { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", @@ -5005,38 +4936,20 @@ export const MODELS = { }, contextWindow: 1048576, maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-3-flash-preview": { - id: "gemini-3-flash-preview", - name: "Gemini 3 Flash Preview", - api: "google-generative-ai", - provider: "google", - baseUrl: "https://generativelanguage.googleapis.com/v1beta", - reasoning: true, - thinkingLevelMap: {"off":null}, - input: ["text", "image"], - cost: { - input: 0.5, - output: 3, - cacheRead: 0.05, - cacheWrite: 0, - }, - contextWindow: 1048576, - maxTokens: 65536, - } satisfies Model<"google-generative-ai">, - "gemini-3-pro-preview": { - id: "gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", + } satisfies Model<"google-generative-ai">, + "gemini-3-flash-preview": { + id: "gemini-3-flash-preview", + name: "Gemini 3 Flash Preview", api: "google-generative-ai", provider: "google", baseUrl: "https://generativelanguage.googleapis.com/v1beta", reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":"LOW","medium":null,"high":"HIGH"}, + thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 2, - output: 12, - cacheRead: 0.2, + input: 0.5, + output: 3, + cacheRead: 0.05, cacheWrite: 0, }, contextWindow: 1048576, @@ -5160,9 +5073,27 @@ export const MODELS = { thinkingLevelMap: {"off":null}, input: ["text", "image"], cost: { - input: 1.5, - output: 7.5, - cacheRead: 0.15, + input: 0.75, + output: 3.75, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, + "gemini-3.7-flash": { + id: "gemini-3.7-flash", + name: "Gemini 3.7 Flash", + api: "google-generative-ai", + provider: "google", + baseUrl: "https://generativelanguage.googleapis.com/v1beta", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 0.75, + output: 3.75, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 1048576, @@ -5177,9 +5108,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1.5, - output: 9, - cacheRead: 0.15, + input: 0.75, + output: 3.75, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 1048576, @@ -5194,9 +5125,9 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.25, - output: 1.5, - cacheRead: 0.025, + input: 0.3, + output: 2.5, + cacheRead: 0.03, cacheWrite: 0, }, contextWindow: 1048576, @@ -5518,23 +5449,6 @@ export const MODELS = { contextWindow: 131072, maxTokens: 32768, } satisfies Model<"openai-completions">, - "meta-llama/llama-4-scout-17b-16e-instruct": { - id: "meta-llama/llama-4-scout-17b-16e-instruct", - name: "Llama 4 Scout 17B 16E", - api: "openai-completions", - provider: "groq", - baseUrl: "https://api.groq.com/openai/v1", - reasoning: false, - input: ["text", "image"], - cost: { - input: 0.11, - output: 0.34, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 8192, - } satisfies Model<"openai-completions">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", @@ -5586,23 +5500,22 @@ export const MODELS = { contextWindow: 131072, maxTokens: 65536, } satisfies Model<"openai-completions">, - "qwen/qwen3-32b": { - id: "qwen/qwen3-32b", - name: "Qwen3-32B", + "qwen/qwen3.6-27b": { + id: "qwen/qwen3.6-27b", + name: "Qwen3.6 27B", api: "openai-completions", provider: "groq", baseUrl: "https://api.groq.com/openai/v1", reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"default"}, - input: ["text"], + input: ["text", "image"], cost: { - input: 0.29, - output: 0.59, - cacheRead: 0, + input: 0.6, + output: 3, + cacheRead: 0.3, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 40960, + maxTokens: 16384, } satisfies Model<"openai-completions">, }, "huggingface": { @@ -5622,7 +5535,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 128000, + maxTokens: 131072, } satisfies Model<"openai-completions">, "MiniMaxAI/MiniMax-M2.1": { id: "MiniMaxAI/MiniMax-M2.1", @@ -5694,7 +5607,25 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 524288, - maxTokens: 128000, + maxTokens: 512000, + } satisfies Model<"openai-completions">, + "Qwen/Qwen2.5-Coder-32B-Instruct": { + id: "Qwen/Qwen2.5-Coder-32B-Instruct", + name: "Qwen2.5-Coder-32B-Instruct", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.06, + output: 0.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, } satisfies Model<"openai-completions">, "Qwen/Qwen3-235B-A22B": { id: "Qwen/Qwen3-235B-A22B", @@ -5714,6 +5645,24 @@ export const MODELS = { contextWindow: 40960, maxTokens: 16384, } satisfies Model<"openai-completions">, + "Qwen/Qwen3-235B-A22B-Instruct-2507": { + id: "Qwen/Qwen3-235B-A22B-Instruct-2507", + name: "Qwen3 235B-A22B Instruct 2507", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.855, + output: 2.565, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "Qwen/Qwen3-235B-A22B-Thinking-2507": { id: "Qwen/Qwen3-235B-A22B-Thinking-2507", name: "Qwen3-235B-A22B-Thinking-2507", @@ -5732,6 +5681,24 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131072, } satisfies Model<"openai-completions">, + "Qwen/Qwen3-30B-A3B": { + id: "Qwen/Qwen3-30B-A3B", + name: "Qwen3 30B A3B", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.12, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 40960, + maxTokens: 16384, + } satisfies Model<"openai-completions">, "Qwen/Qwen3-32B": { id: "Qwen/Qwen3-32B", name: "Qwen3 32B", @@ -5840,6 +5807,42 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131072, } satisfies Model<"openai-completions">, + "Qwen/Qwen3-VL-235B-A22B-Instruct": { + id: "Qwen/Qwen3-VL-235B-A22B-Instruct", + name: "Qwen3 VL 235B A22B Instruct", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3-VL-235B-A22B-Thinking": { + id: "Qwen/Qwen3-VL-235B-A22B-Thinking", + name: "Qwen3 VL 235B A22B Thinking", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.98, + output: 3.95, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "Qwen/Qwen3.5-122B-A10B": { id: "Qwen/Qwen3.5-122B-A10B", name: "Qwen3.5 122B-A10B", @@ -5966,6 +5969,42 @@ export const MODELS = { contextWindow: 262144, maxTokens: 65536, } satisfies Model<"openai-completions">, + "Qwen/Qwen3.8-2.4T-A95B": { + id: "Qwen/Qwen3.8-2.4T-A95B", + name: "Qwen3.8 2.4T A95B", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 2.5, + output: 6.25, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "Qwen/Qwen3.8-27B": { + id: "Qwen/Qwen3.8-27B", + name: "Qwen3.8 27B", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.4, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "XiaomiMiMo/MiMo-V2-Flash": { id: "XiaomiMiMo/MiMo-V2-Flash", name: "MiMo-V2-Flash", @@ -6056,6 +6095,60 @@ export const MODELS = { contextWindow: 163840, maxTokens: 163840, } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3": { + id: "deepseek-ai/DeepSeek-V3", + name: "DeepSeek-V3", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.4, + output: 1.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 64000, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3-0324": { + id: "deepseek-ai/DeepSeek-V3-0324", + name: "DeepSeek V3 0324", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.27, + output: 1.12, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 163840, + maxTokens: 163840, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V3.1": { + id: "deepseek-ai/DeepSeek-V3.1", + name: "DeepSeek-V3.1", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.27, + output: 1, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "deepseek-ai/DeepSeek-V3.2": { id: "deepseek-ai/DeepSeek-V3.2", name: "DeepSeek-V3.2", @@ -6092,9 +6185,45 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 384000, } satisfies Model<"openai-completions">, - "deepseek-ai/DeepSeek-V4-Pro": { - id: "deepseek-ai/DeepSeek-V4-Pro", - name: "DeepSeek V4 Pro", + "deepseek-ai/DeepSeek-V4-Flash-0731": { + id: "deepseek-ai/DeepSeek-V4-Flash-0731", + name: "DeepSeek V4 Flash 0731", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.14, + output: 0.28, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V4-Pro": { + id: "deepseek-ai/DeepSeek-V4-Pro", + name: "DeepSeek V4 Pro", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.435, + output: 0.87, + cacheRead: 0.003625, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 393216, + } satisfies Model<"openai-completions">, + "deepseek-ai/DeepSeek-V4-Pro-0813": { + id: "deepseek-ai/DeepSeek-V4-Pro-0813", + name: "DeepSeek V4 Pro 0813", api: "openai-completions", provider: "huggingface", baseUrl: "https://router.huggingface.co/v1", @@ -6102,13 +6231,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, + input: 1.32, + output: 3.96, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 393216, + contextWindow: 1000000, + maxTokens: 384000, } satisfies Model<"openai-completions">, "google/gemma-4-26B-A4B-it": { id: "google/gemma-4-26B-A4B-it", @@ -6146,6 +6275,24 @@ export const MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, + "meta-llama/Llama-3.1-8B-Instruct": { + id: "meta-llama/Llama-3.1-8B-Instruct", + name: "Llama-3.1-8B-Instruct", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: false, + input: ["text"], + cost: { + input: 0.06, + output: 0.06, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 4096, + } satisfies Model<"openai-completions">, "meta-llama/Llama-3.3-70B-Instruct": { id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama-3.3-70B-Instruct", @@ -6399,6 +6546,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 1048576, } satisfies Model<"openai-completions">, + "thinkingmachines/Inkling-Small": { + id: "thinkingmachines/Inkling-Small", + name: "Inkling Small", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.2, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 524288, + maxTokens: 1048576, + } satisfies Model<"openai-completions">, "zai-org/GLM-4.5": { id: "zai-org/GLM-4.5", name: "GLM-4.5", @@ -6471,6 +6636,24 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, + "zai-org/GLM-4.6V-Flash": { + id: "zai-org/GLM-4.6V-Flash", + name: "GLM-4.6V-Flash", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 0.9, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 32768, + } satisfies Model<"openai-completions">, "zai-org/GLM-4.7": { id: "zai-org/GLM-4.7", name: "GLM-4.7", @@ -6561,6 +6744,24 @@ export const MODELS = { contextWindow: 262144, maxTokens: 131072, } satisfies Model<"openai-completions">, + "zai-org/GLM-5.3-Flash": { + id: "zai-org/GLM-5.3-Flash", + name: "GLM-5.3-Flash", + api: "openai-completions", + provider: "huggingface", + baseUrl: "https://router.huggingface.co/v1", + compat: {"supportsDeveloperRole":false}, + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, }, "kimi-coding": { "k3": { @@ -7221,6 +7422,40 @@ export const MODELS = { contextWindow: 128000, maxTokens: 128000, } satisfies Model<"mistral-conversations">, + "voxtral-small-latest": { + id: "voxtral-small-latest", + name: "Voxtral Small (latest)", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: false, + input: ["text"], + cost: { + input: 0.1, + output: 0.3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 32000, + maxTokens: 32000, + } satisfies Model<"mistral-conversations">, + "zai-glm-5-2": { + id: "zai-glm-5-2", + name: "GLM-5.2", + api: "mistral-conversations", + provider: "mistral", + baseUrl: "https://api.mistral.ai", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.14, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"mistral-conversations">, }, "moonshotai": { "kimi-k2-0711-preview": { @@ -8129,10 +8364,10 @@ export const MODELS = { thinkingLevelMap: {"off":"none","xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 4, + output: 20, + cacheRead: 0.4, + cacheWrite: 5, }, contextWindow: 1050000, maxTokens: 128000, @@ -8165,10 +8400,10 @@ export const MODELS = { thinkingLevelMap: {"off":"none","xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 4, + output: 20, + cacheRead: 0.4, + cacheWrite: 5, }, contextWindow: 1050000, maxTokens: 128000, @@ -8598,23 +8833,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, - "claude-opus-4-1": { - id: "claude-opus-4-1", - name: "Claude Opus 4.1", - api: "anthropic-messages", - provider: "opencode", - baseUrl: "https://opencode.ai/zen", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, "claude-opus-4-5": { id: "claude-opus-4-5", name: "Claude Opus 4.5", @@ -8782,7 +9000,7 @@ export const MODELS = { baseUrl: "https://opencode.ai/zen/v1", compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { input: 0.14, @@ -8793,25 +9011,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 384000, } satisfies Model<"openai-completions">, - "deepseek-v4-flash-free": { - id: "deepseek-v4-flash-free", - name: "DeepSeek V4 Flash Free (New)", - api: "openai-completions", - provider: "opencode", - baseUrl: "https://opencode.ai/zen/v1", - compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 200000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, "deepseek-v4-pro": { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", @@ -8820,7 +9019,7 @@ export const MODELS = { baseUrl: "https://opencode.ai/zen/v1", compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { input: 1.74, @@ -8921,6 +9120,24 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"google-generative-ai">, + "gemini-3.7-flash": { + id: "gemini-3.7-flash", + name: "Gemini 3.7 Flash", + api: "google-generative-ai", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + thinkingLevelMap: {"off":null}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 7.5, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"google-generative-ai">, "glm-5": { id: "glm-5", name: "GLM-5", @@ -9280,7 +9497,7 @@ export const MODELS = { } satisfies Model<"openai-responses">, "gpt-5.6-sol": { id: "gpt-5.6-sol", - name: "GPT-5.6 Sol", + name: "GPT-5.6 Sol (50% Off)", api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", @@ -9288,10 +9505,10 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 2, + output: 10, + cacheRead: 0.2, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -9322,6 +9539,23 @@ export const MODELS = { baseUrl: "https://opencode.ai/zen/v1", reasoning: true, input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.3, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 500000, + } satisfies Model<"openai-responses">, + "grok-4.6": { + id: "grok-4.6", + name: "Grok 4.6", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], cost: { input: 2, output: 6, @@ -9334,7 +9568,7 @@ export const MODELS = { "grok-build-0.1": { id: "grok-build-0.1", name: "Grok Build 0.1", - api: "openai-completions", + api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", reasoning: true, @@ -9347,6 +9581,23 @@ export const MODELS = { }, contextWindow: 256000, maxTokens: 256000, + } satisfies Model<"openai-responses">, + "hy3-free": { + id: "hy3-free", + name: "Hy3 Free", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 190000, + maxTokens: 64000, } satisfies Model<"openai-completions">, "kimi-k2.5": { id: "kimi-k2.5", @@ -9417,40 +9668,6 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, - "laguna-s-2.1-free": { - id: "laguna-s-2.1-free", - name: "Laguna S 2.1 Free", - api: "openai-completions", - provider: "opencode", - baseUrl: "https://opencode.ai/zen/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, - "ling-3.0-flash-free": { - id: "ling-3.0-flash-free", - name: "Ling-3.0-flash Free", - api: "openai-completions", - provider: "opencode", - baseUrl: "https://opencode.ai/zen/v1", - reasoning: true, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 32768, - } satisfies Model<"openai-completions">, "mimo-v2.5-free": { id: "mimo-v2.5-free", name: "MiMo V2.5 Free", @@ -9499,26 +9716,60 @@ export const MODELS = { cacheRead: 0.06, cacheWrite: 0, }, - contextWindow: 204800, + contextWindow: 204800, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "minimax-m3": { + id: "minimax-m3", + name: "MiniMax-M3", + api: "openai-completions", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 128000, + } satisfies Model<"openai-completions">, + "muse-spark-1.2": { + id: "muse-spark-1.2", + name: "Muse Spark 1.2", + api: "openai-responses", + provider: "opencode", + baseUrl: "https://opencode.ai/zen/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 4.25, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, maxTokens: 131072, - } satisfies Model<"openai-completions">, - "minimax-m3": { - id: "minimax-m3", - name: "MiniMax-M3", - api: "openai-completions", + } satisfies Model<"openai-responses">, + "muse-spark-1.2-contributor-free": { + id: "muse-spark-1.2-contributor-free", + name: "Muse Spark 1.2 Free", + api: "openai-responses", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", reasoning: true, input: ["text", "image"], cost: { - input: 0.3, - output: 1.2, - cacheRead: 0.06, + input: 0, + output: 0, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 512000, - maxTokens: 128000, - } satisfies Model<"openai-completions">, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-responses">, "nemotron-3-ultra-free": { id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", @@ -9536,9 +9787,9 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "north-mini-code-free": { - id: "north-mini-code-free", - name: "North Mini Code Free", + "nemotron-3.5-lightning-free": { + id: "nemotron-3.5-lightning-free", + name: "Nemotron 3.5 Lightning Free", api: "openai-completions", provider: "opencode", baseUrl: "https://opencode.ai/zen/v1", @@ -9550,8 +9801,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 64000, + contextWindow: 262144, + maxTokens: 262144, } satisfies Model<"openai-completions">, "qwen3.5-plus": { id: "qwen3.5-plus", @@ -9591,18 +9842,37 @@ export const MODELS = { "opencode-go": { "deepseek-v4-flash": { id: "deepseek-v4-flash", - name: "DeepSeek V4 Flash (New)", + name: "DeepSeek V4 Flash", api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, + input: 0.22, + output: 0.66, + cacheRead: 0.007, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek-v4-flash-vision-exp": { + id: "deepseek-v4-flash-vision-exp", + name: "DeepSeek V4 Flash Vision Exp", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, + input: ["text", "image"], + cost: { + input: 0.22, + output: 0.66, + cacheRead: 0.007, cacheWrite: 0, }, contextWindow: 1000000, @@ -9610,18 +9880,18 @@ export const MODELS = { } satisfies Model<"openai-completions">, "deepseek-v4-pro": { id: "deepseek-v4-pro", - name: "DeepSeek V4 Pro", + name: "DeepSeek V4 Pro (New)", api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max"}, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, + input: 0.66, + output: 1.98, + cacheRead: 0.022, cacheWrite: 0, }, contextWindow: 1000000, @@ -9661,9 +9931,43 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"openai-completions">, + "glm-5.3": { + id: "glm-5.3", + name: "GLM-5.3", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.3-flash": { + id: "glm-5.3-flash", + name: "GLM-5.3-Flash (2x usage)", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.25, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "gpt-5.6-luna": { id: "gpt-5.6-luna", - name: "GPT-5.6 Luna (2x usage)", + name: "GPT-5.6 Luna", api: "openai-responses", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", @@ -9671,17 +9975,17 @@ export const MODELS = { thinkingLevelMap: {"off":null,"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 0.1, - output: 0.6, - cacheRead: 0.01, - cacheWrite: 0.125, + input: 0.2, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, }, contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"openai-responses">, - "grok-4.5": { - id: "grok-4.5", - name: "Grok 4.5", + "grok-4.6": { + id: "grok-4.6", + name: "Grok 4.6", api: "openai-responses", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", @@ -9698,16 +10002,16 @@ export const MODELS = { } satisfies Model<"openai-responses">, "hy3": { id: "hy3", - name: "Hy3", + name: "Hy3 (8x usage)", api: "openai-completions", provider: "opencode-go", baseUrl: "https://opencode.ai/zen/go/v1", reasoning: true, input: ["text"], cost: { - input: 0.14, - output: 0.58, - cacheRead: 0.035, + input: 0.0175, + output: 0.0725, + cacheRead: 0.004375, cacheWrite: 0, }, contextWindow: 256000, @@ -9765,6 +10069,23 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, + "longcat-2.0": { + id: "longcat-2.0", + name: "LongCat-2.0", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text"], + cost: { + input: 0.3, + output: 1.2, + cacheRead: 0.006, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "mimo-v2.5": { id: "mimo-v2.5", name: "MiMo V2.5", @@ -9833,6 +10154,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"anthropic-messages">, + "muse-spark-1.2-contributor": { + id: "muse-spark-1.2-contributor", + name: "Muse Spark 1.2 Contributor", + api: "openai-responses", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.1, + output: 0.2, + cacheRead: 0.002, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-responses">, "qwen3.6-plus": { id: "qwen3.6-plus", name: "Qwen3.6 Plus", @@ -9854,9 +10192,9 @@ export const MODELS = { "qwen3.7-max": { id: "qwen3.7-max", name: "Qwen3.7 Max", - api: "anthropic-messages", + api: "openai-completions", provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go", + baseUrl: "https://opencode.ai/zen/go/v1", reasoning: true, input: ["text"], cost: { @@ -9867,13 +10205,13 @@ export const MODELS = { }, contextWindow: 1000000, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, + } satisfies Model<"openai-completions">, "qwen3.7-plus": { id: "qwen3.7-plus", name: "Qwen3.7 Plus", - api: "anthropic-messages", + api: "openai-completions", provider: "opencode-go", - baseUrl: "https://opencode.ai/zen/go", + baseUrl: "https://opencode.ai/zen/go/v1", reasoning: true, input: ["text", "image"], cost: { @@ -9884,26 +10222,43 @@ export const MODELS = { }, contextWindow: 1000000, maxTokens: 65536, - } satisfies Model<"anthropic-messages">, - }, - "openrouter": { - "ai21/jamba-large-1.7": { - id: "ai21/jamba-large-1.7", - name: "AI21: Jamba Large 1.7", + } satisfies Model<"openai-completions">, + "qwen3.8-flash": { + id: "qwen3.8-flash", + name: "Qwen3.8 Flash", api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.47, + cacheRead: 0.016, + cacheWrite: 0.2, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen3.8-max": { + id: "qwen3.8-max", + name: "Qwen3.8 Max", + api: "openai-completions", + provider: "opencode-go", + baseUrl: "https://opencode.ai/zen/go/v1", + reasoning: true, + input: ["text", "image"], cost: { input: 2, - output: 8, - cacheRead: 0, - cacheWrite: 0, + output: 6, + cacheRead: 0.25, + cacheWrite: 2.5, }, - contextWindow: 256000, - maxTokens: 4096, + contextWindow: 1000000, + maxTokens: 131072, } satisfies Model<"openai-completions">, + }, + "openrouter": { "aion-labs/aion-2.0": { id: "aion-labs/aion-2.0", name: "AionLabs: Aion-2.0", @@ -10376,7 +10731,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "arcee-ai/virtuoso-large": { id: "arcee-ai/virtuoso-large", @@ -10450,6 +10805,43 @@ export const MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, + "bytedance-seed/seed-2-1-turbo": { + id: "bytedance-seed/seed-2-1-turbo", + name: "ByteDance Seed: Seed 2.1 Turbo", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 2.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 235929, + } satisfies Model<"openai-completions">, + "bytedance-seed/seed-2.0-code": { + id: "bytedance-seed/seed-2.0-code", + name: "ByteDance Seed: Seed-2.0-Code", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 0.5, + output: 3, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "bytedance-seed/seed-2.0-lite": { id: "bytedance-seed/seed-2.0-lite", name: "ByteDance Seed: Seed-2.0-Lite", @@ -10565,13 +10957,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.27, - output: 1.12, - cacheRead: 0.135, + input: 0.25, + output: 1, + cacheRead: 0, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 65536, + maxTokens: 147456, } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3.1": { id: "deepseek/deepseek-chat-v3.1", @@ -10584,13 +10976,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.25, - output: 0.95, - cacheRead: 0.13, + input: 0.55, + output: 1.6500000000000001, + cacheRead: 0.55, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 32768, + maxTokens: 144900, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1": { id: "deepseek/deepseek-r1", @@ -10608,7 +11000,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 163840, + contextWindow: 64000, maxTokens: 16000, } satisfies Model<"openai-completions">, "deepseek/deepseek-r1-0528": { @@ -10689,7 +11081,7 @@ export const MODELS = { } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-flash": { id: "deepseek/deepseek-v4-flash", - name: "DeepSeek: DeepSeek V4 Flash", + name: "DeepSeek: DeepSeek V4 Flash 0423", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -10698,13 +11090,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.14, - output: 0.28, - cacheRead: 0.028, + input: 0.088606, + output: 0.177212, + cacheRead: 0.017721200000000003, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 393216, + maxTokens: 384000, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-flash-0731": { id: "deepseek/deepseek-v4-flash-0731", @@ -10717,17 +11109,55 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.09, - output: 0.18, - cacheRead: 0.018, + input: 0.07, + output: 0.14, + cacheRead: 0.014, + cacheWrite: 0, + }, + contextWindow: 1310720, + maxTokens: 943718, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-flash-vision-exp": { + id: "deepseek/deepseek-v4-flash-vision-exp", + name: "DeepSeek: DeepSeek V4 Flash Vision Exp", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, + input: ["text", "image"], + cost: { + input: 0.44, + output: 1.32, + cacheRead: 0.014, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 65536, + maxTokens: 384000, + } satisfies Model<"openai-completions">, + "deepseek/deepseek-v4-pro": { + id: "deepseek/deepseek-v4-pro", + name: "DeepSeek: DeepSeek V4 Pro 0423", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"requiresReasoningContentOnAssistantMessages":true,"thinkingFormat":"deepseek"}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, + input: ["text"], + cost: { + input: 0.87, + output: 1.74, + cacheRead: 0.0725, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 384000, } satisfies Model<"openai-completions">, - "deepseek/deepseek-v4-pro": { - id: "deepseek/deepseek-v4-pro", - name: "DeepSeek: DeepSeek V4 Pro", + "deepseek/deepseek-v4-pro-0813": { + id: "deepseek/deepseek-v4-pro-0813", + name: "DeepSeek: DeepSeek V4 Pro 0813", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -10736,14 +11166,33 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.435, - output: 0.87, - cacheRead: 0.003625, + input: 1.32, + output: 3.9600000000000004, + cacheRead: 0.044, cacheWrite: 0, }, contextWindow: 1048576, maxTokens: 384000, } satisfies Model<"openai-completions">, + "dots-studio/dots-3-note-preview:free": { + id: "dots-studio/dots-3-note-preview:free", + name: "Dots Studio: Dots3-Note Preview (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 512000, + maxTokens: 460800, + } satisfies Model<"openai-completions">, "google/gemini-2.5-flash": { id: "google/gemini-2.5-flash", name: "Google: Gemini 2.5 Flash", @@ -10758,7 +11207,7 @@ export const MODELS = { input: 0.3, output: 2.5, cacheRead: 0.03, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.0833333333333333, }, contextWindow: 1048576, maxTokens: 65535, @@ -10777,7 +11226,7 @@ export const MODELS = { input: 0.09999999999999999, output: 0.39999999999999997, cacheRead: 0.01, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.0833333333333333, }, contextWindow: 1048576, maxTokens: 65535, @@ -10852,10 +11301,10 @@ export const MODELS = { input: 0.5, output: 3, cacheRead: 0.049999999999999996, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.0833333333333333, }, contextWindow: 1048576, - maxTokens: 65535, + maxTokens: 65536, } satisfies Model<"openai-completions">, "google/gemini-3-pro-image": { id: "google/gemini-3-pro-image", @@ -10889,7 +11338,7 @@ export const MODELS = { input: 0.25, output: 1.5, cacheRead: 0.024999999999999998, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.0833333333333333, }, contextWindow: 1048576, maxTokens: 65536, @@ -10907,7 +11356,7 @@ export const MODELS = { input: 0.25, output: 1.5, cacheRead: 0.024999999999999998, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.0833333333333333, }, contextWindow: 1048576, maxTokens: 65536, @@ -10961,7 +11410,7 @@ export const MODELS = { input: 1.5, output: 9, cacheRead: 0.15, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.0833333333333333, }, contextWindow: 1048576, maxTokens: 65536, @@ -10979,7 +11428,7 @@ export const MODELS = { input: 0.3, output: 2.5, cacheRead: 0.03, - cacheWrite: 0.08333333333333334, + cacheWrite: 0.0833333333333333, }, contextWindow: 1048576, maxTokens: 65536, @@ -10994,10 +11443,28 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 1.5, - output: 7.5, - cacheRead: 0.15, - cacheWrite: 0.08333333333333334, + input: 0.75, + output: 3.75, + cacheRead: 0.075, + cacheWrite: 0.0416666666666667, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, + "google/gemini-3.7-flash": { + id: "google/gemini-3.7-flash", + name: "Google: Gemini 3.7 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 0.375, + output: 1.875, + cacheRead: 0.0375, + cacheWrite: 0.0208333333333333, }, contextWindow: 1048576, maxTokens: 65536, @@ -11034,7 +11501,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "google/gemma-4-26b-a4b-it": { id: "google/gemma-4-26b-a4b-it", @@ -11085,13 +11552,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.09999999999999999, + input: 0.09, output: 0.33999999999999997, - cacheRead: 0.09999999999999999, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 16384, } satisfies Model<"openai-completions">, "google/gemma-4-31b-it:free": { id: "google/gemma-4-31b-it:free", @@ -11127,7 +11594,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "inception/mercury-2": { id: "inception/mercury-2", @@ -11147,47 +11614,34 @@ export const MODELS = { contextWindow: 128000, maxTokens: 50000, } satisfies Model<"openai-completions">, - "inclusionai/ling-2.6-1t": { - id: "inclusionai/ling-2.6-1t", - name: "inclusionAI: Ling-2.6-1T", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - input: ["text"], - cost: { - input: 0.075, - output: 0.625, - cacheRead: 0.015, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 32768, - } satisfies Model<"openai-completions">, - "inclusionai/ling-2.6-flash": { - id: "inclusionai/ling-2.6-flash", - name: "inclusionAI: Ling-2.6-flash", + "inclusionai/ling-3.0-flash": { + id: "inclusionai/ling-3.0-flash", + name: "Ling-3.0-flash", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.01, - output: 0.03, - cacheRead: 0.002, + input: 0.020999999999999998, + output: 0.063, + cacheRead: 0.004200000000000001, cacheWrite: 0, }, contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, - "inclusionai/ling-3.0-flash:free": { - id: "inclusionai/ling-3.0-flash:free", - name: "Ling-3.0-flash (free)", + "inclusionai/ling-3.0-flash-fin:free": { + id: "inclusionai/ling-3.0-flash-fin:free", + name: "Ling 3.0 Flash Fin (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { input: 0, @@ -11198,24 +11652,6 @@ export const MODELS = { contextWindow: 262144, maxTokens: 32768, } satisfies Model<"openai-completions">, - "inclusionai/ring-2.6-1t": { - id: "inclusionai/ring-2.6-1t", - name: "inclusionAI: Ring-2.6-1T", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null}, - input: ["text"], - cost: { - input: 0.075, - output: 0.625, - cacheRead: 0.015, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 65536, - } satisfies Model<"openai-completions">, "kwaipilot/kat-coder-air-v2.5": { id: "kwaipilot/kat-coder-air-v2.5", name: "Kwaipilot: KAT-Coder-Air V2.5", @@ -11264,9 +11700,28 @@ export const MODELS = { cacheRead: 0.15, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 262144, maxTokens: 80000, } satisfies Model<"openai-completions">, + "liquid/lfm-2.5-2.6b:free": { + id: "liquid/lfm-2.5-2.6b:free", + name: "LiquidAI: LFM2.5-2.6B (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 65536, + maxTokens: 8192, + } satisfies Model<"openai-completions">, "meituan/longcat-2.0": { id: "meituan/longcat-2.0", name: "Meituan: LongCat 2.0", @@ -11318,7 +11773,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "meta-llama/llama-3.3-70b-instruct": { id: "meta-llama/llama-3.3-70b-instruct", @@ -11329,13 +11784,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.13, - output: 0.39999999999999997, - cacheRead: 0, + input: 0.71, + output: 0.71, + cacheRead: 0.71, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 128000, + maxTokens: 115200, } satisfies Model<"openai-completions">, "meta-llama/llama-4-maverick": { id: "meta-llama/llama-4-maverick", @@ -11363,13 +11818,31 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.3, - cacheRead: 0, + input: 0.11, + output: 0.33999999999999997, + cacheRead: 0.055, cacheWrite: 0, }, contextWindow: 1310720, - maxTokens: 16384, + maxTokens: 8192, + } satisfies Model<"openai-completions">, + "meta/muse-glimmer-30b": { + id: "meta/muse-glimmer-30b", + name: "Meta: Muse Glimmer 30B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":null}, + input: ["text", "image"], + cost: { + input: 0.35, + output: 1.5, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "meta/muse-spark-1.1": { id: "meta/muse-spark-1.1", @@ -11387,7 +11860,43 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 4096, + maxTokens: 943718, + } satisfies Model<"openai-completions">, + "meta/muse-spark-1.2": { + id: "meta/muse-spark-1.2", + name: "Meta: Muse Spark 1.2", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":null}, + input: ["text", "image"], + cost: { + input: 1.25, + output: 4.25, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 943718, + } satisfies Model<"openai-completions">, + "meta/muse-spark-1.2-contributor": { + id: "meta/muse-spark-1.2-contributor", + name: "Meta: Muse Spark 1.2 Contributor", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":null}, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.19999999999999998, + cacheRead: 0.002, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 943718, } satisfies Model<"openai-completions">, "minimax/minimax-m1": { id: "minimax/minimax-m1", @@ -11457,13 +11966,13 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.15, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, + input: 0.27, + output: 1.08, + cacheRead: 0.027, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 196608, + maxTokens: 128000, } satisfies Model<"openai-completions">, "minimax/minimax-m2.7": { id: "minimax/minimax-m2.7", @@ -11476,14 +11985,33 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.25, - output: 1, - cacheRead: 0.049999999999999996, + input: 0.3, + output: 1.2, + cacheRead: 0.06, cacheWrite: 0, }, contextWindow: 204800, maxTokens: 131072, } satisfies Model<"openai-completions">, + "minimax/minimax-m2.7:free": { + id: "minimax/minimax-m2.7:free", + name: "MiniMax: MiniMax M2.7 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 196608, + maxTokens: 176947, + } satisfies Model<"openai-completions">, "minimax/minimax-m3": { id: "minimax/minimax-m3", name: "MiniMax: MiniMax M3", @@ -11503,6 +12031,25 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 512000, } satisfies Model<"openai-completions">, + "minimax/minimax-m3:free": { + id: "minimax/minimax-m3:free", + name: "MiniMax: MiniMax M3 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 943718, + } satisfies Model<"openai-completions">, "mistralai/codestral-2508": { id: "mistralai/codestral-2508", name: "Mistral: Codestral 2508", @@ -11518,7 +12065,24 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 256000, - maxTokens: 4096, + maxTokens: 204800, + } satisfies Model<"openai-completions">, + "mistralai/devstral-2512": { + id: "mistralai/devstral-2512", + name: "Mistral: Devstral 2 2512", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: false, + input: ["text"], + cost: { + input: 0.44, + output: 2.2, + cacheRead: 0.044, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/ministral-14b-2512": { id: "mistralai/ministral-14b-2512", @@ -11535,7 +12099,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/ministral-3b-2512": { id: "mistralai/ministral-3b-2512", @@ -11552,7 +12116,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 104857, } satisfies Model<"openai-completions">, "mistralai/ministral-8b-2512": { id: "mistralai/ministral-8b-2512", @@ -11569,7 +12133,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/mistral-large": { id: "mistralai/mistral-large", @@ -11586,7 +12150,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 4096, + maxTokens: 102400, } satisfies Model<"openai-completions">, "mistralai/mistral-large-2407": { id: "mistralai/mistral-large-2407", @@ -11603,7 +12167,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 104857, } satisfies Model<"openai-completions">, "mistralai/mistral-large-2512": { id: "mistralai/mistral-large-2512", @@ -11620,7 +12184,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/mistral-medium-3": { id: "mistralai/mistral-medium-3", @@ -11637,7 +12201,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 104857, } satisfies Model<"openai-completions">, "mistralai/mistral-medium-3-5": { id: "mistralai/mistral-medium-3-5", @@ -11655,7 +12219,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/mistral-medium-3.1": { id: "mistralai/mistral-medium-3.1", @@ -11672,7 +12236,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 4096, + maxTokens: 104857, } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", @@ -11706,7 +12270,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 32768, - maxTokens: 4096, + maxTokens: 26214, } satisfies Model<"openai-completions">, "mistralai/mistral-small-2603": { id: "mistralai/mistral-small-2603", @@ -11724,7 +12288,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 4096, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/mistral-small-3.2-24b-instruct": { id: "mistralai/mistral-small-3.2-24b-instruct", @@ -11740,7 +12304,7 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 256000, + contextWindow: 131072, maxTokens: 16384, } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { @@ -11758,7 +12322,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 65536, - maxTokens: 4096, + maxTokens: 52428, } satisfies Model<"openai-completions">, "mistralai/voxtral-small-24b-2507": { id: "mistralai/voxtral-small-24b-2507", @@ -11775,7 +12339,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 32000, - maxTokens: 4096, + maxTokens: 25600, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2": { id: "moonshotai/kimi-k2", @@ -11860,13 +12424,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.6, - output: 3.41, - cacheRead: 0.19999999999999998, + input: 0.95, + output: 4, + cacheRead: 0.16, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2.7-code": { id: "moonshotai/kimi-k2.7-code", @@ -11879,13 +12443,13 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.73, - output: 3.5, - cacheRead: 0.15, + input: 0.66, + output: 3.4, + cacheRead: 0.18, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "moonshotai/kimi-k3": { id: "moonshotai/kimi-k3", @@ -11922,7 +12486,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "nex-agi/nex-n2-pro": { id: "nex-agi/nex-n2-pro", @@ -11936,35 +12500,16 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.25, - output: 1, - cacheRead: 0.024999999999999998, - cacheWrite: 0, - }, - contextWindow: 262144, - maxTokens: 262144, - } satisfies Model<"openai-completions">, - "nvidia/nemotron-3-nano-30b-a3b": { - id: "nvidia/nemotron-3-nano-30b-a3b", - name: "NVIDIA: Nemotron 3 Nano 30B A3B", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - compat: {"supportsReasoningEffort":false}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, - input: ["text"], - cost: { - input: 0.049999999999999996, - output: 0.19999999999999998, - cacheRead: 0.03, + output: 1, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, - "nvidia/nemotron-3-nano-30b-a3b:free": { - id: "nvidia/nemotron-3-nano-30b-a3b:free", - name: "NVIDIA: Nemotron 3 Nano 30B A3B (free)", + "nvidia/nemotron-3-nano-30b-a3b": { + id: "nvidia/nemotron-3-nano-30b-a3b", + name: "NVIDIA: Nemotron 3 Nano 30B A3B", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -11973,13 +12518,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.049999999999999996, + output: 0.19999999999999998, + cacheRead: 0.024999999999999998, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 4096, + contextWindow: 262144, + maxTokens: 228000, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free": { id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", @@ -12034,7 +12579,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-ultra-550b-a55b": { id: "nvidia/nemotron-3-ultra-550b-a55b", @@ -12046,13 +12591,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":"medium","high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.6, - output: 3.5999999999999996, - cacheRead: 0.19999999999999998, + input: 0.5, + output: 2.2, + cacheRead: 0.09999999999999999, cacheWrite: 0, }, - contextWindow: 512288, - maxTokens: 4096, + contextWindow: 262144, + maxTokens: 16384, } satisfies Model<"openai-completions">, "nvidia/nemotron-3-ultra-550b-a55b:free": { id: "nvidia/nemotron-3-ultra-550b-a55b:free", @@ -12072,28 +12617,28 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 65536, } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-12b-v2-vl:free": { - id: "nvidia/nemotron-nano-12b-v2-vl:free", - name: "NVIDIA: Nemotron Nano 12B 2 VL (free)", + "nvidia/nemotron-3.5-lightning": { + id: "nvidia/nemotron-3.5-lightning", + name: "NVIDIA: Nemotron 3.5 Lightning", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", compat: {"supportsReasoningEffort":false}, reasoning: true, thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, - input: ["text", "image"], + input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, + input: 0.09999999999999999, + output: 0.25, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 128000, + contextWindow: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, - "nvidia/nemotron-nano-9b-v2:free": { - id: "nvidia/nemotron-nano-9b-v2:free", - name: "NVIDIA: Nemotron Nano 9B V2 (free)", + "nvidia/nemotron-3.5-lightning:free": { + id: "nvidia/nemotron-3.5-lightning:free", + name: "NVIDIA: Nemotron 3.5 Lightning (free)", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -12107,8 +12652,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 1000000, + maxTokens: 65536, } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo": { id: "openai/gpt-3.5-turbo", @@ -12142,7 +12687,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 4095, - maxTokens: 4096, + maxTokens: 3685, } satisfies Model<"openai-completions">, "openai/gpt-3.5-turbo-16k": { id: "openai/gpt-3.5-turbo-16k", @@ -12543,7 +13088,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 128000, - maxTokens: 16384, + maxTokens: 32000, } satisfies Model<"openai-completions">, "openai/gpt-5.2-codex": { id: "openai/gpt-5.2-codex", @@ -12581,24 +13126,6 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "openai/gpt-5.3-chat": { - id: "openai/gpt-5.3-chat", - name: "OpenAI: GPT-5.3 Chat", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: false, - thinkingLevelMap: {"xhigh":"xhigh"}, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 16384, - } satisfies Model<"openai-completions">, "openai/gpt-5.3-codex": { id: "openai/gpt-5.3-codex", name: "OpenAI: GPT-5.3-Codex", @@ -12735,10 +13262,10 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.6000000000000001, - cacheRead: 0.01, - cacheWrite: 0.12500000000000003, + input: 0.19999999999999998, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, }, contextWindow: 1050000, maxTokens: 128000, @@ -12753,10 +13280,10 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { - input: 0.09999999999999999, - output: 0.6000000000000001, - cacheRead: 0.01, - cacheWrite: 0.12500000000000003, + input: 0.19999999999999998, + output: 1.2, + cacheRead: 0.02, + cacheWrite: 0.25, }, contextWindow: 1050000, maxTokens: 128000, @@ -12771,10 +13298,10 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 2, + output: 10, + cacheRead: 0.19999999999999998, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -12789,10 +13316,10 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 2, + output: 10, + cacheRead: 0.19999999999999998, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -12807,10 +13334,10 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { - input: 1.0000000000000002, - output: 6, - cacheRead: 0.09999999999999999, - cacheWrite: 1.25, + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -12825,10 +13352,10 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { - input: 1.0000000000000002, - output: 6, - cacheRead: 0.09999999999999999, - cacheWrite: 1.25, + input: 2, + output: 12, + cacheRead: 0.19999999999999998, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -12900,7 +13427,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", @@ -12918,25 +13445,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"openai-completions">, - "openai/gpt-oss-20b:free": { - id: "openai/gpt-oss-20b:free", - name: "OpenAI: gpt-oss-20b (free)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, - input: ["text"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 32768, + maxTokens: 117964, } satisfies Model<"openai-completions">, "openai/gpt-oss-safeguard-20b": { id: "openai/gpt-oss-safeguard-20b", @@ -13247,7 +13756,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 32768, - maxTokens: 32768, + maxTokens: 29491, } satisfies Model<"openai-completions">, "qwen/qwen-plus": { id: "qwen/qwen-plus", @@ -13283,25 +13792,6 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 32768, } satisfies Model<"openai-completions">, - "qwen/qwen-plus-2025-07-28:thinking": { - id: "qwen/qwen-plus-2025-07-28:thinking", - name: "Qwen: Qwen Plus 0728 (thinking)", - api: "openai-completions", - provider: "openrouter", - baseUrl: "https://openrouter.ai/api/v1", - compat: {"supportsReasoningEffort":false}, - reasoning: true, - thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, - input: ["text"], - cost: { - input: 0.39999999999999997, - output: 1.2, - cacheRead: 0, - cacheWrite: 0.5, - }, - contextWindow: 1000000, - maxTokens: 32768, - } satisfies Model<"openai-completions">, "qwen/qwen3-14b": { id: "qwen/qwen3-14b", name: "Qwen: Qwen3 14B", @@ -13313,13 +13803,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.22749999999999998, - output: 0.9099999999999999, + input: 0.12, + output: 0.24, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 8192, + maxTokens: 16384, } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b": { id: "qwen/qwen3-235b-a22b", @@ -13349,13 +13839,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.09, - output: 0.55, - cacheRead: 0, + input: 0.0875, + output: 0.35, + cacheRead: 0.0175, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3-235b-a22b-thinking-2507": { id: "qwen/qwen3-235b-a22b-thinking-2507", @@ -13373,8 +13863,8 @@ export const MODELS = { cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 4096, + contextWindow: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b": { id: "qwen/qwen3-30b-a3b", @@ -13501,7 +13991,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3-coder-flash": { id: "qwen/qwen3-coder-flash", @@ -13535,7 +14025,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3-coder-plus": { id: "qwen/qwen3-coder-plus", @@ -13605,7 +14095,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3-next-80b-a3b-thinking": { id: "qwen/qwen3-next-80b-a3b-thinking", @@ -13768,7 +14258,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.5-27b": { id: "qwen/qwen3.5-27b", @@ -13800,13 +14290,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.14, - output: 1, - cacheRead: 0, + input: 0.25, + output: 1.25, + cacheRead: 0.25, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -13844,7 +14334,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.5-flash-02-23": { id: "qwen/qwen3.5-flash-02-23", @@ -13914,13 +14404,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.3, - output: 2, - cacheRead: 0.15, + input: 0.6, + output: 3.5999999999999996, + cacheRead: 0.12, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 65536, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -13933,13 +14423,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.14, - output: 1, - cacheRead: 0, + input: 0.09999999999999999, + output: 0.8999999999999999, + cacheRead: 0.049999999999999996, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.6-flash": { id: "qwen/qwen3.6-flash", @@ -14055,6 +14545,79 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"openai-completions">, + "qwen/qwen3.8-2.4t-a95b": { + id: "qwen/qwen3.8-2.4t-a95b", + name: "Qwen: Qwen3.8 2.4T A95B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":null,"xhigh":"xhigh","max":null}, + input: ["text"], + cost: { + input: 2, + output: 6, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "qwen/qwen3.8-27b": { + id: "qwen/qwen3.8-27b", + name: "Qwen: Qwen3.8 27B", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":null,"xhigh":"xhigh","max":null}, + input: ["text", "image"], + cost: { + input: 0.425, + output: 2.5500000000000003, + cacheRead: 0.08499999999999999, + cacheWrite: 0.53125, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen/qwen3.8-flash": { + id: "qwen/qwen3.8-flash", + name: "Qwen: Qwen3.8 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.47, + cacheRead: 0.016, + cacheWrite: 0.19999999999999998, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "qwen/qwen3.8-max": { + id: "qwen/qwen3.8-max", + name: "Qwen: Qwen3.8 Max", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":null}, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.25, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "rekaai/reka-edge": { id: "rekaai/reka-edge", name: "Reka Edge", @@ -14070,7 +14633,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 16384, - maxTokens: 16384, + maxTokens: 14745, } satisfies Model<"openai-completions">, "relace/relace-search": { id: "relace/relace-search", @@ -14107,6 +14670,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"openai-completions">, + "sakana/sakana-namazu": { + id: "sakana/sakana-namazu", + name: "Sakana: Sakana Namazu", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 0.95, + output: 4, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "sao10k/l3.1-euryale-70b": { id: "sao10k/l3.1-euryale-70b", name: "Sao10K: Llama 3.1 Euryale 70B v2.2", @@ -14159,7 +14740,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 256000, + maxTokens: 230400, } satisfies Model<"openai-completions">, "tencent/hy3": { id: "tencent/hy3", @@ -14189,13 +14770,31 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.063, - output: 0.21, - cacheRead: 0.020999999999999998, + input: 0.18, + output: 0.6, + cacheRead: 0.06, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 235929, + } satisfies Model<"openai-completions">, + "tencent/hy4-preview": { + id: "tencent/hy4-preview", + name: "Tencent: Hy4 preview", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text"], + cost: { + input: 0.834, + output: 2.501, + cacheRead: 0.041999999999999996, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 4096, + contextWindow: 1048576, + maxTokens: 64000, } satisfies Model<"openai-completions">, "thedrummer/unslopnemo-12b": { id: "thedrummer/unslopnemo-12b", @@ -14212,7 +14811,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1024000, - maxTokens: 32768, + maxTokens: 26214, } satisfies Model<"openai-completions">, "thinkingmachines/inkling": { id: "thinkingmachines/inkling", @@ -14224,13 +14823,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":null,"max":"max"}, input: ["text", "image"], cost: { - input: 1, + input: 0.95, output: 4.05, - cacheRead: 0.16999999999999998, + cacheRead: 0.16, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 4096, + maxTokens: 262144, } satisfies Model<"openai-completions">, "thinkingmachines/inkling-small": { id: "thinkingmachines/inkling-small", @@ -14242,13 +14841,49 @@ export const MODELS = { thinkingLevelMap: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":null,"max":"max"}, input: ["text", "image"], cost: { - input: 0.5, + input: 0.44999999999999996, output: 1.2, cacheRead: 0.09999999999999999, cacheWrite: 0, }, - contextWindow: 524288, - maxTokens: 4096, + contextWindow: 1048576, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "thinkingmachines/inkling-small:free": { + id: "thinkingmachines/inkling-small:free", + name: "Thinking Machines: Inkling Small (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "thinkingmachines/inkling:free": { + id: "thinkingmachines/inkling:free", + name: "Thinking Machines: Inkling (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 262144, } satisfies Model<"openai-completions">, "upstage/solar-pro-3": { id: "upstage/solar-pro-3", @@ -14266,12 +14901,31 @@ export const MODELS = { cacheRead: 0.015, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 4096, + contextWindow: 131072, + maxTokens: 117964, + } satisfies Model<"openai-completions">, + "upstage/solar-pro4": { + id: "upstage/solar-pro4", + name: "Upstage: Solar Pro 4", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + compat: {"supportsReasoningEffort":false}, + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, + input: ["text"], + cost: { + input: 0.03, + output: 0.12, + cacheRead: 0.006, + cacheWrite: 0, + }, + contextWindow: 524288, + maxTokens: 131072, } satisfies Model<"openai-completions">, "x-ai/grok-4.20": { id: "x-ai/grok-4.20", - name: "xAI: Grok 4.20", + name: "SpaceXAI: Grok 4.20", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -14286,11 +14940,11 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 2000000, - maxTokens: 4096, + maxTokens: 1800000, } satisfies Model<"openai-completions">, "x-ai/grok-4.3": { id: "x-ai/grok-4.3", - name: "xAI: Grok 4.3", + name: "SpaceXAI: Grok 4.3", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -14304,11 +14958,11 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 1000000, - maxTokens: 4096, + maxTokens: 900000, } satisfies Model<"openai-completions">, "x-ai/grok-4.5": { id: "x-ai/grok-4.5", - name: "xAI: Grok 4.5", + name: "SpaceXAI: Grok 4.5", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -14322,11 +14976,29 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 500000, - maxTokens: 4096, + maxTokens: 450000, + } satisfies Model<"openai-completions">, + "x-ai/grok-4.6": { + id: "x-ai/grok-4.6", + name: "SpaceXAI: Grok 4.6", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":null}, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 450000, } satisfies Model<"openai-completions">, "x-ai/grok-build-0.1": { id: "x-ai/grok-build-0.1", - name: "xAI: Grok Build 0.1", + name: "SpaceXAI: Grok Build 0.1", api: "openai-completions", provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", @@ -14341,7 +15013,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 256000, - maxTokens: 4096, + maxTokens: 230400, } satisfies Model<"openai-completions">, "xiaomi/mimo-v2.5": { id: "xiaomi/mimo-v2.5", @@ -14449,13 +15121,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.5, - output: 2, - cacheRead: 0.09999999999999999, + input: 0.43, + output: 1.75, + cacheRead: 0.08, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 131072, + maxTokens: 16384, } satisfies Model<"openai-completions">, "z-ai/glm-4.6v": { id: "z-ai/glm-4.6v", @@ -14531,7 +15203,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 131072, + maxTokens: 128000, } satisfies Model<"openai-completions">, "z-ai/glm-5-turbo": { id: "z-ai/glm-5-turbo", @@ -14563,13 +15235,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.966, - output: 3.036, - cacheRead: 0.1794, + input: 1.26, + output: 3.9600000000000004, + cacheRead: 0.234, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 128000, + maxTokens: 182476, } satisfies Model<"openai-completions">, "z-ai/glm-5.2": { id: "z-ai/glm-5.2", @@ -14581,14 +15253,68 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null}, input: ["text"], cost: { - input: 0.2842, - output: 0.8932, - cacheRead: 0.05278, + input: 1.19, + output: 3.74, + cacheRead: 0.221, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 262144, + } satisfies Model<"openai-completions">, + "z-ai/glm-5.2:free": { + id: "z-ai/glm-5.2:free", + name: "Z.ai: GLM 5.2 (free)", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null}, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 230400, + } satisfies Model<"openai-completions">, + "z-ai/glm-5.3": { + id: "z-ai/glm-5.3", + name: "Z.ai: GLM 5.3", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":"max"}, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, cacheWrite: 0, }, contextWindow: 1048576, maxTokens: 131072, } satisfies Model<"openai-completions">, + "z-ai/glm-5.3-flash": { + id: "z-ai/glm-5.3-flash", + name: "Z.ai: GLM 5.3 Flash", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0.075, + output: 0.25, + cacheRead: 0.015, + cacheWrite: 0, + }, + contextWindow: 1310720, + maxTokens: 131072, + } satisfies Model<"openai-completions">, "z-ai/glm-5v-turbo": { id: "z-ai/glm-5v-turbo", name: "Z.ai: GLM 5V Turbo", @@ -14692,13 +15418,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.09, - output: 0.18, - cacheRead: 0.018, + input: 0.03, + output: 0.09999999999999999, + cacheRead: 0.007, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 65536, + contextWindow: 1310720, + maxTokens: 131072, } satisfies Model<"openai-completions">, "~google/gemini-flash-latest": { id: "~google/gemini-flash-latest", @@ -14707,13 +15433,13 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - thinkingLevelMap: {"off":null,"minimal":"minimal","low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 1.5, - output: 7.5, - cacheRead: 0.15, - cacheWrite: 0.08333333333333334, + input: 0.375, + output: 1.875, + cacheRead: 0.0375, + cacheWrite: 0.0208333333333333, }, contextWindow: 1048576, maxTokens: 65536, @@ -14746,13 +15472,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":"max"}, input: ["text", "image"], cost: { - input: 2.9000000000000004, - output: 14, - cacheRead: 0.29, + input: 2.5500000000000003, + output: 12.75, + cacheRead: 0.25599998999999996, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 1048576, + maxTokens: 943718, } satisfies Model<"openai-completions">, "~openai/gpt-latest": { id: "~openai/gpt-latest", @@ -14764,10 +15490,10 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 2, + output: 10, + cacheRead: 0.19999999999999998, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -14797,16 +15523,34 @@ export const MODELS = { provider: "openrouter", baseUrl: "https://openrouter.ai/api/v1", reasoning: true, - thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":"xhigh","max":null}, input: ["text", "image"], cost: { input: 2, output: 6, - cacheRead: 0.3, + cacheRead: 0.5, cacheWrite: 0, }, contextWindow: 500000, - maxTokens: 4096, + maxTokens: 450000, + } satisfies Model<"openai-completions">, + "~z-ai/glm-latest": { + id: "~z-ai/glm-latest", + name: "Z.ai: GLM Latest", + api: "openai-completions", + provider: "openrouter", + baseUrl: "https://openrouter.ai/api/v1", + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":"max"}, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, } satisfies Model<"openai-completions">, }, "prime-inference": { @@ -15056,8 +15800,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.5, - output: 1.5, + input: 0.4, + output: 1.3, cacheRead: 0, cacheWrite: 0, }, @@ -15074,13 +15818,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 1.25, - output: 1.5, + input: 0.27, + output: 1.12, cacheRead: 0, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 65536, + maxTokens: 147456, } satisfies Model<"openai-completions">, "deepseek/deepseek-chat-v3.1": { id: "deepseek/deepseek-chat-v3.1", @@ -15093,13 +15837,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.56, - output: 1.68, + input: 0.6, + output: 1.7, cacheRead: 0, cacheWrite: 0, }, contextWindow: 163840, - maxTokens: 32768, + maxTokens: 144900, } satisfies Model<"openai-completions">, "deepseek/deepseek-v3.1-terminus": { id: "deepseek/deepseek-v3.1-terminus", @@ -15112,8 +15856,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.45, - output: 1.5, + input: 0.27, + output: 1, cacheRead: 0, cacheWrite: 0, }, @@ -15131,8 +15875,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.28, - output: 0.42, + input: 0.3705, + output: 1.1115, cacheRead: 0, cacheWrite: 0, }, @@ -15151,8 +15895,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.28, - output: 0.42, + input: 0.27, + output: 0.41, cacheRead: 0, cacheWrite: 0, }, @@ -15170,13 +15914,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.14, - output: 0.28, + input: 0.44, + output: 1.32, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 393216, + maxTokens: 384000, featured: true, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-flash-0731": { @@ -15190,13 +15934,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 0.14, - output: 0.28, + input: 0.44, + output: 1.32, cacheRead: 0, cacheWrite: 0, }, - contextWindow: 1048576, - maxTokens: 384000, + contextWindow: 1310720, + maxTokens: 943718, } satisfies Model<"openai-completions">, "deepseek/deepseek-v4-pro": { id: "deepseek/deepseek-v4-pro", @@ -15209,13 +15953,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"max","max":null}, input: ["text"], cost: { - input: 2.1, - output: 4.4, + input: 1.91, + output: 3.83, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 393216, + maxTokens: 384000, featured: true, } satisfies Model<"openai-completions">, "google/gemini-2.5-flash": { @@ -15351,6 +16095,25 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 65536, } satisfies Model<"openai-completions">, + "google/gemini-3.7-flash": { + id: "google/gemini-3.7-flash", + name: "Gemini 3.7 Flash", + api: "openai-completions", + provider: "prime-inference", + baseUrl: "https://api.pinference.ai/api/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, + input: ["text", "image"], + cost: { + input: 1.35, + output: 6.75, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 65536, + } satisfies Model<"openai-completions">, "google/gemma-3-27b-it": { id: "google/gemma-3-27b-it", name: "Gemma 3 27B IT", @@ -15367,7 +16130,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "meta-llama/Llama-3.2-1B-Instruct": { id: "meta-llama/Llama-3.2-1B-Instruct", @@ -15379,13 +16142,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.03, - output: 0.21, + input: 0.027, + output: 0.201, cacheRead: 0, cacheWrite: 0, }, contextWindow: 60000, - maxTokens: 60000, + maxTokens: 54000, } satisfies Model<"openai-completions">, "meta-llama/Llama-3.2-3B-Instruct": { id: "meta-llama/Llama-3.2-3B-Instruct", @@ -15397,8 +16160,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, - output: 0.34, + input: 0.0509, + output: 0.335, cacheRead: 0, cacheWrite: 0, }, @@ -15415,13 +16178,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.9, - output: 0.9, + input: 1.04, + output: 2.253, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 16384, + maxTokens: 115200, } satisfies Model<"openai-completions">, "meta-llama/llama-4-maverick": { id: "meta-llama/llama-4-maverick", @@ -15433,13 +16196,13 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.27, - output: 0.88, + input: 0.35, + output: 1.15, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 8192, + maxTokens: 16384, } satisfies Model<"openai-completions">, "minimax/minimax-m2.5": { id: "minimax/minimax-m2.5", @@ -15452,13 +16215,13 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.3, - output: 1.2, + input: 0.6, + output: 2.4, cacheRead: 0, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 196608, + maxTokens: 128000, } satisfies Model<"openai-completions">, "minimax/minimax-m2.7": { id: "minimax/minimax-m2.7", @@ -15515,7 +16278,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 8192, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/mistral-nemo": { id: "mistralai/mistral-nemo", @@ -15527,8 +16290,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.1, - output: 0.25, + input: 0.165, + output: 0.17, cacheRead: 0, cacheWrite: 0, }, @@ -15546,13 +16309,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.1875, - output: 0.75, + input: 0.165, + output: 0.66, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 8192, + maxTokens: 209715, } satisfies Model<"openai-completions">, "mistralai/mixtral-8x22b-instruct": { id: "mistralai/mixtral-8x22b-instruct", @@ -15564,13 +16327,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 2, - output: 6, + input: 2.2, + output: 6.6, cacheRead: 0, cacheWrite: 0, }, contextWindow: 65536, - maxTokens: 8192, + maxTokens: 52428, } satisfies Model<"openai-completions">, "moonshotai/kimi-k2-0905": { id: "moonshotai/kimi-k2-0905", @@ -15582,8 +16345,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 1.2, - output: 5, + input: 0.6, + output: 2.5, cacheRead: 0, cacheWrite: 0, }, @@ -15602,7 +16365,7 @@ export const MODELS = { input: ["text", "image"], cost: { input: 0.6, - output: 3.2, + output: 3, cacheRead: 0, cacheWrite: 0, }, @@ -15639,13 +16402,13 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 1.0925, - output: 4.6, + input: 1.9, + output: 8, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, featured: true, } satisfies Model<"openai-completions">, "moonshotai/kimi-k3": { @@ -15659,8 +16422,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":"max"}, input: ["text", "image"], cost: { - input: 3, - output: 15, + input: 3.45, + output: 17.25, cacheRead: 0, cacheWrite: 0, }, @@ -15679,8 +16442,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.05, - output: 0.2, + input: 0.06, + output: 0.24, cacheRead: 0, cacheWrite: 0, }, @@ -15700,7 +16463,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.3, - output: 0.9, + output: 0.65, cacheRead: 0, cacheWrite: 0, }, @@ -15893,25 +16656,6 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"openai-completions">, - "openai/gpt-5.2-chat": { - id: "openai/gpt-5.2-chat", - name: "GPT 5.2 Chat", - api: "openai-completions", - provider: "prime-inference", - baseUrl: "https://api.pinference.ai/api/v1", - compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":true,"maxTokensField":"max_tokens","supportsStrictMode":false}, - reasoning: false, - thinkingLevelMap: {"xhigh":"xhigh"}, - input: ["text", "image"], - cost: { - input: 1.75, - output: 14, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 32000, - } satisfies Model<"openai-completions">, "openai/gpt-5.2-pro": { id: "openai/gpt-5.2-pro", name: "GPT 5.2 PRO", @@ -16194,13 +16938,13 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":"medium","high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.15, - output: 0.6, + input: 0.35, + output: 0.75, cacheRead: 0, cacheWrite: 0, }, contextWindow: 131072, - maxTokens: 131072, + maxTokens: 117964, } satisfies Model<"openai-completions">, "openai/gpt-oss-20b": { id: "openai/gpt-oss-20b", @@ -16232,8 +16976,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.1, - output: 0.19, + input: 0.09, + output: 0.18, cacheRead: 0, cacheWrite: 0, }, @@ -16251,8 +16995,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.065, - output: 0.13, + input: 0.06, + output: 0.12, cacheRead: 0, cacheWrite: 0, }, @@ -16269,13 +17013,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.22, - output: 0.88, + input: 0.25, + output: 1, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3-30b-a3b-instruct-2507": { id: "qwen/qwen3-30b-a3b-instruct-2507", @@ -16287,8 +17031,8 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.2, - output: 0.8, + input: 0.13, + output: 0.52, cacheRead: 0, cacheWrite: 0, }, @@ -16325,7 +17069,7 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 2, + input: 0.975, output: 4.875, cacheRead: 0, cacheWrite: 0, @@ -16343,13 +17087,13 @@ export const MODELS = { reasoning: false, input: ["text"], cost: { - input: 0.5, + input: 0.3, output: 1.5, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, featured: true, } satisfies Model<"openai-completions">, "qwen/qwen3-max": { @@ -16381,8 +17125,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.4, - output: 1.9, + input: 0.3, + output: 1.5, cacheRead: 0, cacheWrite: 0, }, @@ -16400,7 +17144,7 @@ export const MODELS = { thinkingLevelMap: {"off":null,"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 1, + input: 0.98, output: 4, cacheRead: 0, cacheWrite: 0, @@ -16419,13 +17163,13 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.25, - output: 1, + input: 0.2, + output: 0.7, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 16384, + maxTokens: 32768, } satisfies Model<"openai-completions">, "qwen/qwen3-vl-8b-instruct": { id: "qwen/qwen3-vl-8b-instruct", @@ -16437,8 +17181,8 @@ export const MODELS = { reasoning: false, input: ["text", "image"], cost: { - input: 0.18, - output: 0.7, + input: 0.117, + output: 0.455, cacheRead: 0, cacheWrite: 0, }, @@ -16456,13 +17200,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.3125, - output: 1.8, + input: 0.25, + output: 1.3, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.5-397b-a17b": { id: "qwen/qwen3.5-397b-a17b", @@ -16500,7 +17244,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.6-35b-a3b": { id: "qwen/qwen3.6-35b-a3b", @@ -16513,13 +17257,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.3, - output: 1.8, + input: 0.25, + output: 1.25, cacheRead: 0, cacheWrite: 0, }, contextWindow: 262144, - maxTokens: 262144, + maxTokens: 235929, } satisfies Model<"openai-completions">, "qwen/qwen3.7-flash": { id: "qwen/qwen3.7-flash", @@ -16611,8 +17355,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text", "image"], cost: { - input: 0.4, - output: 2, + input: 0.168, + output: 0.336, cacheRead: 0, cacheWrite: 0, }, @@ -16630,8 +17374,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 1, - output: 3, + input: 0.48024, + output: 1.5, cacheRead: 0, cacheWrite: 0, }, @@ -16693,7 +17437,7 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 131072, + maxTokens: 16384, } satisfies Model<"openai-completions">, "z-ai/glm-4.7": { id: "z-ai/glm-4.7", @@ -16707,7 +17451,7 @@ export const MODELS = { input: ["text"], cost: { input: 0.6, - output: 2.65, + output: 2.2, cacheRead: 0, cacheWrite: 0, }, @@ -16725,8 +17469,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 0.1, - output: 0.43, + input: 0.07, + output: 0.4, cacheRead: 0, cacheWrite: 0, }, @@ -16744,8 +17488,8 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 1.2, - output: 3.5, + input: 1, + output: 3.2, cacheRead: 0, cacheWrite: 0, }, @@ -16764,13 +17508,13 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":null,"max":null}, input: ["text"], cost: { - input: 1.75, - output: 5.5, + input: 1.4, + output: 4.4, cacheRead: 0, cacheWrite: 0, }, contextWindow: 204800, - maxTokens: 131072, + maxTokens: 182476, featured: true, } satisfies Model<"openai-completions">, "z-ai/glm-5.2": { @@ -16784,15 +17528,53 @@ export const MODELS = { thinkingLevelMap: {"minimal":null,"low":null,"medium":null,"high":"high","xhigh":"xhigh","max":null}, input: ["text"], cost: { - input: 1.68, - output: 5.28, + input: 1.54, + output: 4.84, cacheRead: 0, cacheWrite: 0, }, contextWindow: 1048576, - maxTokens: 131072, + maxTokens: 262144, featured: true, } satisfies Model<"openai-completions">, + "z-ai/glm-5.3": { + id: "z-ai/glm-5.3", + name: "GLM 5.3", + api: "openai-completions", + provider: "prime-inference", + baseUrl: "https://api.pinference.ai/api/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":"max"}, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "z-ai/glm-5.3-flash": { + id: "z-ai/glm-5.3-flash", + name: "GLM 5.3 Flash", + api: "openai-completions", + provider: "prime-inference", + baseUrl: "https://api.pinference.ai/api/v1", + compat: {"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"maxTokensField":"max_tokens","supportsStrictMode":false,"thinkingFormat":"zai"}, + reasoning: true, + thinkingLevelMap: {"off":null,"minimal":null,"low":"low","medium":null,"high":"high","xhigh":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.5, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1310720, + maxTokens: 131072, + } satisfies Model<"openai-completions">, }, "vercel-ai-gateway": { "alibaba/qwen-3-14b": { @@ -17220,6 +18002,74 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.8-2.4t-a95b": { + id: "alibaba/qwen3.8-2.4t-a95b", + name: "Qwen3.8 2.4T A95B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.19999999999999998, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.8-27b": { + id: "alibaba/qwen3.8-27b", + name: "Qwen3.8 27B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.55, + output: 3.3000000000000003, + cacheRead: 0.11, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.8-flash": { + id: "alibaba/qwen3.8-flash", + name: "Qwen 3.8 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.16, + output: 0.47, + cacheRead: 0.016, + cacheWrite: 0.19999999999999998, + }, + contextWindow: 991000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "alibaba/qwen3.8-max": { + id: "alibaba/qwen3.8-max", + name: "Qwen 3.8 Max", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.25, + cacheWrite: 2.5, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "amazon/nova-2-lite": { id: "amazon/nova-2-lite", name: "Nova 2 Lite", @@ -17357,23 +18207,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, - "anthropic/claude-opus-4.1": { - id: "anthropic/claude-opus-4.1", - name: "Claude Opus 4.1", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 15, - output: 75, - cacheRead: 1.5, - cacheWrite: 18.75, - }, - contextWindow: 200000, - maxTokens: 32000, - } satisfies Model<"anthropic-messages">, "anthropic/claude-opus-4.5": { id: "anthropic/claude-opus-4.5", name: "Claude Opus 4.5", @@ -17586,23 +18419,6 @@ export const MODELS = { contextWindow: 262100, maxTokens: 80000, } satisfies Model<"anthropic-messages">, - "arcee-ai/trinity-mini": { - id: "arcee-ai/trinity-mini", - name: "Trinity Mini", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, - input: ["text"], - cost: { - input: 0.045, - output: 0.15, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 131072, - maxTokens: 131072, - } satisfies Model<"anthropic-messages">, "bytedance/seed-1.6": { id: "bytedance/seed-1.6", name: "Seed 1.6", @@ -17765,9 +18581,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.14, - output: 0.28, - cacheRead: 0.0028, + input: 0.13, + output: 0.26, + cacheRead: 0.028, cacheWrite: 0, }, contextWindow: 1000000, @@ -17782,9 +18598,26 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.13, - output: 0.26, - cacheRead: 0.028, + input: 0.07600000000000001, + output: 0.153, + cacheRead: 0.014, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 384000, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v4-flash-vision-exp": { + id: "deepseek/deepseek-v4-flash-vision-exp", + name: "DeepSeek V4 Flash Vision Exp", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.22, + output: 0.66, + cacheRead: 0.007, cacheWrite: 0, }, contextWindow: 1000000, @@ -17799,9 +18632,26 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.435, - output: 0.87, - cacheRead: 0.0036, + input: 1.74, + output: 3.48, + cacheRead: 0.14, + cacheWrite: 0, + }, + contextWindow: 1048600, + maxTokens: 1048600, + } satisfies Model<"anthropic-messages">, + "deepseek/deepseek-v4-pro-0813": { + id: "deepseek/deepseek-v4-pro-0813", + name: "DeepSeek V4 Pro 0813", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.66, + output: 1.9800000000000002, + cacheRead: 0.06599999999999999, cacheWrite: 0, }, contextWindow: 1000000, @@ -17952,17 +18802,34 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 1.5, - output: 7.5, - cacheRead: 0.15, + input: 0.75, + output: 3.75, + cacheRead: 0.075, cacheWrite: 0, }, contextWindow: 1000000, maxTokens: 64000, } satisfies Model<"anthropic-messages">, + "google/gemini-3.7-flash": { + id: "google/gemini-3.7-flash", + name: "Gemini 3.7 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.75, + output: 3.75, + cacheRead: 0.075, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 65536, + } satisfies Model<"anthropic-messages">, "google/gemma-4-26b-a4b-it": { id: "google/gemma-4-26b-a4b-it", - name: "Gemma 4 26B A4B IT", + name: "Google Gemma 4 26B A4B", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", @@ -18028,14 +18895,31 @@ export const MODELS = { contextWindow: 32000, maxTokens: 16384, } satisfies Model<"anthropic-messages">, - "inclusionai/ling-3.0-flash-free": { - id: "inclusionai/ling-3.0-flash-free", + "inclusionai/ling-3.0-flash": { + id: "inclusionai/ling-3.0-flash", name: "Ling 3.0 Flash", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, input: ["text"], + cost: { + input: 0.06, + output: 0.18, + cacheRead: 0.012, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "inclusionai/ling-3.0-flash-fin": { + id: "inclusionai/ling-3.0-flash-fin", + name: "Ling 3.0 Flash Fin", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], cost: { input: 0, output: 0, @@ -18043,7 +18927,24 @@ export const MODELS = { cacheWrite: 0, }, contextWindow: 256000, - maxTokens: 256000, + maxTokens: 32000, + } satisfies Model<"anthropic-messages">, + "inclusionai/ling-3.0-flash-fin-free": { + id: "inclusionai/ling-3.0-flash-fin-free", + name: "Ling 3.0 Flash Fin (Free)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 32000, } satisfies Model<"anthropic-messages">, "interfaze/interfaze-beta": { id: "interfaze/interfaze-beta", @@ -18215,6 +19116,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 8192, } satisfies Model<"anthropic-messages">, + "meta/muse-glimmer-30b": { + id: "meta/muse-glimmer-30b", + name: "Muse Glimmer 30B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.35, + output: 1.5, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 131072, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "meta/muse-spark-1.1": { id: "meta/muse-spark-1.1", name: "Muse Spark 1.1", @@ -18232,6 +19150,40 @@ export const MODELS = { contextWindow: 1048576, maxTokens: 1048576, } satisfies Model<"anthropic-messages">, + "meta/muse-spark-1.2": { + id: "meta/muse-spark-1.2", + name: "Muse Spark 1.2", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1.25, + output: 4.25, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 1048576, + } satisfies Model<"anthropic-messages">, + "meta/muse-spark-1.2-contributor": { + id: "meta/muse-spark-1.2-contributor", + name: "Muse Spark 1.2 Contributor", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09999999999999999, + output: 0.19999999999999998, + cacheRead: 0.002, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 1048576, + } satisfies Model<"anthropic-messages">, "minimax/minimax-m2": { id: "minimax/minimax-m2", name: "MiniMax M2", @@ -18334,6 +19286,23 @@ export const MODELS = { contextWindow: 204800, maxTokens: 131000, } satisfies Model<"anthropic-messages">, + "minimax/minimax-m2.7-free": { + id: "minimax/minimax-m2.7-free", + name: "Minimax M2.7 (Free)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 196608, + maxTokens: 196608, + } satisfies Model<"anthropic-messages">, "minimax/minimax-m2.7-highspeed": { id: "minimax/minimax-m2.7-highspeed", name: "MiniMax M2.7 High Speed", @@ -18368,6 +19337,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 1000000, } satisfies Model<"anthropic-messages">, + "minimax/minimax-m3-free": { + id: "minimax/minimax-m3-free", + name: "MiniMax M3 (Free)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1048576, + maxTokens: 1048576, + } satisfies Model<"anthropic-messages">, "mistral/codestral": { id: "mistral/codestral", name: "Mistral Codestral", @@ -18419,40 +19405,6 @@ export const MODELS = { contextWindow: 256000, maxTokens: 256000, } satisfies Model<"anthropic-messages">, - "mistral/magistral-medium": { - id: "mistral/magistral-medium", - name: "Magistral Medium 2509", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 2, - output: 5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, - "mistral/magistral-small": { - id: "mistral/magistral-small", - name: "Magistral Small 2509", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.5, - output: 1.5, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 64000, - } satisfies Model<"anthropic-messages">, "mistral/ministral-14b": { id: "mistral/ministral-14b", name: "Ministral 14B", @@ -18795,6 +19747,23 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 65000, } satisfies Model<"anthropic-messages">, + "nvidia/nemotron-3.5-lightning": { + id: "nvidia/nemotron-3.5-lightning", + name: "Nemotron 3.5 Lightning 30B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.049999999999999996, + output: 0.15, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 262144, + maxTokens: 131072, + } satisfies Model<"anthropic-messages">, "nvidia/nemotron-nano-12b-v2-vl": { id: "nvidia/nemotron-nano-12b-v2-vl", name: "Nvidia Nemotron Nano 12B V2 VL", @@ -18880,6 +19849,23 @@ export const MODELS = { contextWindow: 1047576, maxTokens: 32768, } satisfies Model<"anthropic-messages">, + "openai/gpt-4.1-fast": { + id: "openai/gpt-4.1-fast", + name: "GPT-4.1 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 3.5, + output: 14, + cacheRead: 0.875, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, "openai/gpt-4.1-mini": { id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", @@ -18897,6 +19883,23 @@ export const MODELS = { contextWindow: 1047576, maxTokens: 32768, } satisfies Model<"anthropic-messages">, + "openai/gpt-4.1-mini-fast": { + id: "openai/gpt-4.1-mini-fast", + name: "GPT-4.1 mini (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.7, + output: 2.8, + cacheRead: 0.175, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, "openai/gpt-4.1-nano": { id: "openai/gpt-4.1-nano", name: "GPT-4.1 nano", @@ -18914,6 +19917,23 @@ export const MODELS = { contextWindow: 1047576, maxTokens: 32768, } satisfies Model<"anthropic-messages">, + "openai/gpt-4.1-nano-fast": { + id: "openai/gpt-4.1-nano-fast", + name: "GPT-4.1 nano (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 0.7999999999999999, + cacheRead: 0.049999999999999996, + cacheWrite: 0, + }, + contextWindow: 1047576, + maxTokens: 32768, + } satisfies Model<"anthropic-messages">, "openai/gpt-4o": { id: "openai/gpt-4o", name: "GPT-4o", @@ -18931,6 +19951,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"anthropic-messages">, + "openai/gpt-4o-fast": { + id: "openai/gpt-4o-fast", + name: "GPT-4o (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 4.25, + output: 17, + cacheRead: 2.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, "openai/gpt-4o-mini": { id: "openai/gpt-4o-mini", name: "GPT-4o mini", @@ -18948,6 +19985,23 @@ export const MODELS = { contextWindow: 128000, maxTokens: 16384, } satisfies Model<"anthropic-messages">, + "openai/gpt-4o-mini-fast": { + id: "openai/gpt-4o-mini-fast", + name: "GPT-4o mini (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: false, + input: ["text", "image"], + cost: { + input: 0.25, + output: 1, + cacheRead: 0.125, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16384, + } satisfies Model<"anthropic-messages">, "openai/gpt-5": { id: "openai/gpt-5", name: "GPT-5", @@ -18982,6 +20036,23 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-5-fast": { + id: "openai/gpt-5-fast", + name: "GPT-5 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2.5, + output: 20, + cacheRead: 0.25, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "openai/gpt-5-mini": { id: "openai/gpt-5-mini", name: "GPT-5 mini", @@ -18991,9 +20062,26 @@ export const MODELS = { reasoning: true, input: ["text", "image"], cost: { - input: 0.25, - output: 2, - cacheRead: 0.024999999999999998, + input: 0.25, + output: 2, + cacheRead: 0.024999999999999998, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5-mini-fast": { + id: "openai/gpt-5-mini-fast", + name: "GPT-5 mini (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.44999999999999996, + output: 3.5999999999999996, + cacheRead: 0.045, cacheWrite: 0, }, contextWindow: 400000, @@ -19084,35 +20172,35 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "openai/gpt-5.1-instant": { - id: "openai/gpt-5.1-instant", - name: "GPT-5.1 Instant", + "openai/gpt-5.1-thinking": { + id: "openai/gpt-5.1-thinking", + name: "GPT 5.1 Thinking", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, + reasoning: true, input: ["text", "image"], cost: { input: 1.25, output: 10, - cacheRead: 0.13, + cacheRead: 0.125, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "openai/gpt-5.1-thinking": { - id: "openai/gpt-5.1-thinking", - name: "GPT 5.1 Thinking", + "openai/gpt-5.1-thinking-fast": { + id: "openai/gpt-5.1-thinking-fast", + name: "GPT 5.1 Thinking (Fast)", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, input: ["text", "image"], cost: { - input: 1.25, - output: 10, - cacheRead: 0.125, + input: 2.5, + output: 20, + cacheRead: 0.25, cacheWrite: 0, }, contextWindow: 400000, @@ -19154,6 +20242,24 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-5.2-fast": { + id: "openai/gpt-5.2-fast", + name: "GPT 5.2 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 3.5, + output: 28, + cacheRead: 0.35, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "openai/gpt-5.2-pro": { id: "openai/gpt-5.2-pro", name: "GPT 5.2 ", @@ -19172,13 +20278,13 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "openai/gpt-5.3-chat": { - id: "openai/gpt-5.3-chat", - name: "GPT-5.3 Chat", + "openai/gpt-5.3-codex": { + id: "openai/gpt-5.3-codex", + name: "GPT 5.3 Codex", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", - reasoning: false, + reasoning: true, thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { @@ -19187,12 +20293,12 @@ export const MODELS = { cacheRead: 0.175, cacheWrite: 0, }, - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 400000, + maxTokens: 128000, } satisfies Model<"anthropic-messages">, - "openai/gpt-5.3-codex": { - id: "openai/gpt-5.3-codex", - name: "GPT 5.3 Codex", + "openai/gpt-5.3-codex-fast": { + id: "openai/gpt-5.3-codex-fast", + name: "GPT 5.3 Codex (Fast)", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", @@ -19200,9 +20306,9 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh"}, input: ["text", "image"], cost: { - input: 1.75, - output: 14, - cacheRead: 0.175, + input: 3.5, + output: 28, + cacheRead: 0.35, cacheWrite: 0, }, contextWindow: 400000, @@ -19226,6 +20332,24 @@ export const MODELS = { contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-5.4-fast": { + id: "openai/gpt-5.4-fast", + name: "GPT 5.4 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 5, + output: 30, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "openai/gpt-5.4-mini": { id: "openai/gpt-5.4-mini", name: "GPT 5.4 Mini", @@ -19244,6 +20368,24 @@ export const MODELS = { contextWindow: 400000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-5.4-mini-fast": { + id: "openai/gpt-5.4-mini-fast", + name: "GPT 5.4 Mini (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 1.5, + output: 9, + cacheRead: 0.15, + cacheWrite: 0, + }, + contextWindow: 400000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "openai/gpt-5.4-nano": { id: "openai/gpt-5.4-nano", name: "GPT 5.4 Nano", @@ -19298,6 +20440,24 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-5.5-fast": { + id: "openai/gpt-5.5-fast", + name: "GPT 5.5 (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh"}, + input: ["text", "image"], + cost: { + input: 12.5, + output: 75, + cacheRead: 1.25, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "openai/gpt-5.5-pro": { id: "openai/gpt-5.5-pro", name: "GPT 5.5 Pro", @@ -19334,6 +20494,24 @@ export const MODELS = { contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-5.6-luna-fast": { + id: "openai/gpt-5.6-luna-fast", + name: "GPT 5.6 Luna (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 0.39999999999999997, + output: 2.4, + cacheRead: 0.04, + cacheWrite: 0.25, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "openai/gpt-5.6-sol": { id: "openai/gpt-5.6-sol", name: "GPT 5.6 Sol", @@ -19344,10 +20522,28 @@ export const MODELS = { thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, input: ["text", "image"], cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 6.25, + input: 2, + output: 10, + cacheRead: 0.19999999999999998, + cacheWrite: 2.5, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "openai/gpt-5.6-sol-fast": { + id: "openai/gpt-5.6-sol-fast", + name: "GPT 5.6 Sol (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 4, + output: 20, + cacheRead: 0.39999999999999997, + cacheWrite: 2.5, }, contextWindow: 1050000, maxTokens: 128000, @@ -19370,6 +20566,24 @@ export const MODELS = { contextWindow: 1050000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "openai/gpt-5.6-terra-fast": { + id: "openai/gpt-5.6-terra-fast", + name: "GPT 5.6 Terra (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + thinkingLevelMap: {"xhigh":"xhigh","minimal":null,"max":"max"}, + input: ["text", "image"], + cost: { + input: 4, + output: 24, + cacheRead: 0.39999999999999997, + cacheWrite: 2.5, + }, + contextWindow: 1050000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, "openai/gpt-oss-120b": { id: "openai/gpt-oss-120b", name: "GPT OSS 120B", @@ -19404,6 +20618,23 @@ export const MODELS = { contextWindow: 131072, maxTokens: 8192, } satisfies Model<"anthropic-messages">, + "openai/gpt-oss-safeguard-120b": { + id: "openai/gpt-oss-safeguard-120b", + name: "GPT OSS Safeguard 120B", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.15, + output: 0.6, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 128000, + maxTokens: 16000, + } satisfies Model<"anthropic-messages">, "openai/gpt-oss-safeguard-20b": { id: "openai/gpt-oss-safeguard-20b", name: "GPT OSS Safeguard 20B", @@ -19413,13 +20644,13 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0.075, - output: 0.3, - cacheRead: 0.037, + input: 0.07, + output: 0.19999999999999998, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 131072, - maxTokens: 65536, + contextWindow: 128000, + maxTokens: 16000, } satisfies Model<"anthropic-messages">, "openai/o1": { id: "openai/o1", @@ -19455,18 +20686,18 @@ export const MODELS = { contextWindow: 200000, maxTokens: 100000, } satisfies Model<"anthropic-messages">, - "openai/o3-deep-research": { - id: "openai/o3-deep-research", - name: "o3-deep-research", + "openai/o3-fast": { + id: "openai/o3-fast", + name: "o3 (Fast)", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, input: ["text", "image"], cost: { - input: 10, - output: 40, - cacheRead: 2.5, + input: 3.5, + output: 14, + cacheRead: 0.875, cacheWrite: 0, }, contextWindow: 200000, @@ -19523,6 +20754,23 @@ export const MODELS = { contextWindow: 200000, maxTokens: 100000, } satisfies Model<"anthropic-messages">, + "openai/o4-mini-fast": { + id: "openai/o4-mini-fast", + name: "o4-mini (Fast)", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 8, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 200000, + maxTokens: 100000, + } satisfies Model<"anthropic-messages">, "poolside/laguna-s-2.1": { id: "poolside/laguna-s-2.1", name: "Laguna S 2.1", @@ -19549,118 +20797,50 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 32768, - } satisfies Model<"anthropic-messages">, - "sakana/fugu-ultra": { - id: "sakana/fugu-ultra", - name: "Fugu Ultra", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 5, - output: 30, - cacheRead: 0.5, - cacheWrite: 0, - }, - contextWindow: 1000000, - maxTokens: 1000000, - } satisfies Model<"anthropic-messages">, - "stepfun/step-3.5-flash": { - id: "stepfun/step-3.5-flash", - name: "StepFun 3.5 Flash", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.09, - output: 0.3, - cacheRead: 0.02, - cacheWrite: 0, - }, - contextWindow: 262114, - maxTokens: 262114, - } satisfies Model<"anthropic-messages">, - "stepfun/step-3.7-flash": { - id: "stepfun/step-3.7-flash", - name: "Step 3.7 Flash", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.19999999999999998, - output: 1.15, - cacheRead: 0.04, - cacheWrite: 0, - }, - contextWindow: 256000, - maxTokens: 256000, - } satisfies Model<"anthropic-messages">, - "tencent/hy3": { - id: "tencent/hy3", - name: "Hy3", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text"], - cost: { - input: 0.14, - output: 0.58, - cacheRead: 0.035, + input: 0, + output: 0, + cacheRead: 0, cacheWrite: 0, }, - contextWindow: 262144, - maxTokens: 262144, + contextWindow: 256000, + maxTokens: 32768, } satisfies Model<"anthropic-messages">, - "thinkingmachines/inkling": { - id: "thinkingmachines/inkling", - name: "Inkling", + "sakana/fugu-ultra": { + id: "sakana/fugu-ultra", + name: "Fugu Ultra", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, input: ["text", "image"], cost: { - input: 1, - output: 4.05, - cacheRead: 0.16999999999999998, + input: 5, + output: 30, + cacheRead: 0.5, cacheWrite: 0, }, - contextWindow: 256000, - maxTokens: 256000, + contextWindow: 1000000, + maxTokens: 1000000, } satisfies Model<"anthropic-messages">, - "thinkingmachines/inkling-small": { - id: "thinkingmachines/inkling-small", - name: "Inkling Small", + "sakana/namazu": { + id: "sakana/namazu", + name: "Sakana Namazu", api: "anthropic-messages", provider: "vercel-ai-gateway", baseUrl: "https://ai-gateway.vercel.sh", reasoning: true, input: ["text", "image"], cost: { - input: 0.5, - output: 1.2, - cacheRead: 0.09999999999999999, + input: 0.95, + output: 4, + cacheRead: 0.15, cacheWrite: 0, }, - contextWindow: 1000000, - maxTokens: 1000000, + contextWindow: 256000, + maxTokens: 256000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.1-fast-non-reasoning": { - id: "xai/grok-4.1-fast-non-reasoning", + "spacexai/grok-4.1-fast-non-reasoning": { + id: "spacexai/grok-4.1-fast-non-reasoning", name: "Grok 4.1 Fast Non-Reasoning", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19676,8 +20856,8 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 1000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.1-fast-reasoning": { - id: "xai/grok-4.1-fast-reasoning", + "spacexai/grok-4.1-fast-reasoning": { + id: "spacexai/grok-4.1-fast-reasoning", name: "Grok 4.1 Fast Reasoning", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19693,8 +20873,8 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 1000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.20-multi-agent": { - id: "xai/grok-4.20-multi-agent", + "spacexai/grok-4.20-multi-agent": { + id: "spacexai/grok-4.20-multi-agent", name: "Grok 4.20 Multi-Agent", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19710,8 +20890,8 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 2000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.20-multi-agent-beta": { - id: "xai/grok-4.20-multi-agent-beta", + "spacexai/grok-4.20-multi-agent-beta": { + id: "spacexai/grok-4.20-multi-agent-beta", name: "Grok 4.20 Multi Agent Beta", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19727,8 +20907,8 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 2000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.20-non-reasoning": { - id: "xai/grok-4.20-non-reasoning", + "spacexai/grok-4.20-non-reasoning": { + id: "spacexai/grok-4.20-non-reasoning", name: "Grok 4.20 Non-Reasoning", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19744,8 +20924,8 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 2000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.20-non-reasoning-beta": { - id: "xai/grok-4.20-non-reasoning-beta", + "spacexai/grok-4.20-non-reasoning-beta": { + id: "spacexai/grok-4.20-non-reasoning-beta", name: "Grok 4.20 Beta Non-Reasoning", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19761,8 +20941,8 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 2000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.20-reasoning": { - id: "xai/grok-4.20-reasoning", + "spacexai/grok-4.20-reasoning": { + id: "spacexai/grok-4.20-reasoning", name: "Grok 4.20 Reasoning", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19778,8 +20958,8 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 2000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.20-reasoning-beta": { - id: "xai/grok-4.20-reasoning-beta", + "spacexai/grok-4.20-reasoning-beta": { + id: "spacexai/grok-4.20-reasoning-beta", name: "Grok 4.20 Beta Reasoning", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19795,8 +20975,8 @@ export const MODELS = { contextWindow: 2000000, maxTokens: 2000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.3": { - id: "xai/grok-4.3", + "spacexai/grok-4.3": { + id: "spacexai/grok-4.3", name: "Grok 4.3", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19812,8 +20992,8 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 1000000, } satisfies Model<"anthropic-messages">, - "xai/grok-4.5": { - id: "xai/grok-4.5", + "spacexai/grok-4.5": { + id: "spacexai/grok-4.5", name: "Grok 4.5", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19829,8 +21009,25 @@ export const MODELS = { contextWindow: 500000, maxTokens: 500000, } satisfies Model<"anthropic-messages">, - "xai/grok-build-0.1": { - id: "xai/grok-build-0.1", + "spacexai/grok-4.6": { + id: "spacexai/grok-4.6", + name: "Grok 4.6", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 500000, + } satisfies Model<"anthropic-messages">, + "spacexai/grok-build-0.1": { + id: "spacexai/grok-build-0.1", name: "Grok Build 0.1", api: "anthropic-messages", provider: "vercel-ai-gateway", @@ -19846,6 +21043,91 @@ export const MODELS = { contextWindow: 256000, maxTokens: 256000, } satisfies Model<"anthropic-messages">, + "stepfun/step-3.5-flash": { + id: "stepfun/step-3.5-flash", + name: "StepFun 3.5 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.09, + output: 0.3, + cacheRead: 0.02, + cacheWrite: 0, + }, + contextWindow: 262114, + maxTokens: 262114, + } satisfies Model<"anthropic-messages">, + "stepfun/step-3.7-flash": { + id: "stepfun/step-3.7-flash", + name: "Step 3.7 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.19999999999999998, + output: 1.15, + cacheRead: 0.04, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "tencent/hy3": { + id: "tencent/hy3", + name: "Hy3", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 0.13199999999999998, + output: 0.5279999999999999, + cacheRead: 0.032999999999999995, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 128000, + } satisfies Model<"anthropic-messages">, + "thinkingmachines/inkling": { + id: "thinkingmachines/inkling", + name: "Inkling", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 1, + output: 4.05, + cacheRead: 0.16999999999999998, + cacheWrite: 0, + }, + contextWindow: 256000, + maxTokens: 256000, + } satisfies Model<"anthropic-messages">, + "thinkingmachines/inkling-small": { + id: "thinkingmachines/inkling-small", + name: "Inkling Small", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.5, + output: 1.2, + cacheRead: 0.09999999999999999, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 1000000, + } satisfies Model<"anthropic-messages">, "xiaomi/mimo-v2.5": { id: "xiaomi/mimo-v2.5", name: "MiMo M2.5", @@ -19948,40 +21230,6 @@ export const MODELS = { contextWindow: 200000, maxTokens: 96000, } satisfies Model<"anthropic-messages">, - "zai/glm-4.6v": { - id: "zai/glm-4.6v", - name: "GLM-4.6V", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0.3, - output: 0.8999999999999999, - cacheRead: 0.049999999999999996, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 24000, - } satisfies Model<"anthropic-messages">, - "zai/glm-4.6v-flash": { - id: "zai/glm-4.6v-flash", - name: "GLM-4.6V-Flash", - api: "anthropic-messages", - provider: "vercel-ai-gateway", - baseUrl: "https://ai-gateway.vercel.sh", - reasoning: true, - input: ["text", "image"], - cost: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - }, - contextWindow: 128000, - maxTokens: 24000, - } satisfies Model<"anthropic-messages">, "zai/glm-4.7": { id: "zai/glm-4.7", name: "GLM 4.7", @@ -20081,8 +21329,8 @@ export const MODELS = { cacheRead: 0.26, cacheWrite: 0, }, - contextWindow: 202000, - maxTokens: 202000, + contextWindow: 202800, + maxTokens: 64000, } satisfies Model<"anthropic-messages">, "zai/glm-5.2": { id: "zai/glm-5.2", @@ -20093,9 +21341,9 @@ export const MODELS = { reasoning: true, input: ["text"], cost: { - input: 1.1, - output: 3.851, - cacheRead: 0.275, + input: 0.7999999999999999, + output: 2.5500000000000003, + cacheRead: 0.16, cacheWrite: 0, }, contextWindow: 1000000, @@ -20118,6 +21366,40 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 128000, } satisfies Model<"anthropic-messages">, + "zai/glm-5.3": { + id: "zai/glm-5.3", + name: "GLM 5.3", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text"], + cost: { + input: 1.4, + output: 4.4, + cacheRead: 0.26, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 12800, + } satisfies Model<"anthropic-messages">, + "zai/glm-5.3-flash": { + id: "zai/glm-5.3-flash", + name: "GLM 5.3 Flash", + api: "anthropic-messages", + provider: "vercel-ai-gateway", + baseUrl: "https://ai-gateway.vercel.sh", + reasoning: true, + input: ["text", "image"], + cost: { + input: 0.15, + output: 0.5, + cacheRead: 0.03, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131000, + } satisfies Model<"anthropic-messages">, "zai/glm-5v-turbo": { id: "zai/glm-5v-turbo", name: "GLM 5V Turbo", @@ -20205,6 +21487,23 @@ export const MODELS = { contextWindow: 500000, maxTokens: 500000, } satisfies Model<"openai-completions">, + "grok-4.6": { + id: "grok-4.6", + name: "Grok 4.6", + api: "openai-completions", + provider: "xai", + baseUrl: "https://api.x.ai/v1", + reasoning: true, + input: ["text", "image"], + cost: { + input: 2, + output: 6, + cacheRead: 0.5, + cacheWrite: 0, + }, + contextWindow: 500000, + maxTokens: 500000, + } satisfies Model<"openai-completions">, "grok-build-0.1": { id: "grok-build-0.1", name: "Grok Build 0.1", @@ -20729,5 +22028,59 @@ export const MODELS = { contextWindow: 1000000, maxTokens: 131072, } satisfies Model<"openai-completions">, + "glm-5.3": { + id: "glm-5.3", + name: "GLM-5.3", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.3-flash": { + id: "glm-5.3-flash", + name: "GLM-5.3-Flash", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text", "image"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, + "glm-5.3-highspeed": { + id: "glm-5.3-highspeed", + name: "GLM-5.3 Highspeed", + api: "openai-completions", + provider: "zai", + baseUrl: "https://api.z.ai/api/coding/paas/v4", + compat: {"supportsDeveloperRole":false,"thinkingFormat":"zai","zaiToolStream":true}, + reasoning: true, + input: ["text"], + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }, + contextWindow: 1000000, + maxTokens: 131072, + } satisfies Model<"openai-completions">, }, } as const; diff --git a/packages/ai/src/models.ts b/packages/ai/src/models.ts index fc7ba59b49..892fd608fb 100644 --- a/packages/ai/src/models.ts +++ b/packages/ai/src/models.ts @@ -3,7 +3,6 @@ import type { Api, KnownProvider, Model, ModelThinkingLevel, Usage } from "./typ const modelRegistry: Map>> = new Map(); -// Initialize registry from MODELS on module load for (const [provider, models] of Object.entries(MODELS)) { const providerModels = new Map>(); for (const [id, model] of Object.entries(models)) { @@ -37,10 +36,12 @@ export function getModels( } export function supportsFastMode(model: Model): boolean { + const eligibleId = + model.id === "gpt-5.4" || model.id === "gpt-5.5" || model.id === "gpt-5.6" || model.id.startsWith("gpt-5.6-"); return ( - model.provider === "openai-codex" && - model.api === "openai-codex-responses" && - (model.id === "gpt-5.4" || model.id === "gpt-5.5" || model.id === "gpt-5.6" || model.id.startsWith("gpt-5.6-")) + eligibleId && + ((model.provider === "openai-codex" && model.api === "openai-codex-responses") || + (model.provider === "openai" && model.api === "openai-responses")) ); } @@ -95,10 +96,6 @@ export function clampThinkingLevel( return availableLevels[0] ?? "off"; } -/** - * Check if two models are equal by comparing both their id and provider. - * Returns false if either model is null or undefined. - */ export function modelsAreEqual( a: Model | null | undefined, b: Model | null | undefined, diff --git a/packages/ai/src/openrouter-reasoning.ts b/packages/ai/src/openrouter-reasoning.ts index b0e93fc507..a09c4595ea 100644 --- a/packages/ai/src/openrouter-reasoning.ts +++ b/packages/ai/src/openrouter-reasoning.ts @@ -33,14 +33,6 @@ function enabledOnlyCapabilities(mandatory: boolean): OpenRouterReasoningCapabil }; } -/** - * Normalize the reasoning capability metadata published by OpenRouter. - * - * The top-level reasoning object is only trusted after supported_parameters - * confirms that the route accepts reasoning controls. An omitted - * supported_efforts field means the route exposes an enabled toggle but no - * effort selector; null means every gateway effort is accepted. - */ export function getOpenRouterReasoningCapabilities(model: unknown): OpenRouterReasoningCapabilities | undefined { if (!isRecord(model)) return undefined; const supportedParameters = Array.isArray(model.supported_parameters) ? model.supported_parameters : []; diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index 3de36f60eb..0a540c337e 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -125,18 +125,14 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt hasConfiguredProfile, ); - // Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured. - // This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in - // catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE. + // Preserve custom endpoints and AWS region/profile configuration. if (useExplicitEndpoint) { config.endpoint = model.baseUrl; } - // Resolve bearer token for Bedrock API key auth. const bearerToken = options.bearerToken || process.env.AWS_BEARER_TOKEN_BEDROCK || undefined; const useBearerToken = bearerToken !== undefined && process.env.AWS_BEDROCK_SKIP_AUTH !== "1"; - // in Node.js/Bun environment only if (typeof process !== "undefined" && (process.versions?.node || process.versions?.bun)) { // Region resolution: explicit option > env vars > SDK default chain. // When AWS_PROFILE is set, we leave region undefined so the SDK can @@ -149,7 +145,6 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt config.region = "us-east-1"; } - // Support proxies that don't need authentication if (process.env.AWS_BEDROCK_SKIP_AUTH === "1") { config.credentials = { accessKeyId: "dummy-access-key", @@ -178,7 +173,6 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt httpsAgent: agent, }); } else if (process.env.AWS_BEDROCK_FORCE_HTTP1 === "1") { - // Some custom endpoints require HTTP/1.1 instead of HTTP/2 const nodeHttpHandler = await import("@smithy/node-http-handler"); config.requestHandler = new nodeHttpHandler.NodeHttpHandler(); } @@ -601,11 +595,8 @@ function supportsPromptCaching(model: Model<"bedrock-converse-stream">): boolean if (typeof process !== "undefined" && process.env.AWS_BEDROCK_FORCE_CACHE === "1") return true; return false; } - // Claude 4.x models (opus-4, sonnet-4, haiku-4) if (candidates.some((s) => s.includes("-4-"))) return true; - // Claude 3.7 Sonnet if (candidates.some((s) => s.includes("claude-3-7-sonnet"))) return true; - // Claude 3.5 Haiku if (candidates.some((s) => s.includes("claude-3-5-haiku"))) return true; return false; } @@ -631,7 +622,6 @@ function buildSystemPrompt( const blocks: SystemContentBlock[] = [{ text: sanitizeSurrogates(systemPrompt) }]; - // Add cache point for supported Claude models when caching is enabled if (cacheRetention !== "none" && supportsPromptCaching(model)) { blocks.push({ cachePoint: { type: CachePointType.DEFAULT, ...(cacheRetention === "long" ? { ttl: CacheTTL.ONE_HOUR } : {}) }, @@ -686,7 +676,6 @@ function convertMessages( for (const c of m.content) { switch (c.type) { case "text": - // Skip empty text blocks if (c.text.trim().length === 0) continue; contentBlocks.push({ text: sanitizeSurrogates(c.text) }); break; @@ -696,7 +685,6 @@ function convertMessages( }); break; case "thinking": - // Skip empty thinking blocks if (c.thinking.trim().length === 0) continue; // Only Anthropic models support the signature field in reasoningText. // For other models, we omit the signature to avoid errors like: @@ -729,7 +717,6 @@ function convertMessages( throw new Error("Unknown assistant content type"); } } - // Skip if all content blocks were filtered out if (contentBlocks.length === 0) { continue; } @@ -744,7 +731,6 @@ function convertMessages( // Bedrock requires all tool results to be in one message const toolResults: ContentBlock.ToolResultMember[] = []; - // Add current tool result with all content blocks combined toolResults.push({ toolResult: { toolUseId: m.toolCallId, @@ -757,7 +743,6 @@ function convertMessages( }, }); - // Look ahead for consecutive toolResult messages let j = i + 1; while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") { const nextMsg = transformedMessages[j] as ToolResultMessage; @@ -775,7 +760,6 @@ function convertMessages( j++; } - // Skip the messages we've already processed i = j - 1; result.push({ @@ -931,7 +915,6 @@ function buildAdditionalModelRequestFields( max: 16384, // Budget-based Claude has no max tier, clamp to high }; - // Custom budgets override defaults (xhigh/max not in ThinkingBudgets, use high) const level = options.reasoning === "xhigh" || options.reasoning === "max" ? "high" : options.reasoning; const budget = options.thinkingBudgets?.[level] ?? defaultBudgets[options.reasoning]; diff --git a/packages/ai/src/providers/anthropic.ts b/packages/ai/src/providers/anthropic.ts index 0f3232dc0f..a3fcb41521 100644 --- a/packages/ai/src/providers/anthropic.ts +++ b/packages/ai/src/providers/anthropic.ts @@ -77,7 +77,7 @@ function getCacheControl( } // Stealth mode: Mimic Claude Code's tool naming exactly -const claudeCodeVersion = "2.1.75"; +const claudeCodeVersion = "2.1.257"; // Claude Code 2.x tool names (canonical casing) // Source: https://cchistory.mariozechner.at/data/prompts-2.1.11.md @@ -104,7 +104,6 @@ const claudeCodeTools = [ const ccToolLookup = new Map(claudeCodeTools.map((t) => [t.toLowerCase(), t])); -// Convert tool name to CC canonical casing if it matches (case-insensitive) const toClaudeCodeName = (name: string) => ccToolLookup.get(name.toLowerCase()) ?? name; const fromClaudeCodeName = (name: string, tools?: Tool[]) => { if (tools && tools.length > 0) { @@ -131,13 +130,11 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]): }; } > { - // If only text blocks, return as concatenated string for simplicity const hasImages = content.some((c) => c.type === "image"); if (!hasImages) { return sanitizeSurrogates(content.map((c) => (c as TextContent).text).join("\n")); } - // If we have images, convert to content block array const blocks = content.map((block) => { if (block.type === "text") { return { @@ -155,7 +152,6 @@ function convertContentBlocks(content: (TextContent | ImageContent)[]): }; }); - // If only images (no text), add placeholder text block const hasText = blocks.some((b) => b.type === "text"); if (!hasText) { blocks.unshift({ @@ -544,7 +540,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti output.usage.output = event.message.usage.output_tokens || 0; output.usage.cacheRead = event.message.usage.cache_read_input_tokens || 0; output.usage.cacheWrite = event.message.usage.cache_creation_input_tokens || 0; - // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; if (cacheControl && usesAnthropicCachePricing) { @@ -700,7 +695,6 @@ export const streamAnthropic: StreamFunction<"anthropic-messages", AnthropicOpti if (event.usage.cache_creation_input_tokens != null) { output.usage.cacheWrite = event.usage.cache_creation_input_tokens; } - // Anthropic doesn't provide total_tokens, compute from components output.usage.totalTokens = output.usage.input + output.usage.output + output.usage.cacheRead + output.usage.cacheWrite; calculateCost( @@ -888,7 +882,6 @@ function createClient( return { client, isOAuthToken: false }; } - // Copilot: Bearer auth, selective betas. if (model.provider === "github-copilot") { const client = new Anthropic({ apiKey: null, @@ -910,7 +903,6 @@ function createClient( return { client, isOAuthToken: false }; } - // OAuth: Bearer auth, Claude Code identity headers if (isOAuthToken(apiKey)) { const client = new Anthropic({ apiKey: null, @@ -933,7 +925,6 @@ function createClient( return { client, isOAuthToken: true }; } - // API key auth const client = new Anthropic({ apiKey, baseURL: model.baseUrl, @@ -1028,7 +1019,6 @@ function buildParams( : { effort: options.effort }; } } else { - // Budget-based thinking for older models params.thinking = { type: "enabled", budget_tokens: options.thinkingBudgetTokens || 1024, @@ -1071,7 +1061,6 @@ function convertMessages( ): MessageParam[] { const params: MessageParam[] = []; - // Transform messages for cross-provider compatibility const transformedMessages = transformMessages(messages, model, normalizeToolCallId); for (let i = 0; i < transformedMessages.length; i++) { @@ -1168,7 +1157,6 @@ function convertMessages( // Collect all consecutive toolResult messages, needed for z.ai Anthropic endpoint const toolResults: ContentBlockParam[] = []; - // Add the current tool result toolResults.push({ type: "tool_result", tool_use_id: msg.toolCallId, @@ -1176,7 +1164,6 @@ function convertMessages( is_error: msg.isError, }); - // Look ahead for consecutive toolResult messages let j = i + 1; while (j < transformedMessages.length && transformedMessages[j].role === "toolResult") { const nextMsg = transformedMessages[j] as ToolResultMessage; // We know it's a toolResult @@ -1189,10 +1176,8 @@ function convertMessages( j++; } - // Skip the messages we've already processed i = j - 1; - // Add a single user message with all tool results params.push({ role: "user", content: toolResults, diff --git a/packages/ai/src/providers/azure-openai-responses.ts b/packages/ai/src/providers/azure-openai-responses.ts index 6265a72301..6e869afc44 100644 --- a/packages/ai/src/providers/azure-openai-responses.ts +++ b/packages/ai/src/providers/azure-openai-responses.ts @@ -45,7 +45,6 @@ function resolveDeploymentName(model: Model<"azure-openai-responses">, options?: return mappedDeployment || model.id; } -// Azure OpenAI Responses-specific options export interface AzureOpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "detailed" | "concise" | null; @@ -55,9 +54,6 @@ export interface AzureOpenAIResponsesOptions extends StreamOptions { azureDeploymentName?: string; } -/** - * Generate function for Azure OpenAI Responses API - */ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses", AzureOpenAIResponsesOptions> = ( model: Model<"azure-openai-responses">, context: Context, @@ -65,7 +61,6 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - // Start async processing (async () => { const deploymentName = resolveDeploymentName(model, options); @@ -88,7 +83,6 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses" }; try { - // Create Azure OpenAI client const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; const client = createClient(model, apiKey, options); let params = buildParams(model, context, options, deploymentName); diff --git a/packages/ai/src/providers/cloudflare.ts b/packages/ai/src/providers/cloudflare.ts index 8e9d44cbae..01afdba2cb 100644 --- a/packages/ai/src/providers/cloudflare.ts +++ b/packages/ai/src/providers/cloudflare.ts @@ -1,6 +1,5 @@ import type { Api, Model } from "../types.js"; -/** Workers AI direct endpoint. */ export const CLOUDFLARE_WORKERS_AI_BASE_URL = "https://api.cloudflare.com/client/v4/accounts/{CLOUDFLARE_ACCOUNT_ID}/ai/v1"; @@ -8,11 +7,9 @@ export const CLOUDFLARE_WORKERS_AI_BASE_URL = export const CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL = "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/compat"; -/** AI Gateway → OpenAI passthrough. Used until /compat supports /v1/responses. */ export const CLOUDFLARE_AI_GATEWAY_OPENAI_BASE_URL = "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/openai"; -/** AI Gateway → Anthropic passthrough. */ export const CLOUDFLARE_AI_GATEWAY_ANTHROPIC_BASE_URL = "https://gateway.ai.cloudflare.com/v1/{CLOUDFLARE_ACCOUNT_ID}/{CLOUDFLARE_GATEWAY_ID}/anthropic"; diff --git a/packages/ai/src/providers/github-copilot-headers.ts b/packages/ai/src/providers/github-copilot-headers.ts index 4f01a9d2ac..812b8fcd03 100644 --- a/packages/ai/src/providers/github-copilot-headers.ts +++ b/packages/ai/src/providers/github-copilot-headers.ts @@ -1,13 +1,10 @@ import type { Message } from "../types.js"; -// Copilot expects X-Initiator to indicate whether the request is user-initiated -// or agent-initiated (e.g. follow-up after assistant/tool messages). export function inferCopilotInitiator(messages: Message[]): "user" | "agent" { const last = messages[messages.length - 1]; return last && last.role !== "user" ? "agent" : "user"; } -// Copilot requires Copilot-Vision-Request header when sending images export function hasCopilotVisionInput(messages: Message[]): boolean { return messages.some((msg) => { if (msg.role === "user" && Array.isArray(msg.content)) { diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index e9d05e37a7..aa1e4e41e1 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -1,6 +1,4 @@ -/** - * Shared utilities for Google Generative AI and Google Vertex providers. - */ +/** Shared utilities for Google Generative AI and Vertex providers. */ import { type Content, FinishReason, FunctionCallingConfigMode, type Part } from "@google/genai"; import type { Context, ImageContent, Model, StopReason, TextContent, ThinkingBudgets, Tool } from "../types.js"; @@ -9,10 +7,7 @@ import { transformMessages } from "./transform-messages.js"; type GoogleApiType = "google-generative-ai" | "google-vertex"; -/** - * Thinking level for Gemini 3 models. - * Mirrors Google's ThinkingLevel enum values. - */ +/** Thinking level values accepted by Gemini 3 models. */ export type GoogleThinkingLevel = "THINKING_LEVEL_UNSPECIFIED" | "MINIMAL" | "LOW" | "MEDIUM" | "HIGH"; type GoogleBudgetThinkingLevel = "minimal" | "low" | "medium" | "high"; @@ -83,16 +78,12 @@ function isValidThoughtSignature(signature: string | undefined): boolean { return base64SignaturePattern.test(signature); } -/** - * Only keep signatures from the same provider/model and with valid base64. - */ +/** Retains a thought signature only for the originating provider/model and when it is valid base64. */ function resolveThoughtSignature(isSameProviderAndModel: boolean, signature: string | undefined): string | undefined { return isSameProviderAndModel && isValidThoughtSignature(signature) ? signature : undefined; } -/** - * Models via Google APIs that require explicit tool call IDs in function calls/responses. - */ +/** Whether this Google API model requires tool-call IDs on function calls and responses. */ export function requiresToolCallId(modelId: string): boolean { return modelId.startsWith("claude-") || modelId.startsWith("gpt-oss-"); } @@ -111,9 +102,7 @@ function supportsMultimodalFunctionResponse(modelId: string): boolean { return true; } -/** - * Convert internal messages to Gemini Content[] format. - */ +/** Converts internal context to Google `Content[]`, preserving replayable signatures only when protocol-valid. */ export function convertMessages(model: Model, context: Context): Content[] { const contents: Content[] = []; const normalizeToolCallId = (id: string): string => { @@ -151,12 +140,10 @@ export function convertMessages(model: Model, contex } } else if (msg.role === "assistant") { const parts: Part[] = []; - // Check if message is from same provider and model - only then keep thinking blocks const isSameProviderAndModel = msg.provider === model.provider && msg.model === model.id; for (const block of msg.content) { if (block.type === "text") { - // Skip empty text blocks if (!block.text || block.text.trim() === "") continue; const thoughtSignature = resolveThoughtSignature(isSameProviderAndModel, block.textSignature); parts.push({ @@ -164,7 +151,6 @@ export function convertMessages(model: Model, contex ...(thoughtSignature && { thoughtSignature }), }); } else if (block.type === "thinking") { - // Skip empty thinking blocks if (!block.thinking || block.thinking.trim() === "") continue; // Only keep as thinking block if same provider AND same model // Otherwise convert to plain text (no tags to avoid model mimicking them) @@ -200,7 +186,6 @@ export function convertMessages(model: Model, contex parts, }); } else if (msg.role === "toolResult") { - // Extract text and image content const textContent = msg.content.filter((c): c is TextContent => c.type === "text"); const textResult = textContent.map((c) => c.text).join("\n"); const imageContent = model.input.includes("image") @@ -271,9 +256,6 @@ const JSON_SCHEMA_META_DECLARATIONS = new Set([ "definitions", // pre-draft-2019-09 equivalent of $defs ]); -/** - * Strip meta-declarations from a schema obj - */ function sanitizeForOpenApi(schema: unknown): unknown { if (typeof schema !== "object" || schema === null || Array.isArray(schema)) { return schema; @@ -313,9 +295,7 @@ export function convertTools( ]; } -/** - * Map tool choice string to Gemini FunctionCallingConfigMode. - */ +/** Converts the generic tool-choice mode to Google's function-calling mode. */ export function mapToolChoice(choice: string): FunctionCallingConfigMode { switch (choice) { case "auto": @@ -329,9 +309,7 @@ export function mapToolChoice(choice: string): FunctionCallingConfigMode { } } -/** - * Map Gemini FinishReason to our StopReason. - */ +/** Converts Google finish reasons to the shared stop-reason protocol. */ export function mapStopReason(reason: FinishReason): StopReason { switch (reason) { case FinishReason.STOP: @@ -360,17 +338,3 @@ export function mapStopReason(reason: FinishReason): StopReason { } } } - -/** - * Map string finish reason to our StopReason (for raw API responses). - */ -export function mapStopReasonString(reason: string): StopReason { - switch (reason) { - case "STOP": - return "stop"; - case "MAX_TOKENS": - return "length"; - default: - return "error"; - } -} diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index d48a8f4580..b6291d68b3 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -62,7 +62,6 @@ const THINKING_LEVEL_MAP: Record = { HIGH: ThinkingLevel.HIGH, }; -// Counter for generating unique tool call IDs let toolCallCounter = 0; export const streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOptions> = ( @@ -93,7 +92,6 @@ export const streamGoogleVertex: StreamFunction<"google-vertex", GoogleVertexOpt try { const apiKey = resolveApiKey(options); - // Create the client using either a Vertex API key, if provided, or ADC with project and location const client = apiKey ? createClientWithApiKey(model, apiKey, options?.headers) : createClient(model, resolveProject(options), resolveLocation(options), options?.headers); diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index f43c54f23b..4a9c0a73c9 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -47,7 +47,6 @@ export interface GoogleOptions extends StreamOptions { }; } -// Counter for generating unique tool call IDs let toolCallCounter = 0; export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions> = ( @@ -178,7 +177,6 @@ export const streamGoogle: StreamFunction<"google-generative-ai", GoogleOptions> currentBlock = null; } - // Generate unique ID if not provided or if it's a duplicate const providedId = part.functionCall.id; const needsNewId = !providedId || output.content.some((b) => b.type === "toolCall" && b.id === providedId); diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index f153e36403..fad46e2544 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -33,20 +33,17 @@ import { transformMessages } from "./transform-messages.js"; const MISTRAL_TOOL_CALL_ID_LENGTH = 9; const MAX_MISTRAL_ERROR_BODY_CHARS = 4000; -/** - * Provider-specific options for the Mistral API. - */ +/** Mistral reasoning-effort values. */ type MistralReasoningEffort = "none" | "high"; +/** Provider-specific request options for the Mistral API. */ export interface MistralOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any" | "required" | { type: "function"; function: { name: string } }; promptMode?: "reasoning"; reasoningEffort?: MistralReasoningEffort; } -/** - * Stream responses from Mistral using `chat.stream`. - */ +/** Streams Mistral chat completions through `chat.stream`. */ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptions> = ( model: Model<"mistral-conversations">, context: Context, @@ -107,9 +104,7 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio return stream; }; -/** - * Maps provider-agnostic `SimpleStreamOptions` to Mistral options. - */ +/** Maps provider-agnostic `SimpleStreamOptions` to Mistral request options. */ export const streamSimpleMistral: StreamFunction<"mistral-conversations", SimpleStreamOptions> = ( model: Model<"mistral-conversations">, context: Context, diff --git a/packages/ai/src/providers/openai-codex-responses.ts b/packages/ai/src/providers/openai-codex-responses.ts index 43c95d2690..cf9f113a86 100644 --- a/packages/ai/src/providers/openai-codex-responses.ts +++ b/packages/ai/src/providers/openai-codex-responses.ts @@ -43,10 +43,6 @@ import { headersToRecord } from "../utils/headers.js"; import { convertResponsesMessages, convertResponsesTools, processResponsesStream } from "./openai-responses-shared.js"; import { buildBaseOptions } from "./simple-options.js"; -// ============================================================================ -// Configuration -// ============================================================================ - const DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"; const JWT_CLAIM_PATH = "https://api.openai.com/auth" as const; const MAX_RETRIES = 3; @@ -63,10 +59,6 @@ const CODEX_RESPONSE_STATUSES = new Set([ "in_progress", ]); -// ============================================================================ -// Types -// ============================================================================ - export interface OpenAICodexResponsesOptions extends StreamOptions { reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null; @@ -95,10 +87,6 @@ interface RequestBody { [key: string]: unknown; } -// ============================================================================ -// Retry Helpers -// ============================================================================ - function isRetryableError(status: number, errorText: string): boolean { if (status === 429 || status === 500 || status === 502 || status === 503 || status === 504) { return true; @@ -120,10 +108,6 @@ function sleep(ms: number, signal?: AbortSignal): Promise { }); } -// ============================================================================ -// Main Stream Function -// ============================================================================ - export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses", OpenAICodexResponsesOptions> = ( model: Model<"openai-codex-responses">, context: Context, @@ -227,7 +211,6 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } } - // Fetch with retry logic for rate limits and transient errors let response: Response | undefined; let lastError: Error | undefined; @@ -259,7 +242,6 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" continue; } - // Parse error for friendly message on final attempt or non-retryable error const fakeResponse = new Response(errorText, { status: response.status, statusText: response.statusText, @@ -273,7 +255,6 @@ export const streamOpenAICodexResponses: StreamFunction<"openai-codex-responses" } } lastError = error instanceof Error ? error : new Error(String(error)); - // Network errors are retryable if (attempt < MAX_RETRIES && !lastError.message.includes("usage limit")) { const delayMs = BASE_DELAY_MS * 2 ** attempt; await sleep(delayMs, options?.signal); @@ -335,10 +316,6 @@ export const streamSimpleOpenAICodexResponses: StreamFunction<"openai-codex-resp } satisfies OpenAICodexResponsesOptions); }; -// ============================================================================ -// Request Building -// ============================================================================ - function buildRequestBody( model: Model<"openai-codex-responses">, context: Context, @@ -389,6 +366,7 @@ function buildRequestBody( return body; } +// Multipliers per https://developers.openai.com/api/docs/pricing (retrieved 2026-08-21) function getServiceTierCostMultiplier( model: Pick, "id">, serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, @@ -397,7 +375,7 @@ function getServiceTierCostMultiplier( case "flex": return 0.5; case "priority": - return model.id.startsWith("gpt-5.5") || model.id.startsWith("gpt-5.6") ? 2.5 : 2; + return model.id.startsWith("gpt-5.5") ? 2.5 : 2; default: return 1; } @@ -443,10 +421,6 @@ function resolveCodexWebSocketUrl(baseUrl?: string): string { return url.toString(); } -// ============================================================================ -// Response Processing -// ============================================================================ - async function processStream( response: Response, output: AssistantMessage, @@ -528,10 +502,6 @@ function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined return CODEX_RESPONSE_STATUSES.has(status as CodexResponseStatus) ? (status as CodexResponseStatus) : undefined; } -// ============================================================================ -// SSE Parsing -// ============================================================================ - async function* parseSSE(response: Response): AsyncGenerator> { if (!response.body) return; @@ -571,24 +541,19 @@ async function* parseSSE(response: Response): AsyncGenerator { const raw = await response.text(); let message = raw || response.statusText || "Request failed"; @@ -1251,10 +1212,6 @@ async function parseErrorResponse(response: Response): Promise<{ message: string return { message, friendlyMessage }; } -// ============================================================================ -// Auth & Headers -// ============================================================================ - function extractAccountId(token: string): string { try { const parts = token.split("."); diff --git a/packages/ai/src/providers/openai-completions.ts b/packages/ai/src/providers/openai-completions.ts index ecd16b4f7a..e39d959178 100644 --- a/packages/ai/src/providers/openai-completions.ts +++ b/packages/ai/src/providers/openai-completions.ts @@ -75,6 +75,31 @@ function isImageContentBlock(block: { type: string }): block is ImageContent { return block.type === "image"; } +const REASONING_DETAILS_SIGNATURE_TYPE = "openai-completions.reasoning_details.v1"; + +interface ReasoningDetailsSignature { + type: typeof REASONING_DETAILS_SIGNATURE_TYPE; + details: Record[]; +} + +function encodeReasoningDetails(details: Record[]): string { + return JSON.stringify({ type: REASONING_DETAILS_SIGNATURE_TYPE, details } satisfies ReasoningDetailsSignature); +} + +function decodeReasoningDetails(signature?: string): Record[] | undefined { + if (!signature?.startsWith("{")) return undefined; + try { + const parsed = JSON.parse(signature) as Partial; + if (parsed.type !== REASONING_DETAILS_SIGNATURE_TYPE || !Array.isArray(parsed.details)) return undefined; + if (parsed.details.some((detail) => !detail || typeof detail !== "object" || Array.isArray(detail))) { + return undefined; + } + return parsed.details as Record[]; + } catch { + return undefined; + } +} + export interface OpenAICompletionsOptions extends StreamOptions { toolChoice?: "auto" | "none" | "required" | { type: "function"; function: { name: string } }; reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; @@ -175,6 +200,9 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA let thinkingBlock: ThinkingContent | null = null; const toolCallBlocksByIndex = new Map(); const toolCallBlocksById = new Map(); + const reasoningDetailsByIndex = new Map>(); + let nextReasoningDetailsIndex = 0; + let reasoningDetailsBlock: ThinkingContent | null = null; const blocks = output.content as StreamingBlock[]; const getContentIndex = (block: StreamingBlock) => blocks.indexOf(block); const finishBlock = (block: StreamingBlock) => { @@ -372,15 +400,50 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions", OpenA const reasoningDetails = (choice.delta as any).reasoning_details; if (reasoningDetails && Array.isArray(reasoningDetails)) { for (const detail of reasoningDetails) { - if (detail.type === "reasoning.encrypted" && detail.id && detail.data) { + if (!detail || typeof detail !== "object" || Array.isArray(detail)) continue; + const detailRecord = detail as Record; + const explicitIndex = typeof detailRecord.index === "number" ? detailRecord.index : undefined; + const index = explicitIndex ?? nextReasoningDetailsIndex; + nextReasoningDetailsIndex = Math.max(nextReasoningDetailsIndex, index + 1); + const previousDetail = reasoningDetailsByIndex.get(index); + const mergedDetail = { ...previousDetail, ...detailRecord }; + for (const field of ["text", "summary"] as const) { + const previousFragment = previousDetail?.[field]; + const fragment = detailRecord[field]; + if (typeof previousFragment === "string" && typeof fragment === "string") { + mergedDetail[field] = previousFragment + fragment; + } + } + reasoningDetailsByIndex.set(index, mergedDetail); + if ( + detailRecord.type === "reasoning.encrypted" && + typeof detailRecord.id === "string" && + detailRecord.data + ) { const matchingToolCall = output.content.find( - (b) => b.type === "toolCall" && b.id === detail.id, + (b) => b.type === "toolCall" && b.id === detailRecord.id, ) as ToolCall | undefined; if (matchingToolCall) { - matchingToolCall.thoughtSignature = JSON.stringify(detail); + matchingToolCall.thoughtSignature = JSON.stringify(detailRecord); } } } + if (reasoningDetailsByIndex.size > 0) { + if (!reasoningDetailsBlock) { + reasoningDetailsBlock = { type: "thinking", thinking: "", redacted: true }; + blocks.push(reasoningDetailsBlock); + stream.push({ + type: "thinking_start", + contentIndex: getContentIndex(reasoningDetailsBlock), + partial: output, + }); + } + reasoningDetailsBlock.thinkingSignature = encodeReasoningDetails( + [...reasoningDetailsByIndex.entries()] + .sort(([left], [right]) => left - right) + .map(([, detail]) => detail), + ); + } } } } @@ -484,7 +547,6 @@ function createClient( headers["x-session-affinity"] = sessionId; } - // Merge options headers last so they can override defaults if (optionsHeaders) { Object.assign(headers, optionsHeaders); } @@ -597,7 +659,6 @@ function buildParams( : { enabled: false }; } } else if (options?.reasoningEffort && model.reasoning && compat.supportsReasoningEffort) { - // OpenAI-style reasoning_effort (params as any).reasoning_effort = model.thinkingLevelMap?.[options.reasoningEffort] ?? options.reasoningEffort; } else if (options?.reasoningEnabled === false && model.reasoning && compat.supportsReasoningEffort) { const offValue = model.thinkingLevelMap?.off; @@ -606,12 +667,10 @@ function buildParams( } } - // OpenRouter provider routing preferences if (model.baseUrl.includes("openrouter.ai") && model.compat?.openRouterRouting) { (params as any).provider = model.compat.openRouterRouting; } - // Vercel AI Gateway provider routing preferences if (model.baseUrl.includes("ai-gateway.vercel.sh") && model.compat?.vercelGatewayRouting) { const routing = model.compat.vercelGatewayRouting; if (routing.only || routing.order) { @@ -665,7 +724,7 @@ function addCacheControlToLastConversationMessage( ): void { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; - if (message.role === "user" || message.role === "assistant") { + if (message.role === "user" || message.role === "assistant" || message.role === "tool") { if (addCacheControlToMessage(message, cacheControl)) { return; } @@ -696,7 +755,7 @@ function addCacheControlToMessage( message: ChatCompletionMessageParam, cacheControl: OpenAICompatCacheControl, ): boolean { - if (message.role === "user" || message.role === "assistant") { + if (message.role === "user" || message.role === "assistant" || message.role === "tool") { return addCacheControlToTextContent(message, cacheControl); } return false; @@ -706,6 +765,7 @@ function addCacheControlToTextContent( message: | ChatCompletionInstructionMessageParam | ChatCompletionAssistantMessageParam + | ChatCompletionToolMessageParam | Extract, cacheControl: OpenAICompatCacheControl, ): boolean { @@ -830,8 +890,16 @@ export function convertMessages( ); const assistantText = assistantTextParts.map((part) => part.text).join(""); + const replayReasoningDetails = msg.content + .filter(isThinkingContentBlock) + .flatMap((block) => decodeReasoningDetails(block.thinkingSignature) ?? []); + if (replayReasoningDetails.length > 0) { + (assistantMsg as any).reasoning_details = replayReasoningDetails; + } + const nonEmptyThinkingBlocks = msg.content .filter(isThinkingContentBlock) + .filter((block) => decodeReasoningDetails(block.thinkingSignature) === undefined) .filter((block) => block.thinking.trim().length > 0); if (nonEmptyThinkingBlocks.length > 0) { if (compat.requiresThinkingAsText) { @@ -898,7 +966,7 @@ export function convertMessages( } }) .filter(Boolean); - if (reasoningDetails.length > 0) { + if (reasoningDetails.length > 0 && replayReasoningDetails.length === 0) { (assistantMsg as any).reasoning_details = reasoningDetails; } } @@ -909,6 +977,9 @@ export function convertMessages( ) { (assistantMsg as { reasoning_content?: string }).reasoning_content = ""; } + if (replayReasoningDetails.length > 0 && assistantMsg.content === null && !assistantMsg.tool_calls) { + assistantMsg.content = ""; + } // Skip assistant messages that have no content and no tool calls. // Some providers require "either content or tool_calls, but not none". // Other providers also don't accept empty assistant messages. @@ -918,7 +989,7 @@ export function convertMessages( content !== null && content !== undefined && (typeof content === "string" ? content.length > 0 : content.length > 0); - if (!hasContent && !assistantMsg.tool_calls) { + if (!hasContent && !assistantMsg.tool_calls && replayReasoningDetails.length === 0) { continue; } params.push(assistantMsg); @@ -929,7 +1000,6 @@ export function convertMessages( for (; j < transformedMessages.length && transformedMessages[j].role === "toolResult"; j++) { const toolMsg = transformedMessages[j] as ToolResultMessage; - // Extract text and image content const textResult = toolMsg.content .filter(isTextContentBlock) .map((block) => block.text) @@ -938,7 +1008,6 @@ export function convertMessages( // Always send tool result with text (or placeholder if only images) const hasText = textResult.length > 0; - // Some providers require the 'name' field in tool results const toolResultMsg: ChatCompletionToolMessageParam = { role: "tool", content: sanitizeSurrogates(hasText ? textResult : hasImages ? "(see attached image)" : ""), diff --git a/packages/ai/src/providers/openai-responses-shared.ts b/packages/ai/src/providers/openai-responses-shared.ts index 8a22f03ff4..ae312d9457 100644 --- a/packages/ai/src/providers/openai-responses-shared.ts +++ b/packages/ai/src/providers/openai-responses-shared.ts @@ -34,10 +34,6 @@ import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import { classifyStreamFailure, StreamFailureError } from "../utils/stream-failure.js"; import { transformMessages } from "./transform-messages.js"; -// ============================================================================= -// Utilities -// ============================================================================= - function encodeTextSignatureV1(id: string, phase?: TextSignatureV1["phase"]): string { const payload: TextSignatureV1 = { v: 1, id }; if (phase) payload.phase = phase; @@ -84,10 +80,6 @@ export interface ConvertResponsesToolsOptions { strict?: boolean | null; } -// ============================================================================= -// Message conversion -// ============================================================================= - export function convertResponsesMessages( model: Model, context: Context, @@ -262,10 +254,6 @@ export function convertResponsesMessages( return messages; } -// ============================================================================= -// Tool conversion -// ============================================================================= - export function convertResponsesTools(tools: Tool[], options?: ConvertResponsesToolsOptions): OpenAITool[] { const strict = options?.strict === undefined ? false : options.strict; return tools.map((tool) => ({ @@ -277,10 +265,6 @@ export function convertResponsesTools(tools: Tool[], options?: ConvertResponsesT })); } -// ============================================================================= -// Stream processing -// ============================================================================= - export async function processResponsesStream( openaiStream: AsyncIterable, output: AssistantMessage, @@ -368,7 +352,6 @@ export async function processResponsesStream( } else if (event.type === "response.content_part.added") { if (currentItem?.type === "message") { currentItem.content = currentItem.content || []; - // Filter out ReasoningText, only accept output_text and refusal if (event.part.type === "output_text" || event.part.type === "refusal") { currentItem.content.push(event.part); } @@ -510,7 +493,6 @@ export async function processResponsesStream( : (response?.service_tier ?? options.serviceTier); options.applyServiceTierPricing(output.usage, serviceTier); } - // Map status to stop reason output.stopReason = mapStopReason(response?.status); if (output.content.some((b) => b.type === "toolCall") && output.stopReason === "stop") { output.stopReason = "toolUse"; @@ -550,7 +532,6 @@ function mapStopReason(status: OpenAI.Responses.ResponseStatus | undefined): Sto case "failed": case "cancelled": return "error"; - // These two are wonky ... case "in_progress": case "queued": return "stop"; diff --git a/packages/ai/src/providers/openai-responses.ts b/packages/ai/src/providers/openai-responses.ts index d152f7d3c2..0370d63fcc 100644 --- a/packages/ai/src/providers/openai-responses.ts +++ b/packages/ai/src/providers/openai-responses.ts @@ -56,16 +56,12 @@ function getPromptCacheRetention( return cacheRetention === "long" && compat.supportsLongCacheRetention ? "24h" : undefined; } -// OpenAI Responses-specific options export interface OpenAIResponsesOptions extends StreamOptions { reasoningEffort?: "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; reasoningSummary?: "auto" | "detailed" | "concise" | null; serviceTier?: ResponseCreateParamsStreaming["service_tier"]; } -/** - * Generate function for OpenAI Responses API - */ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIResponsesOptions> = ( model: Model<"openai-responses">, context: Context, @@ -73,7 +69,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes ): AssistantMessageEventStream => { const stream = new AssistantMessageEventStream(); - // Start async processing (async () => { const output: AssistantMessage = { role: "assistant", @@ -94,7 +89,6 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses", OpenAIRes }; try { - // Create OpenAI client const apiKey = options?.apiKey || getEnvApiKey(model.provider) || ""; const cacheRetention = resolveCacheRetention(options?.cacheRetention); const cacheSessionId = cacheRetention === "none" ? undefined : options?.sessionId; @@ -200,7 +194,6 @@ function createClient( headers["x-client-request-id"] = sessionId; } - // Merge options headers last so they can override defaults if (optionsHeaders) { Object.assign(headers, optionsHeaders); } @@ -272,6 +265,7 @@ function buildParams(model: Model<"openai-responses">, context: Context, options return params; } +// Multipliers per https://developers.openai.com/api/docs/pricing (retrieved 2026-08-21) function getServiceTierCostMultiplier( model: Pick, "id">, serviceTier: ResponseCreateParamsStreaming["service_tier"] | undefined, diff --git a/packages/ai/src/providers/simple-options.ts b/packages/ai/src/providers/simple-options.ts index 6dced0f032..76f6ea8b44 100644 --- a/packages/ai/src/providers/simple-options.ts +++ b/packages/ai/src/providers/simple-options.ts @@ -21,7 +21,6 @@ export function buildBaseOptions(model: Model, options?: SimpleStreamOption } export function clampReasoning(effort: ThinkingLevel | undefined): Exclude | undefined { - // Token-budget providers have no distinct xhigh/max budget tier; clamp both to high. return effort === "xhigh" || effort === "max" ? "high" : effort; } diff --git a/packages/ai/src/providers/transform-messages.ts b/packages/ai/src/providers/transform-messages.ts index 8fde3716d1..cc3c74e5b0 100644 --- a/packages/ai/src/providers/transform-messages.ts +++ b/packages/ai/src/providers/transform-messages.ts @@ -66,18 +66,14 @@ export function transformMessages( model: Model, normalizeToolCallId?: (id: string, model: Model, source: AssistantMessage) => string, ): Message[] { - // Build a map of original tool call IDs to normalized IDs const toolCallIdMap = new Map(); const imageAwareMessages = downgradeUnsupportedImages(messages, model); - // First pass: transform messages (unsupported image downgrade, thinking blocks, tool call ID normalization) const transformed = imageAwareMessages.map((msg) => { - // User messages pass through unchanged if (msg.role === "user") { return msg; } - // Handle toolResult messages - normalize toolCallId if we have a mapping if (msg.role === "toolResult") { const normalizedId = toolCallIdMap.get(msg.toolCallId); if (normalizedId && normalizedId !== msg.toolCallId) { @@ -86,7 +82,6 @@ export function transformMessages( return msg; } - // Assistant messages need transformation check if (msg.role === "assistant") { const assistantMsg = msg as AssistantMessage; const isSameModel = @@ -152,7 +147,6 @@ export function transformMessages( return msg; }); - // Second pass: insert synthetic empty tool results for orphaned tool calls // This preserves thinking signatures and satisfies API requirements const result: Message[] = []; let pendingToolCalls: ToolCall[] = []; @@ -180,7 +174,6 @@ export function transformMessages( const msg = transformed[i]; if (msg.role === "assistant") { - // If we have pending orphaned tool calls from a previous assistant, insert synthetic results now insertSyntheticToolResults(); // Skip errored/aborted assistant messages entirely. @@ -193,7 +186,6 @@ export function transformMessages( continue; } - // Track tool calls from this assistant message const toolCalls = assistantMsg.content.filter((b) => b.type === "toolCall") as ToolCall[]; if (toolCalls.length > 0) { pendingToolCalls = toolCalls; @@ -205,7 +197,6 @@ export function transformMessages( existingToolResultIds.add(msg.toolCallId); result.push(msg); } else if (msg.role === "user") { - // User message interrupts tool flow - insert synthetic results for orphaned calls insertSyntheticToolResults(); result.push(msg); } else { @@ -213,7 +204,6 @@ export function transformMessages( } } - // If the conversation ends with unresolved tool calls, synthesize results now. insertSyntheticToolResults(); return result; diff --git a/packages/ai/src/utils/event-stream.ts b/packages/ai/src/utils/event-stream.ts index f4a7ceba8d..79dbbf2009 100644 --- a/packages/ai/src/utils/event-stream.ts +++ b/packages/ai/src/utils/event-stream.ts @@ -1,6 +1,5 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types.js"; -// Generic event stream class for async iteration export class EventStream implements AsyncIterable { private queue: T[] = []; private waiting: ((value: IteratorResult) => void)[] = []; @@ -25,7 +24,6 @@ export class EventStream implements AsyncIterable { this.resolveFinalResult(this.extractResult(event)); } - // Deliver to waiting consumer or queue it const waiter = this.waiting.shift(); if (waiter) { waiter({ value: event, done: false }); @@ -39,7 +37,6 @@ export class EventStream implements AsyncIterable { if (result !== undefined) { this.resolveFinalResult(result); } - // Notify all waiting consumers that we're done while (this.waiting.length > 0) { const waiter = this.waiting.shift()!; waiter({ value: undefined as any, done: true }); diff --git a/packages/ai/src/utils/hash.ts b/packages/ai/src/utils/hash.ts index 1ff55e8b41..8c6570c4e6 100644 --- a/packages/ai/src/utils/hash.ts +++ b/packages/ai/src/utils/hash.ts @@ -1,4 +1,3 @@ -/** Fast deterministic hash to shorten long strings */ export function shortHash(str: string): string { let h1 = 0xdeadbeef; let h2 = 0x41c6ce57; diff --git a/packages/ai/src/utils/oauth/github-copilot.ts b/packages/ai/src/utils/oauth/github-copilot.ts index dcacb43d2a..67263b8420 100644 --- a/packages/ai/src/utils/oauth/github-copilot.ts +++ b/packages/ai/src/utils/oauth/github-copilot.ts @@ -1,7 +1,3 @@ -/** - * GitHub Copilot OAuth flow - */ - import { getModels } from "../../models.js"; import type { Api, Model } from "../../types.js"; import type { OAuthCredentials, OAuthLoginCallbacks, OAuthProviderInterface } from "./types.js"; @@ -75,18 +71,15 @@ function getBaseUrlFromToken(token: string): string | null { const match = token.match(/proxy-ep=([^;]+)/); if (!match) return null; const proxyHost = match[1]; - // Convert proxy.xxx to api.xxx const apiHost = proxyHost.replace(/^proxy\./, "api."); return `https://${apiHost}`; } export function getGitHubCopilotBaseUrl(token?: string, enterpriseDomain?: string): string { - // If we have a token, extract the base URL from proxy-ep if (token) { const urlFromToken = getBaseUrlFromToken(token); if (urlFromToken) return urlFromToken; } - // Fallback for enterprise or if token parsing fails if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`; return "https://api.individual.githubcopilot.com"; } @@ -144,9 +137,6 @@ async function startDeviceFlow(domain: string): Promise { }; } -/** - * Sleep that can be interrupted by an AbortSignal - */ function abortableSleep(ms: number, signal?: AbortSignal): Promise { return new Promise((resolve, reject) => { if (signal?.aborted) { @@ -235,9 +225,6 @@ async function pollForGitHubAccessToken( throw new Error("Device flow timed out"); } -/** - * Refresh GitHub Copilot token - */ export async function refreshGitHubCopilotToken( refreshToken: string, enterpriseDomain?: string, @@ -359,7 +346,6 @@ export async function loginGitHubCopilot(options: { ); const credentials = await refreshGitHubCopilotToken(githubAccessToken, enterpriseDomain ?? undefined); - // Enable all models after successful login options.onProgress?.("Enabling models..."); await enableAllGitHubCopilotModels(credentials.access, enterpriseDomain ?? undefined); return credentials; diff --git a/packages/ai/src/utils/oauth/index.ts b/packages/ai/src/utils/oauth/index.ts index f5f180bf04..6c0b356ed3 100644 --- a/packages/ai/src/utils/oauth/index.ts +++ b/packages/ai/src/utils/oauth/index.ts @@ -7,9 +7,7 @@ * - GitHub Copilot */ -// Anthropic export { anthropicOAuthProvider, loginAnthropic, refreshAnthropicToken } from "./anthropic.js"; -// GitHub Copilot export { getGitHubCopilotBaseUrl, githubCopilotOAuthProvider, @@ -17,15 +15,10 @@ export { normalizeDomain, refreshGitHubCopilotToken, } from "./github-copilot.js"; -// OpenAI Codex (ChatGPT OAuth) export { loginOpenAICodex, openaiCodexOAuthProvider, refreshOpenAICodexToken } from "./openai-codex.js"; export * from "./types.js"; -// ============================================================================ -// Provider Registry -// ============================================================================ - import { anthropicOAuthProvider } from "./anthropic.js"; import { githubCopilotOAuthProvider } from "./github-copilot.js"; import { openaiCodexOAuthProvider } from "./openai-codex.js"; @@ -41,16 +34,10 @@ const oauthProviderRegistry = new Map( BUILT_IN_OAUTH_PROVIDERS.map((provider) => [provider.id, provider]), ); -/** - * Get an OAuth provider by ID - */ export function getOAuthProvider(id: OAuthProviderId): OAuthProviderInterface | undefined { return oauthProviderRegistry.get(id); } -/** - * Register a custom OAuth provider - */ export function registerOAuthProvider(provider: OAuthProviderInterface): void { oauthProviderRegistry.set(provider.id, provider); } @@ -70,9 +57,6 @@ export function unregisterOAuthProvider(id: string): void { oauthProviderRegistry.delete(id); } -/** - * Reset OAuth providers to built-ins. - */ export function resetOAuthProviders(): void { oauthProviderRegistry.clear(); for (const provider of BUILT_IN_OAUTH_PROVIDERS) { @@ -80,9 +64,6 @@ export function resetOAuthProviders(): void { } } -/** - * Get all registered OAuth providers - */ export function getOAuthProviders(): OAuthProviderInterface[] { return Array.from(oauthProviderRegistry.values()); } @@ -98,10 +79,6 @@ export function getOAuthProviderInfoList(): OAuthProviderInfo[] { })); } -// ============================================================================ -// High-level API (uses provider registry) -// ============================================================================ - /** * Refresh token for any OAuth provider. * @deprecated Use getOAuthProvider(id).refreshToken() instead @@ -138,7 +115,6 @@ export async function getOAuthApiKey( return null; } - // Refresh if expired if (Date.now() >= creds.expires) { try { creds = await provider.refreshToken(creds); diff --git a/packages/ai/src/utils/oauth/pkce.ts b/packages/ai/src/utils/oauth/pkce.ts index bf7ac7d587..f59c6fcb0a 100644 --- a/packages/ai/src/utils/oauth/pkce.ts +++ b/packages/ai/src/utils/oauth/pkce.ts @@ -3,9 +3,6 @@ * Works in both Node.js 20+ and browsers. */ -/** - * Encode bytes as base64url string. - */ function base64urlEncode(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { @@ -19,12 +16,10 @@ function base64urlEncode(bytes: Uint8Array): string { * Uses Web Crypto API for cross-platform compatibility. */ export async function generatePKCE(): Promise<{ verifier: string; challenge: string }> { - // Generate random verifier const verifierBytes = new Uint8Array(32); crypto.getRandomValues(verifierBytes); const verifier = base64urlEncode(verifierBytes); - // Compute SHA-256 challenge const encoder = new TextEncoder(); const data = encoder.encode(verifier); const hashBuffer = await crypto.subtle.digest("SHA-256", data); diff --git a/packages/ai/src/utils/overflow.ts b/packages/ai/src/utils/overflow.ts index b06a3fd17b..bf59405d82 100644 --- a/packages/ai/src/utils/overflow.ts +++ b/packages/ai/src/utils/overflow.ts @@ -115,7 +115,6 @@ const NON_OVERFLOW_PATTERNS = [ * @returns true if the message indicates a context overflow */ export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean { - // Case 1: Check error message patterns if (message.stopReason === "error" && message.errorMessage) { // Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit) const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!)); @@ -124,7 +123,6 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num } } - // Case 2: Silent overflow (z.ai style) - successful but usage exceeds context if (contextWindow && message.stopReason === "stop") { const inputTokens = message.usage.input + message.usage.cacheRead; if (inputTokens > contextWindow) { @@ -144,10 +142,3 @@ export function isContextOverflow(message: AssistantMessage, contextWindow?: num return false; } - -/** - * Get the overflow patterns for testing purposes. - */ -export function getOverflowPatterns(): RegExp[] { - return [...OVERFLOW_PATTERNS]; -} diff --git a/packages/ai/test/abort.test.ts b/packages/ai/test/abort.test.ts index 0973e3a192..c6ac1fb96f 100644 --- a/packages/ai/test/abort.test.ts +++ b/packages/ai/test/abort.test.ts @@ -10,7 +10,6 @@ import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-u import { hasBedrockCredentials } from "./bedrock-utils.js"; import { resolveApiKey } from "./oauth.js"; -// Resolve OAuth tokens at module level (async, runs before tests) const [openaiCodexToken] = await Promise.all([resolveApiKey("openai-codex")]); async function testAbortSignal(llm: Model, options: StreamOptionsWithExtras = {}) { @@ -41,7 +40,6 @@ async function testAbortSignal(llm: Model, options: Stre } const msg = await response.result(); - // If we get here without throwing, the abort didn't work expect(msg.stopReason).toBe("aborted"); expect(msg.content.length).toBeGreaterThan(0); @@ -71,7 +69,6 @@ async function testImmediateAbort(llm: Model, options: S } async function testAbortThenNewMessage(llm: Model, options: StreamOptionsWithExtras = {}) { - // First request: abort immediately before any response content arrives const controller = new AbortController(); controller.abort(); @@ -81,13 +78,10 @@ async function testAbortThenNewMessage(llm: Model, optio const abortedResponse = await complete(llm, context, { ...options, signal: controller.signal }); expect(abortedResponse.stopReason).toBe("aborted"); - // The aborted message has empty content since we aborted before anything arrived expect(abortedResponse.content.length).toBe(0); - // Add the aborted assistant message to context (this is what happens in the real coding agent) context.messages.push(abortedResponse); - // Second request: send a new message - this should work even with the aborted message in context context.messages.push({ role: "user", content: "What is 2 + 2?", diff --git a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts index 3fed7ce260..03da8be79a 100644 --- a/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts +++ b/packages/ai/test/anthropic-eager-tool-input-e2e.test.ts @@ -51,8 +51,6 @@ function getProbePriority(model: Model<"anthropic-messages">): number { const cost = model.cost.input + model.cost.output; let priority = cost; - // Prefer current Claude 4 Haiku routes when present: they are cheap and avoid - // stale Claude 3.x aliases that can remain in catalogs after upstream removal. if (modelId.includes("haiku") && (modelId.includes("4-5") || modelId.includes("4.5"))) { priority -= 1000; } else if (modelId.includes("sonnet") && (modelId.includes("4-") || modelId.includes("4."))) { diff --git a/packages/ai/test/anthropic-tool-name-normalization.test.ts b/packages/ai/test/anthropic-tool-name-normalization.test.ts index 1ea6bdb217..0a0b8ec943 100644 --- a/packages/ai/test/anthropic-tool-name-normalization.test.ts +++ b/packages/ai/test/anthropic-tool-name-normalization.test.ts @@ -7,28 +7,10 @@ import { resolveApiKey } from "./oauth.js"; const oauthToken = await resolveApiKey("anthropic"); -/** - * Tests for Anthropic OAuth tool name normalization. - * - * When using Claude Code OAuth, tool names must match CC's canonical casing. - * The normalization should: - * 1. Convert tool names that match CC tools (case-insensitive) to CC casing on outbound - * 2. Convert tool names back to the original casing on inbound - * - * This is a simple case-insensitive lookup, NOT a mapping of different names. - * e.g., "todowrite" -> "TodoWrite" -> "todowrite" (round-trip works) - * - * The old `find -> Glob` mapping was WRONG because: - * - Outbound: "find" -> "Glob" - * - Inbound: "Glob" -> ??? (no tool named "glob" in context.tools, only "find") - * - Result: tool call has name "Glob" but no tool exists with that name - */ describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => { const model = getModel("anthropic", "claude-sonnet-4-6"); it("should normalize user-defined tool matching CC name (todowrite -> TodoWrite -> todowrite)", async () => { - // User defines a tool named "todowrite" (lowercase) - // CC has "TodoWrite" - this should round-trip correctly const todoTool: Tool = { name: "todowrite", description: "Write a todo item", @@ -64,12 +46,10 @@ describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => { const response = await s.result(); expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse"); - // The tool call should come back with the ORIGINAL name "todowrite", not "TodoWrite" expect(toolCallName).toBe("todowrite"); }); it("should handle pi's built-in tools (read, write, edit, bash)", async () => { - // Pi's tools use lowercase names, CC uses PascalCase const readTool: Tool = { name: "read", description: "Read a file", @@ -105,14 +85,10 @@ describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => { const response = await s.result(); expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse"); - // The tool call should come back with the ORIGINAL name "read", not "Read" expect(toolCallName).toBe("read"); }); it("should NOT map find to Glob - find is not a CC tool name", async () => { - // Pi has a "find" tool, CC has "Glob" - these are DIFFERENT tools - // The old code incorrectly mapped find -> Glob, which broke the round-trip - // because there's no tool named "glob" in context.tools const findTool: Tool = { name: "find", description: "Find files by pattern", @@ -148,22 +124,10 @@ describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => { const response = await s.result(); expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse"); - // With the BROKEN find -> Glob mapping: - // - Sent as "Glob" to Anthropic - // - Received back as "Glob" - // - fromClaudeCodeName("Glob", tools) looks for tool.name.toLowerCase() === "glob" - // - No match (tool is named "find"), returns "Glob" - // - Test fails: toolCallName is "Glob" instead of "find" - // - // With the CORRECT implementation (no find->Glob mapping): - // - Sent as "find" to Anthropic (no CC tool named "Find") - // - Received back as "find" - // - Test passes: toolCallName is "find" expect(toolCallName).toBe("find"); }); it("should handle custom tools that don't match any CC tool names", async () => { - // A completely custom tool should pass through unchanged const customTool: Tool = { name: "my_custom_tool", description: "A custom tool", @@ -199,7 +163,6 @@ describe.skipIf(!oauthToken)("Anthropic OAuth tool name normalization", () => { const response = await s.result(); expect(response.stopReason, `Error: ${response.errorMessage}`).toBe("toolUse"); - // Custom tool names should pass through unchanged expect(toolCallName).toBe("my_custom_tool"); }); }); diff --git a/packages/ai/test/azure-utils.ts b/packages/ai/test/azure-utils.ts index d83b198a63..2c668f06a6 100644 --- a/packages/ai/test/azure-utils.ts +++ b/packages/ai/test/azure-utils.ts @@ -1,7 +1,3 @@ -/** - * Utility functions for Azure OpenAI tests - */ - function parseDeploymentNameMap(value: string | undefined): Map { const map = new Map(); if (!value) return map; diff --git a/packages/ai/test/bedrock-models.test.ts b/packages/ai/test/bedrock-models.test.ts index 453a395375..da86e71d4c 100644 --- a/packages/ai/test/bedrock-models.test.ts +++ b/packages/ai/test/bedrock-models.test.ts @@ -1,21 +1,3 @@ -/** - * A test suite to ensure all configured Amazon Bedrock models are usable. - * - * This is here to make sure we got correct model identifiers from models.dev and other sources. - * Because Amazon Bedrock requires cross-region inference in some models, - * plain model identifiers are not always usable and it requires tweaking of model identifiers to use cross-region inference. - * See https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html#inference-profiles-support-system for more details. - * - * This test suite is not enabled by default unless AWS credentials and `BEDROCK_EXTENSIVE_MODEL_TEST` environment variables are set. - * This test suite takes ~2 minutes to run. Because not all models are available in all regions, - * it's recommended to use `us-west-2` region for best coverage for running this test suite. - * - * You can run this test suite with: - * ```bash - * $ AWS_REGION=us-west-2 BEDROCK_EXTENSIVE_MODEL_TEST=1 AWS_PROFILE=... npm test -- ./test/bedrock-models.test.ts - * ``` - */ - import { describe, expect, it } from "vitest"; import { getModels } from "../src/models.js"; import { complete } from "../src/stream.js"; diff --git a/packages/ai/test/bedrock-thinking-payload.test.ts b/packages/ai/test/bedrock-thinking-payload.test.ts index 0b2151f693..741b839c03 100644 --- a/packages/ai/test/bedrock-thinking-payload.test.ts +++ b/packages/ai/test/bedrock-thinking-payload.test.ts @@ -180,11 +180,9 @@ describe("Application inference profile support", () => { if (event.type === "error") break; } - // System prompt should have a cache point expect(capturedPayload.system).toHaveLength(2); expect(capturedPayload.system[1]).toHaveProperty("cachePoint"); - // Last user message should have a cache point const lastMsg = capturedPayload.messages[capturedPayload.messages.length - 1]; const lastContent = lastMsg.content[lastMsg.content.length - 1]; expect(lastContent).toHaveProperty("cachePoint"); diff --git a/packages/ai/test/bedrock-utils.ts b/packages/ai/test/bedrock-utils.ts index 11ac93de72..4e0ecc6ef4 100644 --- a/packages/ai/test/bedrock-utils.ts +++ b/packages/ai/test/bedrock-utils.ts @@ -1,14 +1,3 @@ -/** - * Utility functions for Amazon Bedrock tests - */ - -/** - * Check if any valid AWS credentials are configured for Bedrock. - * Returns true if any of the following are set: - * - AWS_PROFILE (named profile from ~/.aws/credentials) - * - AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY (IAM keys) - * - AWS_BEARER_TOKEN_BEDROCK (Bedrock API key) - */ export function hasBedrockCredentials(): boolean { return !!( process.env.AWS_PROFILE || diff --git a/packages/ai/test/cache-retention.test.ts b/packages/ai/test/cache-retention.test.ts index 2f234ab081..554a14f39a 100644 --- a/packages/ai/test/cache-retention.test.ts +++ b/packages/ai/test/cache-retention.test.ts @@ -36,13 +36,10 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }, }); - // Consume the stream to trigger the request for await (const _ of s) { - // Just consume } expect(capturedPayload).not.toBeNull(); - // System prompt should have cache_control without ttl expect(capturedPayload.system).toBeDefined(); expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral" }); }, @@ -59,13 +56,10 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }, }); - // Consume the stream to trigger the request for await (const _ of s) { - // Just consume } expect(capturedPayload).not.toBeNull(); - // System prompt should have cache_control with ttl: "1h" expect(capturedPayload.system).toBeDefined(); expect(capturedPayload.system[0].cache_control).toEqual({ type: "ephemeral", ttl: "1h" }); }); @@ -73,7 +67,6 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { it("should add ttl for non-api.anthropic.com baseUrl by default", async () => { process.env.PI_CACHE_RETENTION = "long"; - // Create a model with a different baseUrl (simulating a proxy) const baseModel = getModel("anthropic", "claude-haiku-4-5"); const proxyModel = { ...baseModel, @@ -82,12 +75,6 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { let capturedPayload: any = null; - // We can't actually make the request (no proxy), but we can verify the payload - // by using a mock or checking the logic directly - // For this test, we'll import the helper directly - - // Since we can't easily test this without mocking, we'll skip the actual API call - // and just verify the helper logic works correctly const { streamAnthropic } = await import("../src/providers/anthropic.js"); try { @@ -98,12 +85,11 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }, }); - // This will fail since we're using a fake key and fake proxy, but the payload should be captured for await (const event of s) { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -134,7 +120,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -160,7 +146,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -185,7 +171,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -214,7 +200,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -235,9 +221,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }, }); - // Consume the stream to trigger the request for await (const _ of s) { - // Just consume } expect(capturedPayload).not.toBeNull(); @@ -258,9 +242,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }, }); - // Consume the stream to trigger the request for await (const _ of s) { - // Just consume } expect(capturedPayload).not.toBeNull(); @@ -271,7 +253,6 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { it("should set prompt_cache_retention for non-api.openai.com baseUrl by default", async () => { process.env.PI_CACHE_RETENTION = "long"; - // Create a model with a different baseUrl (simulating a proxy) const baseModel = getModel("openai", "gpt-4o-mini"); const proxyModel = { ...baseModel, @@ -290,12 +271,11 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { }, }); - // This will fail since we're using a fake key and fake proxy, but the payload should be captured for await (const event of s) { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -325,7 +305,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -352,7 +332,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -380,7 +360,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -424,7 +404,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); @@ -450,7 +430,7 @@ describe("Cache Retention (PI_CACHE_RETENTION)", () => { if (event.type === "error") break; } } catch { - // Expected to fail + // The fake proxy request fails after the payload capture used by this assertion. } expect(capturedPayload).not.toBeNull(); diff --git a/packages/ai/test/codex-websocket-cached-probe.ts b/packages/ai/test/codex-websocket-cached-probe.ts index ccc875f854..9bab146687 100644 --- a/packages/ai/test/codex-websocket-cached-probe.ts +++ b/packages/ai/test/codex-websocket-cached-probe.ts @@ -1,10 +1,4 @@ #!/usr/bin/env tsx -/** - * Live probe for OpenAI Codex Responses websocket-cached mode. - * - * Runs a simple tool loop directly against the pi-ai provider source so it does not - * depend on built dist packages or coding-agent SDK wiring. - */ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; diff --git a/packages/ai/test/context-overflow.test.ts b/packages/ai/test/context-overflow.test.ts index 7adea16643..63d185ef71 100644 --- a/packages/ai/test/context-overflow.test.ts +++ b/packages/ai/test/context-overflow.test.ts @@ -1,16 +1,3 @@ -/** - * Test context overflow error handling across providers. - * - * Context overflow occurs when the input (prompt + history) exceeds - * the model's context window. This is different from output token limits. - * - * Expected behavior: All providers should return stopReason: "error" - * with an errorMessage that indicates the context was too large, - * OR (for z.ai) return successfully with usage.input > contextWindow. - * - * The isContextOverflow() function must return true for all providers. - */ - import type { ChildProcess } from "child_process"; import { execSync, spawn } from "child_process"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; @@ -24,15 +11,11 @@ import { getKimiCodingTestModel } from "./kimi-test-model.js"; import { resolveApiKey } from "./oauth.js"; import { getZaiTestModel } from "./zai-test-model.js"; -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([resolveApiKey("github-copilot"), resolveApiKey("openai-codex")]); const [githubCopilotToken, openaiCodexToken] = oauthTokens; -// Lorem ipsum paragraph for realistic token estimation const LOREM_IPSUM = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. `; -// Generate a string that will exceed the context window -// Using chars/4 as token estimate (works better with varied text than repeated chars) function generateOverflowContent(contextWindow: number): string { const targetTokens = contextWindow + 10000; // Exceed by 10k tokens const targetChars = targetTokens * 4 * 1.5; @@ -90,11 +73,6 @@ function logResult(result: OverflowResult) { console.log(` hasUsageData: ${result.hasUsageData}`); } -// ============================================================================= -// Anthropic -// Expected pattern: "prompt is too long: X tokens > Y maximum" -// ============================================================================= - describe("Context overflow error handling", () => { describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic (API Key)", () => { it("claude-haiku-4-5 - should detect overflow via isContextOverflow", async () => { @@ -120,13 +98,7 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // GitHub Copilot (OAuth) - // Tests both OpenAI and Anthropic models via Copilot - // ============================================================================= - describe("GitHub Copilot (OAuth)", () => { - // OpenAI model via Copilot it.skipIf(!githubCopilotToken)( "gpt-5-mini - should detect overflow via isContextOverflow", async () => { @@ -141,7 +113,6 @@ describe("Context overflow error handling", () => { 120000, ); - // Anthropic model via Copilot it.skipIf(!githubCopilotToken)( "claude-sonnet-4 - should detect overflow via isContextOverflow", async () => { @@ -157,11 +128,6 @@ describe("Context overflow error handling", () => { ); }); - // ============================================================================= - // OpenAI - // Expected pattern: "exceeds the context window" - // ============================================================================= - describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => { it("gpt-4o-mini - should detect overflow via isContextOverflow", async () => { const model = { ...getModel("openai", "gpt-4o-mini") }; @@ -199,11 +165,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Google - // Expected pattern: "input token count (X) exceeds the maximum" - // ============================================================================= - describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { it("gemini-2.5-flash - should detect overflow via isContextOverflow", async () => { const model = getModel("google", "gemini-2.5-flash"); @@ -216,18 +177,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Uses same API as Google, expects same error pattern - // ============================================================================= - - // ============================================================================= - // ============================================================================= - - // ============================================================================= - // OpenAI Codex (OAuth) - // Uses ChatGPT Plus/Pro subscription via OAuth - // ============================================================================= - describe("OpenAI Codex (OAuth)", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should detect overflow via isContextOverflow", @@ -243,11 +192,6 @@ describe("Context overflow error handling", () => { ); }); - // ============================================================================= - // Amazon Bedrock - // Expected pattern: "Input is too long for requested model" - // ============================================================================= - describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock", () => { it("claude-sonnet-4-5 - should detect overflow via isContextOverflow", async () => { const model = getModel("amazon-bedrock", "global.anthropic.claude-sonnet-4-5-20250929-v1:0"); @@ -259,11 +203,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // xAI - // Expected pattern: "maximum prompt length is X but the request contains Y" - // ============================================================================= - describe.skipIf(!process.env.XAI_API_KEY)("xAI", () => { it("grok-4.3 - should detect overflow via isContextOverflow", async () => { const model = getModel("xai", "grok-4.3"); @@ -276,11 +215,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Groq - // Expected pattern: "reduce the length of the messages" - // ============================================================================= - describe.skipIf(!process.env.GROQ_API_KEY)("Groq", () => { it("llama-3.3-70b-versatile - should detect overflow via isContextOverflow", async () => { const model = getModel("groq", "llama-3.3-70b-versatile"); @@ -293,11 +227,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Cerebras - // Expected: 400/413 status code with no body - // ============================================================================= - describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => { it("gpt-oss-120b - should detect overflow via isContextOverflow", async () => { const model = getModel("cerebras", "gpt-oss-120b"); @@ -305,17 +234,11 @@ describe("Context overflow error handling", () => { logResult(result); expect(result.stopReason).toBe("error"); - // Cerebras returns status code with no body (400, 413, or 429 for token rate limit) expect(result.errorMessage).toMatch(/4(00|13|29).*\(no body\)/i); expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); }, 120000); }); - // ============================================================================= - // Hugging Face - // Uses OpenAI-compatible Inference Router - // ============================================================================= - describe.skipIf(!process.env.HF_TOKEN)("Hugging Face", () => { it("Kimi-K2.5 - should detect overflow via isContextOverflow", async () => { const model = getModel("huggingface", "moonshotai/Kimi-K2.5"); @@ -327,22 +250,12 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // z.ai - // Special case: may return explicit overflow error text, may accept overflow silently, - // or may rate limit instead - // ============================================================================= - describe.skipIf(!process.env.ZAI_API_KEY)("z.ai", () => { it("should detect overflow via isContextOverflow when z.ai reports it", async () => { const model = getZaiTestModel({ smallestContextWindow: true }); const result = await testContextOverflow(model, process.env.ZAI_API_KEY!); logResult(result); - // z.ai behavior is inconsistent: - // - Sometimes returns explicit overflow error text via non-standard finish_reason handling - // - Sometimes accepts overflow and returns successfully with usage.input > contextWindow - // - Sometimes returns rate limit error if (result.stopReason === "error") { if (result.errorMessage?.match(/model_context_window_exceeded/i)) { expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); @@ -359,10 +272,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Mistral - // ============================================================================= - describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral", () => { it("devstral-medium-latest - should detect overflow via isContextOverflow", async () => { const model = getModel("mistral", "devstral-medium-latest"); @@ -375,11 +284,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // MiniMax - // Expected pattern: TBD - need to test actual error message - // ============================================================================= - describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax", () => { it("MiniMax-M2.7 - should detect overflow via isContextOverflow", async () => { const model = getModel("minimax", "MiniMax-M2.7"); @@ -391,14 +295,7 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Xiaomi MiMo - // ============================================================================= - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing)", () => { - // Xiaomi silently truncates oversized input to fill the context window exactly, - // then returns finish_reason "length" with output=0 (no room left to generate). - // This is a detectable overflow signal but uses stopReason "length" rather than "error". it("mimo-v2.5-pro - should detect overflow via isContextOverflow", async () => { const model = getModel("xiaomi", "mimo-v2.5-pro"); const result = await testContextOverflow(model, process.env.XIAOMI_API_KEY!); @@ -446,10 +343,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Kimi For Coding - // ============================================================================= - describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding", () => { it("should detect overflow via isContextOverflow", async () => { const model = getKimiCodingTestModel(); @@ -461,10 +354,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Vercel AI Gateway - Unified API for multiple providers - // ============================================================================= - describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway", () => { it("google/gemini-2.5-flash via AI Gateway - should detect overflow via isContextOverflow", async () => { const model = getModel("vercel-ai-gateway", "google/gemini-2.5-flash"); @@ -476,13 +365,7 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // OpenRouter - Multiple backend providers - // Expected pattern: "maximum context length is X tokens" - // ============================================================================= - describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter", () => { - // Anthropic backend it("anthropic/claude-sonnet-4 via OpenRouter - should detect overflow via isContextOverflow", async () => { const model = getModel("openrouter", "anthropic/claude-sonnet-4"); const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); @@ -493,7 +376,6 @@ describe("Context overflow error handling", () => { expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); }, 120000); - // DeepSeek backend it("deepseek/deepseek-v3.2 via OpenRouter - should detect overflow via isContextOverflow", async () => { const model = getModel("openrouter", "deepseek/deepseek-v3.2"); const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); @@ -504,7 +386,6 @@ describe("Context overflow error handling", () => { expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); }, 120000); - // Mistral backend it("mistralai/mistral-large-2512 via OpenRouter - should detect overflow via isContextOverflow", async () => { const model = getModel("openrouter", "mistralai/mistral-large-2512"); const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); @@ -515,7 +396,6 @@ describe("Context overflow error handling", () => { expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); }, 120000); - // Google backend it("google/gemini-2.5-flash via OpenRouter - should detect overflow via isContextOverflow", async () => { const model = getModel("openrouter", "google/gemini-2.5-flash"); const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); @@ -526,7 +406,6 @@ describe("Context overflow error handling", () => { expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); }, 120000); - // Meta/Llama backend it("meta-llama/llama-4-scout via OpenRouter - should detect overflow via isContextOverflow", async () => { const model = getModel("openrouter", "meta-llama/llama-4-scout"); const result = await testContextOverflow(model, process.env.OPENROUTER_API_KEY!); @@ -538,11 +417,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // Ollama (local) - // ============================================================================= - - // Check if ollama is installed and local LLM tests are enabled let ollamaInstalled = false; if (!process.env.PI_NO_LOCAL_LLM) { try { @@ -558,7 +432,6 @@ describe("Context overflow error handling", () => { let model: Model<"openai-completions">; beforeAll(async () => { - // Check if model is available, if not pull it try { execSync("ollama list | grep -q 'gpt-oss:20b'", { stdio: "ignore" }); } catch { @@ -571,13 +444,11 @@ describe("Context overflow error handling", () => { } } - // Start ollama server ollamaProcess = spawn("ollama", ["serve"], { detached: false, stdio: "ignore", }); - // Wait for server to be ready await new Promise((resolve) => { const checkServer = async () => { try { @@ -619,24 +490,14 @@ describe("Context overflow error handling", () => { const result = await testContextOverflow(model, "ollama"); logResult(result); - // Ollama silently truncates input instead of erroring - // It returns stopReason "stop" with truncated usage - // We cannot detect overflow via error message, only via usage comparison if (result.stopReason === "stop" && result.hasUsageData) { - // Ollama truncated - check if reported usage is less than what we sent - // This is a "silent overflow" - we can detect it if we know expected input size console.log(" Ollama silently truncated input to", result.usage.input, "tokens"); - // For now, we accept this behavior - Ollama doesn't give us a way to detect overflow } else if (result.stopReason === "error") { expect(isContextOverflow(result.response, model.contextWindow)).toBe(true); } }, 300000); // 5 min timeout for local model }); - // ============================================================================= - // LM Studio (local) - Skip if not running or local LLM tests disabled - // ============================================================================= - let lmStudioRunning = false; if (!process.env.PI_NO_LOCAL_LLM) { try { @@ -670,10 +531,6 @@ describe("Context overflow error handling", () => { }, 120000); }); - // ============================================================================= - // llama.cpp server (local) - Skip if not running or not exposing /v1/completions - // ============================================================================= - let llamaCppRunning = false; if (!process.env.PI_NO_LOCAL_LLM) { try { @@ -690,7 +547,6 @@ describe("Context overflow error handling", () => { describe.skipIf(!llamaCppRunning)("llama.cpp (local)", () => { it("should detect overflow via isContextOverflow", async () => { - // Using small context (4096) to match server --ctx-size setting const model: Model<"openai-completions"> = { id: "local-model", api: "openai-completions", diff --git a/packages/ai/test/cross-provider-handoff.test.ts b/packages/ai/test/cross-provider-handoff.test.ts index 9407119b37..952ffa1a1d 100644 --- a/packages/ai/test/cross-provider-handoff.test.ts +++ b/packages/ai/test/cross-provider-handoff.test.ts @@ -1,27 +1,3 @@ -/** - * Cross-Provider Handoff Test - * - * Tests that contexts generated by one provider/model can be consumed by another. - * This catches issues like: - * - Tool call ID format incompatibilities (e.g., OpenAI Codex pipe characters) - * - Thinking block transformation issues - * - Message format incompatibilities - * - * Strategy: - * 1. beforeAll: For each provider/model, generate a "small context" (if not cached): - * - User message asking to use a tool - * - Assistant response with thinking + tool call - * - Tool result - * - Final assistant response - * - * 2. Test: For each target provider/model: - * - Concatenate ALL other contexts into one - * - Ask the model to "say hi" - * - If it fails, there's a compatibility issue - * - * Fixtures are generated fresh on each run. - */ - import { writeFileSync } from "fs"; import { Type } from "typebox"; import { beforeAll, describe, expect, it } from "vitest"; @@ -33,7 +9,6 @@ import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } import { getKimiCodingTestModel } from "./kimi-test-model.js"; import { resolveApiKey } from "./oauth.js"; -// Simple tool for testing const testToolSchema = Type.Object({ value: Type.Number({ description: "A number to double" }), }); @@ -44,7 +19,6 @@ const testTool: Tool = { parameters: testToolSchema, }; -// Provider/model pairs to test interface ProviderModelPair { provider: string; model: string; @@ -54,11 +28,8 @@ interface ProviderModelPair { } const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ - // Anthropic { provider: "anthropic", model: "claude-sonnet-4-5", label: "anthropic-claude-sonnet-4-5" }, - // Google { provider: "google", model: "gemini-3-flash-preview", label: "google-gemini-3-flash-preview" }, - // OpenAI { provider: "openai", model: "gpt-4o-mini", @@ -67,33 +38,20 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ }, { provider: "openai", model: "gpt-5-mini", label: "openai-responses-gpt-5-mini" }, { provider: "azure-openai-responses", model: "gpt-4o-mini", label: "azure-openai-responses-gpt-4o-mini" }, - // OpenAI Codex { provider: "openai-codex", model: "gpt-5.2-codex", label: "openai-codex-gpt-5.2-codex" }, - // Prime Inference { provider: "prime-inference", model: "openai/gpt-5.5", label: "prime-inference-gpt-5.5" }, - // GitHub Copilot { provider: "github-copilot", model: "claude-sonnet-4.5", label: "copilot-claude-sonnet-4.5" }, - { provider: "github-copilot", model: "gpt-5.1-codex", label: "copilot-gpt-5.1-codex" }, - { provider: "github-copilot", model: "gemini-3-flash-preview", label: "copilot-gemini-3-flash-preview" }, - { provider: "github-copilot", model: "grok-code-fast-1", label: "copilot-grok-code-fast-1" }, - // Amazon Bedrock + { provider: "github-copilot", model: "gpt-5.2-codex", label: "copilot-gpt-5.2-codex" }, + { provider: "github-copilot", model: "gemini-3.5-flash", label: "copilot-gemini-3.5-flash" }, + { provider: "github-copilot", model: "grok-4.5", label: "copilot-grok-4.5" }, { provider: "amazon-bedrock", model: "global.anthropic.claude-sonnet-4-5-20250929-v1:0", label: "bedrock-claude-sonnet-4-5", }, - // xAI { provider: "xai", model: "grok-code-fast-1", label: "xai-grok-code-fast-1" }, - // Cerebras - { provider: "cerebras", model: "zai-glm-4.7", label: "cerebras-zai-glm-4.7" }, - // Cloudflare Workers AI + { provider: "cerebras", model: "gpt-oss-120b", label: "cerebras-gpt-oss-120b" }, { provider: "cloudflare-workers-ai", model: "@cf/moonshotai/kimi-k2.6", label: "cloudflare-kimi-k2.6" }, - // Cloudflare AI Gateway - { - provider: "cloudflare-ai-gateway", - model: "workers-ai/@cf/moonshotai/kimi-k2.6", - label: "cloudflare-gateway-kimi-k2.6", - }, { provider: "cloudflare-ai-gateway", model: "claude-sonnet-4-5", @@ -106,35 +64,39 @@ const PROVIDER_MODEL_PAIRS: ProviderModelPair[] = [ label: "cloudflare-gateway-gpt-5.1", upstreamApiKeyEnv: "OPENAI_API_KEY", }, - // Groq { provider: "groq", model: "openai/gpt-oss-120b", label: "groq-gpt-oss-120b" }, - // Hugging Face { provider: "huggingface", model: "moonshotai/Kimi-K2.5", label: "huggingface-kimi-k2.5" }, - // Kimi For Coding { provider: "kimi-coding", model: getKimiCodingTestModel().id, label: "kimi-coding" }, - // Mistral { provider: "mistral", model: "devstral-medium-latest", label: "mistral-devstral-medium" }, - // MiniMax { provider: "minimax", model: "MiniMax-M2.7", label: "minimax-m2.7" }, { provider: "minimax-cn", model: "MiniMax-M2.7", label: "minimax-m2.7" }, - // OpenCode Zen { provider: "opencode", model: "big-pickle", label: "zen-big-pickle" }, { provider: "opencode", model: "claude-sonnet-4-5", label: "zen-claude-sonnet-4-5" }, { provider: "opencode", model: "gemini-3-flash", label: "zen-gemini-3-flash" }, - { provider: "opencode", model: "glm-4.7-free", label: "zen-glm-4.7-free" }, + { provider: "opencode", model: "glm-5.2", label: "zen-glm-5.2" }, { provider: "opencode", model: "gpt-5.2-codex", label: "zen-gpt-5.2-codex" }, - { provider: "opencode", model: "minimax-m2.1-free", label: "zen-minimax-m2.1-free" }, - // OpenCode Go - { provider: "opencode-go", model: "kimi-k2.5", label: "go-kimi-k2.5" }, - { provider: "opencode-go", model: "minimax-m2.5", label: "go-minimax-m2.5" }, - // Xiaomi MiMo + { provider: "opencode", model: "minimax-m2.7", label: "zen-minimax-m2.7" }, + { provider: "opencode-go", model: "kimi-k2.6", label: "go-kimi-k2.6" }, + { provider: "opencode-go", model: "minimax-m2.7", label: "go-minimax-m2.7" }, { provider: "xiaomi", model: "mimo-v2.5-pro", label: "xiaomi-mimo-v2.5-pro" }, { provider: "xiaomi-token-plan-cn", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-cn-mimo-v2.5-pro" }, { provider: "xiaomi-token-plan-ams", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-ams-mimo-v2.5-pro" }, { provider: "xiaomi-token-plan-sgp", model: "mimo-v2.5-pro", label: "xiaomi-token-plan-sgp-mimo-v2.5-pro" }, ]; -// Cached context structure +function resolveProviderModel(pair: ProviderModelPair): Model | undefined { + return (getModel as (provider: string, model: string) => Model | undefined)(pair.provider, pair.model); +} + +describe("Cross-Provider Handoff configuration", () => { + it("references models in the generated catalog", () => { + const missingModels = PROVIDER_MODEL_PAIRS.filter((pair) => !resolveProviderModel(pair)).map( + (pair) => `${pair.provider}/${pair.model}`, + ); + expect(missingModels).toEqual([]); + }); +}); + interface CachedContext { label: string; provider: string; @@ -144,18 +106,12 @@ interface CachedContext { generatedAt: string; } -/** - * Get API key for provider - checks OAuth storage first, then env vars - */ async function getApiKey(provider: string): Promise { const oauthKey = await resolveApiKey(provider); if (oauthKey) return oauthKey; return getEnvApiKey(provider); } -/** - * Synchronous check for API key availability (env vars only, for skipIf) - */ function hasApiKey(pair: ProviderModelPair): boolean { if (pair.provider === "azure-openai-responses") { return hasAzureOpenAICredentials(); @@ -176,9 +132,6 @@ function getHeaders(pair: ProviderModelPair): Record | undefined return upstreamApiKey ? { Authorization: `Bearer ${upstreamApiKey}` } : undefined; } -/** - * Check if any provider has API keys available (for skipIf at describe level) - */ function hasAnyApiKey(): boolean { return PROVIDER_MODEL_PAIRS.some((pair) => hasApiKey(pair)); } @@ -195,15 +148,11 @@ function dumpFailurePayload(params: { label: string; error: string; payload?: un console.log(`Wrote failure payload to ${filename}`); } -/** - * Generate a context from a provider/model pair. - * Makes a real API call to get authentic tool call IDs and thinking blocks. - */ async function generateContext( pair: ProviderModelPair, apiKey: string, ): Promise<{ messages: Message[]; api: Api } | null> { - const baseModel = (getModel as (p: string, m: string) => Model | undefined)(pair.provider, pair.model); + const baseModel = resolveProviderModel(pair); if (!baseModel) { console.log(` Model not found: ${pair.provider}/${pair.model}`); return null; @@ -394,7 +343,6 @@ describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => { continue; } - // Collect messages from ALL OTHER contexts const otherMessages: Message[] = []; for (const [label, ctx] of Object.entries(contexts)) { if (label === targetPair.label) continue; @@ -416,10 +364,7 @@ describe.skipIf(!hasAnyApiKey())("Cross-Provider Handoff", () => { }, ]; - const baseModel = (getModel as (p: string, m: string) => Model | undefined)( - targetPair.provider, - targetPair.model, - ); + const baseModel = resolveProviderModel(targetPair); if (!baseModel) { console.log(`[Target: ${targetPair.label}] Model not found`); continue; diff --git a/packages/ai/test/empty.test.ts b/packages/ai/test/empty.test.ts index 4539b3f39a..d68c1bf6fb 100644 --- a/packages/ai/test/empty.test.ts +++ b/packages/ai/test/empty.test.ts @@ -9,10 +9,9 @@ type StreamOptionsWithExtras = StreamOptions & Record; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; +import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; import { resolveApiKey } from "./oauth.js"; -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ resolveApiKey("anthropic"), resolveApiKey("github-copilot"), @@ -21,7 +20,6 @@ const oauthTokens = await Promise.all([ const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens; async function testEmptyMessage(llm: Model, options: StreamOptionsWithExtras = {}) { - // Test with completely empty content array const emptyMessage: UserMessage = { role: "user", content: [], @@ -34,10 +32,8 @@ async function testEmptyMessage(llm: Model, options: Str const response = await complete(llm, context, options); - // Should either handle gracefully or return an error expect(response).toBeDefined(); expect(response.role).toBe("assistant"); - // Should handle empty string gracefully if (response.stopReason === "error") { expect(response.errorMessage).toBeDefined(); } else { @@ -46,7 +42,6 @@ async function testEmptyMessage(llm: Model, options: Str } async function testEmptyStringMessage(llm: Model, options: StreamOptionsWithExtras = {}) { - // Test with empty string content const context: Context = { messages: [ { @@ -62,7 +57,6 @@ async function testEmptyStringMessage(llm: Model, option expect(response).toBeDefined(); expect(response.role).toBe("assistant"); - // Should handle empty string gracefully if (response.stopReason === "error") { expect(response.errorMessage).toBeDefined(); } else { @@ -71,7 +65,6 @@ async function testEmptyStringMessage(llm: Model, option } async function testWhitespaceOnlyMessage(llm: Model, options: StreamOptionsWithExtras = {}) { - // Test with whitespace-only content const context: Context = { messages: [ { @@ -87,7 +80,6 @@ async function testWhitespaceOnlyMessage(llm: Model, opt expect(response).toBeDefined(); expect(response.role).toBe("assistant"); - // Should handle whitespace-only gracefully if (response.stopReason === "error") { expect(response.errorMessage).toBeDefined(); } else { @@ -96,8 +88,6 @@ async function testWhitespaceOnlyMessage(llm: Model, opt } async function testEmptyAssistantMessage(llm: Model, options: StreamOptionsWithExtras = {}) { - // Test with empty assistant message in conversation flow - // User -> Empty Assistant -> User const emptyAssistant: AssistantMessage = { role: "assistant", content: [], @@ -137,7 +127,6 @@ async function testEmptyAssistantMessage(llm: Model, opt expect(response).toBeDefined(); expect(response.role).toBe("assistant"); - // Should handle empty assistant message in context gracefully if (response.stopReason === "error") { expect(response.errorMessage).toBeDefined(); } else { @@ -329,26 +318,6 @@ describe("AI Providers Empty Message Tests", () => { }); }); - describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider Empty Messages", () => { - const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); - - it("should handle empty content array", { retry: 3, timeout: 30000 }, async () => { - await testEmptyMessage(llm); - }); - - it("should handle empty string content", { retry: 3, timeout: 30000 }, async () => { - await testEmptyStringMessage(llm); - }); - - it("should handle whitespace-only content", { retry: 3, timeout: 30000 }, async () => { - await testWhitespaceOnlyMessage(llm); - }); - - it("should handle empty assistant message in conversation", { retry: 3, timeout: 30000 }, async () => { - await testEmptyAssistantMessage(llm); - }); - }); - describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Empty Messages", () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); @@ -578,10 +547,6 @@ describe("AI Providers Empty Message Tests", () => { }); }); - // ========================================================================= - // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) - // ========================================================================= - describe("Anthropic OAuth Provider Empty Messages", () => { const llm = getModel("anthropic", "claude-haiku-4-5"); diff --git a/packages/ai/test/fast-mode.test.ts b/packages/ai/test/fast-mode.test.ts index 3f2185ec0c..7c32030016 100644 --- a/packages/ai/test/fast-mode.test.ts +++ b/packages/ai/test/fast-mode.test.ts @@ -23,10 +23,17 @@ describe("Fast mode", () => { expect(supportsFastMode(model("openai-codex", id, "openai-codex-responses"))).toBe(true); }); - it("rejects unsupported models and API-key providers", () => { + it("rejects unsupported models and non-OpenAI gateways", () => { expect(supportsFastMode(model("openai-codex", "gpt-5.3-codex", "openai-codex-responses"))).toBe(false); expect(supportsFastMode(model("openai-codex", "gpt-5.4-mini", "openai-codex-responses"))).toBe(false); - expect(supportsFastMode(model("openai", "gpt-5.5", "openai-responses"))).toBe(false); + expect(supportsFastMode(model("openai", "gpt-5.1", "openai-responses"))).toBe(false); + expect(supportsFastMode(model("github-copilot", "gpt-5.5", "openai-responses"))).toBe(false); + }); + + it("admits API-key models and forwards priority", () => { + const testModel = model("openai", "gpt-5.5", "openai-responses"); + expect(supportsFastMode(testModel)).toBe(true); + expect(buildBaseOptions(testModel, { serviceTier: "priority" }).serviceTier).toBe("priority"); }); it("forwards priority through simple stream options", () => { diff --git a/packages/ai/test/fireworks-models.test.ts b/packages/ai/test/fireworks-models.test.ts index 897968df4b..e8ac50398c 100644 --- a/packages/ai/test/fireworks-models.test.ts +++ b/packages/ai/test/fireworks-models.test.ts @@ -32,15 +32,6 @@ describe("Fireworks models", () => { }); }); - it("registers the Fire Pass turbo router model", () => { - const model = getModel("fireworks", "accounts/fireworks/routers/kimi-k2p6-turbo"); - - expect(model).toBeDefined(); - expect(model.api).toBe("anthropic-messages"); - expect(model.baseUrl).toBe("https://api.fireworks.ai/inference"); - expect(model.input).toEqual(["text", "image"]); - }); - it("resolves FIREWORKS_API_KEY from the environment", () => { process.env.FIREWORKS_API_KEY = "test-fireworks-key"; diff --git a/packages/ai/test/github-copilot-anthropic.test.ts b/packages/ai/test/github-copilot-anthropic.test.ts index dc9589ea9e..92bff083be 100644 --- a/packages/ai/test/github-copilot-anthropic.test.ts +++ b/packages/ai/test/github-copilot-anthropic.test.ts @@ -66,24 +66,19 @@ describe("Copilot Claude via Anthropic Messages", () => { const opts = mockState.constructorOpts!; expect(opts).toBeDefined(); - // Auth: apiKey null, authToken for Bearer expect(opts.apiKey).toBeNull(); expect(opts.authToken).toBe("tid_copilot_session_test_token"); const headers = opts.defaultHeaders as Record; - // Copilot static headers from model.headers expect(headers["User-Agent"]).toContain("GitHubCopilotChat"); expect(headers["Copilot-Integration-Id"]).toBe("vscode-chat"); - // Dynamic headers expect(headers["X-Initiator"]).toBe("user"); expect(headers["Openai-Intent"]).toBe("conversation-edits"); - // No fine-grained-tool-streaming (Copilot doesn't support it) const beta = headers["anthropic-beta"] ?? ""; expect(beta).not.toContain("fine-grained-tool-streaming"); - // Payload is valid Anthropic Messages format const params = mockState.createParams!; expect(params.model).toBe("claude-sonnet-4.5"); expect(params.stream).toBe(true); diff --git a/packages/ai/test/google-thinking-signature.test.ts b/packages/ai/test/google-thinking-signature.test.ts index c90f7caae6..daf6b28f7d 100644 --- a/packages/ai/test/google-thinking-signature.test.ts +++ b/packages/ai/test/google-thinking-signature.test.ts @@ -8,9 +8,6 @@ describe("Google thinking detection (thoughtSignature)", () => { }); it("does not treat thoughtSignature alone as thinking", () => { - // Per Google docs, thoughtSignature is for context replay and can appear on any part type. - // Only thought === true indicates thinking content. - // See: https://ai.google.dev/gemini-api/docs/thought-signatures expect(isThinkingPart({ thought: undefined, thoughtSignature: "opaque-signature" })).toBe(false); expect(isThinkingPart({ thought: false, thoughtSignature: "opaque-signature" })).toBe(false); }); diff --git a/packages/ai/test/image-tool-result.test.ts b/packages/ai/test/image-tool-result.test.ts index e467a478b6..3f06cecb82 100644 --- a/packages/ai/test/image-tool-result.test.ts +++ b/packages/ai/test/image-tool-result.test.ts @@ -13,7 +13,6 @@ import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-u import { hasBedrockCredentials } from "./bedrock-utils.js"; import { resolveApiKey } from "./oauth.js"; -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ resolveApiKey("anthropic"), resolveApiKey("github-copilot"), @@ -21,26 +20,16 @@ const oauthTokens = await Promise.all([ ]); const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens; -/** - * Test that tool results containing only images work correctly across all providers. - * This verifies that: - * 1. Tool results can contain image content blocks - * 2. Providers correctly pass images from tool results to the LLM - * 3. The LLM can see and describe images returned by tools - */ async function handleToolWithImageResult(model: Model, options?: StreamOptionsWithExtras) { - // Check if the model supports images if (!model.input.includes("image")) { console.log(`Skipping tool image result test - model ${model.id} doesn't support images`); return; } - // Read the test image const imagePath = join(__dirname, "data", "red-circle.png"); const imageBuffer = readFileSync(imagePath); const base64Image = imageBuffer.toString("base64"); - // Define a tool that returns only an image (no text) const getImageSchema = Type.Object({}); const getImageTool: Tool = { name: "get_circle", @@ -60,11 +49,9 @@ async function handleToolWithImageResult(model: Model, o tools: [getImageTool], }; - // First request - LLM should call the tool const firstResponse = await complete(model, context, options); expect(firstResponse.stopReason).toBe("toolUse"); - // Find the tool call const toolCall = firstResponse.content.find((b) => b.type === "toolCall"); expect(toolCall).toBeTruthy(); if (!toolCall || toolCall.type !== "toolCall") { @@ -72,10 +59,8 @@ async function handleToolWithImageResult(model: Model, o } expect(toolCall.name).toBe("get_circle"); - // Add the tool call to context context.messages.push(firstResponse); - // Create tool result with ONLY an image (no text) const toolResult: ToolResultMessage = { role: "toolResult", toolCallId: toolCall.id, @@ -93,45 +78,32 @@ async function handleToolWithImageResult(model: Model, o context.messages.push(toolResult); - // Second request - LLM should describe the image from the tool result const secondResponse = await complete(model, context, options); expect(secondResponse.stopReason).toBe("stop"); expect(secondResponse.errorMessage).toBeFalsy(); - // Verify the LLM can see and describe the image const textContent = secondResponse.content.find((b) => b.type === "text"); expect(textContent).toBeTruthy(); if (textContent && textContent.type === "text") { const lowerContent = textContent.text.toLowerCase(); - // Should mention red and circle since that's what the image shows expect(lowerContent).toContain("red"); expect(lowerContent).toContain("circle"); } } -/** - * Test that tool results containing both text and images work correctly across all providers. - * This verifies that: - * 1. Tool results can contain mixed content blocks (text + images) - * 2. Providers correctly pass both text and images from tool results to the LLM - * 3. The LLM can see both the text and images in tool results - */ async function handleToolWithTextAndImageResult( model: Model, options?: StreamOptionsWithExtras, ) { - // Check if the model supports images if (!model.input.includes("image")) { console.log(`Skipping tool text+image result test - model ${model.id} doesn't support images`); return; } - // Read the test image const imagePath = join(__dirname, "data", "red-circle.png"); const imageBuffer = readFileSync(imagePath); const base64Image = imageBuffer.toString("base64"); - // Define a tool that returns both text and an image const getImageSchema = Type.Object({}); const getImageTool: Tool = { name: "get_circle_with_description", @@ -152,11 +124,9 @@ async function handleToolWithTextAndImageResult( tools: [getImageTool], }; - // First request - LLM should call the tool const firstResponse = await complete(model, context, options); expect(firstResponse.stopReason).toBe("toolUse"); - // Find the tool call const toolCall = firstResponse.content.find((b) => b.type === "toolCall"); expect(toolCall).toBeTruthy(); if (!toolCall || toolCall.type !== "toolCall") { @@ -164,10 +134,8 @@ async function handleToolWithTextAndImageResult( } expect(toolCall.name).toBe("get_circle_with_description"); - // Add the tool call to context context.messages.push(firstResponse); - // Create tool result with BOTH text and image const toolResult: ToolResultMessage = { role: "toolResult", toolCallId: toolCall.id, @@ -189,19 +157,15 @@ async function handleToolWithTextAndImageResult( context.messages.push(toolResult); - // Second request - LLM should describe both the text and image from the tool result const secondResponse = await complete(model, context, options); expect(secondResponse.stopReason).toBe("stop"); expect(secondResponse.errorMessage).toBeFalsy(); - // Verify the LLM can see both text and image const textContent = secondResponse.content.find((b) => b.type === "text"); expect(textContent).toBeTruthy(); if (textContent && textContent.type === "text") { const lowerContent = textContent.text.toLowerCase(); - // Should mention details from the text (diameter/pixels) expect(lowerContent.match(/diameter|100|pixel/)).toBeTruthy(); - // Should also mention the visual properties (red and circle) expect(lowerContent).toContain("red"); expect(lowerContent).toContain("circle"); } @@ -306,13 +270,8 @@ describe("Tool Results with Images", () => { await handleToolWithImageResult(llm); }); - // FIXME(xiaomi): when a tool_result contains both a descriptive text block - // and an image block, MiMo locks onto the text and ignores the image (it - // reports the text-derived diameter but never mentions the image's color). - // The image-only case above proves the image reaches the model, and the - // text-only path obviously works, so this is a multimodal-fusion quality - // issue in the model, not a transport bug. Re-enable when upstream model - // quality improves. + // MiMo ignores image content when paired with descriptive text, although its + // image-only path is covered above, so this model-quality limitation stays skipped. it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithTextAndImageResult(llm); }); @@ -327,8 +286,7 @@ describe("Tool Results with Images", () => { await handleToolWithImageResult(llm); }); - // FIXME(xiaomi): see the API-billing block above — same multimodal-fusion - // limitation applies to Token Plan endpoints (same model behind both). + // Same MiMo multimodal-fusion limitation as the API-billing route. it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithTextAndImageResult(llm); }); @@ -344,8 +302,7 @@ describe("Tool Results with Images", () => { await handleToolWithImageResult(llm); }); - // FIXME(xiaomi): see the API-billing block above — same multimodal-fusion - // limitation applies to Token Plan endpoints (same model behind both). + // Same MiMo multimodal-fusion limitation as the API-billing route. it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithTextAndImageResult(llm); }); @@ -361,8 +318,7 @@ describe("Tool Results with Images", () => { await handleToolWithImageResult(llm); }); - // FIXME(xiaomi): see the API-billing block above — same multimodal-fusion - // limitation applies to Token Plan endpoints (same model behind both). + // Same MiMo multimodal-fusion limitation as the API-billing route. it.skip("should handle tool result with text and image", { retry: 3, timeout: 30000 }, async () => { await handleToolWithTextAndImageResult(llm); }); @@ -405,10 +361,6 @@ describe("Tool Results with Images", () => { }); }); - // ========================================================================= - // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) - // ========================================================================= - describe("Anthropic OAuth Provider (claude-sonnet-4-5)", () => { const model = getModel("anthropic", "claude-sonnet-4-5"); diff --git a/packages/ai/test/mcp-oauth.test.ts b/packages/ai/test/mcp-oauth.test.ts index 298915a540..e35f2a009d 100644 --- a/packages/ai/test/mcp-oauth.test.ts +++ b/packages/ai/test/mcp-oauth.test.ts @@ -1,8 +1,9 @@ +import { createServer } from "node:http"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMcpOAuthProvider } from "../src/mcp/oauth.js"; -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +function jsonResponse(body: unknown, status = 200, headers?: Record): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", ...headers } }); } function urlOf(input: unknown): string { @@ -12,151 +13,435 @@ function urlOf(input: unknown): string { throw new Error(`Unsupported fetch input: ${String(input)}`); } -const META = { - issuer: "https://srv.test", +const RESOURCE = "https://mcp.plane.so/http/mcp"; +const PLANE_ISSUER = "https://mcp.plane.so/http"; +const PLANE_PRM_URL = "https://mcp.plane.so/.well-known/oauth-protected-resource/http/mcp"; +const PLANE_META_URL = "https://mcp.plane.so/.well-known/oauth-authorization-server/http"; +const PLANE_META = { + issuer: PLANE_ISSUER, + authorization_endpoint: "https://mcp.plane.so/http/authorize", + token_endpoint: "https://mcp.plane.so/http/token", + registration_endpoint: "https://mcp.plane.so/http/register", + scopes_supported: ["read", "write"], +}; +const ORIGIN_URL = "https://srv.test/mcp"; +const ORIGIN_META = { + issuer: "https://srv.test/tenant", authorization_endpoint: "https://srv.test/authorize", token_endpoint: "https://srv.test/token", registration_endpoint: "https://srv.test/register", scopes_supported: ["read", "write"], }; +function absentPrm(input: unknown): Response | undefined { + const url = urlOf(input); + if (url === ORIGIN_URL) return new Response("", { status: 404 }); + if (url === "https://srv.test/.well-known/oauth-protected-resource/mcp") return new Response("", { status: 404 }); + if (url === "https://srv.test/.well-known/oauth-protected-resource") return new Response("", { status: 404 }); + return undefined; +} + +async function loginWithManualCode( + provider: ReturnType, +): Promise<{ creds: object; authUrl: string }> { + let authUrl = ""; + const creds = await provider.login({ + onAuth: (info) => { + authUrl = info.url; + }, + onPrompt: async () => "", + onManualCodeInput: async () => { + const params = new URL(authUrl).searchParams; + return `${params.get("redirect_uri")}?code=the-code&state=${params.get("state")}`; + }, + }); + return { creds, authUrl }; +} + describe.sequential("MCP OAuth provider", () => { afterEach(() => { vi.unstubAllGlobals(); }); it("has a namespaced id and label", () => { - const provider = createMcpOAuthProvider({ server: "linear", label: "Linear", url: "https://srv.test/mcp" }); + const provider = createMcpOAuthProvider({ server: "linear", label: "Linear", url: ORIGIN_URL }); expect(provider.id).toBe("mcp:linear"); expect(provider.name).toBe("Linear"); expect(provider.usesCallbackServer).toBe(true); }); - it("discovers, registers a client, and exchanges the code for tokens", async () => { - let authUrl = ""; + it("discovers Plane protected-resource metadata and its external pathful issuer", async () => { const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { const url = urlOf(input); - if (url.endsWith("/.well-known/oauth-authorization-server")) return jsonResponse(META); - if (url === META.registration_endpoint) return jsonResponse({ client_id: "client-xyz" }); - if (url === META.token_endpoint) { + if (url === RESOURCE) { + expect(init?.headers).toBeUndefined(); + return new Response("", { status: 401 }); + } + if (url === PLANE_PRM_URL) return jsonResponse({ resource: RESOURCE, authorization_servers: [PLANE_ISSUER] }); + if (url === PLANE_META_URL) return jsonResponse(PLANE_META); + if (url === PLANE_META.registration_endpoint) { + expect(init?.redirect).toBe("error"); + return jsonResponse({ client_id: "plane-client" }); + } + if (url === PLANE_META.token_endpoint) { + expect(init?.redirect).toBe("error"); const params = new URLSearchParams(String(init?.body)); expect(params.get("grant_type")).toBe("authorization_code"); - expect(params.get("client_id")).toBe("client-xyz"); - expect(params.get("code")).toBe("the-code"); - expect(params.get("code_verifier")).toBeTruthy(); + expect(params.get("resource")).toBe(RESOURCE); return jsonResponse({ access_token: "access-1", refresh_token: "refresh-1", expires_in: 3600 }); } throw new Error(`unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); - const provider = createMcpOAuthProvider({ server: "demo", url: "https://srv.test/mcp" }); - const creds = await provider.login({ - onAuth: (info) => { - authUrl = info.url; - }, - onPrompt: async () => "", - // Headless: supply the redirect URL via the manual-input path, which - // races (and wins against) the local callback server. - onManualCodeInput: async () => { - const state = new URL(authUrl).searchParams.get("state") ?? ""; - return `${REDIRECT}?code=the-code&state=${state}`; - }, + const { creds, authUrl } = await loginWithManualCode(createMcpOAuthProvider({ server: "plane", url: RESOURCE })); + expect(creds).toMatchObject({ + access: "access-1", + endpoint: RESOURCE, + resource: RESOURCE, + issuer: PLANE_ISSUER, + tokenEndpoint: PLANE_META.token_endpoint, }); - - expect(creds.access).toBe("access-1"); - expect(creds.refresh).toBe("refresh-1"); - expect(creds.expires).toBeGreaterThan(Date.now()); - // auth URL carries PKCE challenge + registered client id const authParams = new URL(authUrl).searchParams; - expect(authParams.get("client_id")).toBe("client-xyz"); - expect(authParams.get("code_challenge")).toBeTruthy(); + expect(authParams.get("client_id")).toBe("plane-client"); + expect(authParams.get("resource")).toBe(RESOURCE); expect(authParams.get("scope")).toBe("read write"); + expect(fetchMock).toHaveBeenCalledWith(RESOURCE, expect.objectContaining({ redirect: "error" })); }); - it("falls back to the next port when the base callback port is in use", async () => { - const http = await import("node:http"); - // Occupy the base callback port. If something already holds it (e.g. a stray - // local daemon), that satisfies the precondition too — bind best-effort. - const blocker = http.createServer(); - const blockerBound = await new Promise((resolve) => { - blocker.once("error", () => resolve(false)); - blocker.listen(53700, "127.0.0.1", () => resolve(true)); - }); - try { - let authUrl = ""; - vi.stubGlobal( - "fetch", - vi.fn(async (input: unknown): Promise => { - const url = urlOf(input); - if (url.endsWith("/.well-known/oauth-authorization-server")) return jsonResponse(META); - if (url === META.registration_endpoint) return jsonResponse({ client_id: "c" }); - if (url === META.token_endpoint) return jsonResponse({ access_token: "a", expires_in: 60 }); - throw new Error(`unexpected fetch: ${url}`); - }), - ); - const provider = createMcpOAuthProvider({ server: "demo", url: "https://srv.test/mcp" }); - const creds = await provider.login({ - onAuth: (info) => { - authUrl = info.url; - }, + it("uses pathful OIDC metadata when RFC 8414 returns a non-metadata document", async () => { + const issuer = "https://login.example/tenant"; + const oidcMeta = "https://login.example/tenant/.well-known/openid-configuration"; + const metadata = { + ...PLANE_META, + issuer, + authorization_endpoint: "https://login.example/tenant/authorize", + token_endpoint: "https://login.example/tenant/token", + registration_endpoint: "https://login.example/tenant/register", + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = urlOf(input); + if (url === RESOURCE) return new Response("", { status: 404 }); + if (url === PLANE_PRM_URL) return jsonResponse({ resource: RESOURCE, authorization_servers: [issuer] }); + if (url === "https://login.example/.well-known/oauth-authorization-server/tenant") + return new Response("not metadata", { + status: 200, + headers: { "Content-Type": "text/html" }, + }); + if (url === oidcMeta) return jsonResponse(metadata); + if (url === metadata.registration_endpoint) return jsonResponse({ client_id: "c" }); + if (url === metadata.token_endpoint) return jsonResponse({ access_token: "a", expires_in: 60 }); + throw new Error(`unexpected fetch: ${url}`); + }), + ); + const { creds } = await loginWithManualCode(createMcpOAuthProvider({ server: "plane", url: RESOURCE })); + expect(creds).toMatchObject({ resource: RESOURCE, issuer }); + }); + + it("fails closed after protected-resource metadata selects an issuer", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = urlOf(input); + if (url === RESOURCE) + return new Response("", { + status: 401, + headers: { "WWW-Authenticate": `Bearer resource_metadata="${PLANE_PRM_URL}"` }, + }); + if (url === PLANE_PRM_URL) + return jsonResponse({ resource: RESOURCE, authorization_servers: [PLANE_ISSUER] }); + if (url === PLANE_META_URL) return jsonResponse({ ...PLANE_META, issuer: "https://wrong.example" }); + if (url === "https://mcp.plane.so/http/.well-known/openid-configuration") + return new Response("", { status: 404 }); + throw new Error(`unexpected fetch: ${url}`); + }), + ); + await expect( + createMcpOAuthProvider({ server: "plane", url: RESOURCE }).login({ + onAuth: () => {}, onPrompt: async () => "", - onManualCodeInput: async () => { - const p = new URL(authUrl).searchParams; - return `${p.get("redirect_uri")}?code=x&state=${p.get("state")}`; - }, - }); - expect(creds.access).toBe("a"); - // Did NOT use the blocked base port. - const redirect = new URL(authUrl).searchParams.get("redirect_uri") ?? ""; - expect(redirect).not.toContain(":53700/"); - expect(redirect).toContain(":5370"); - } finally { - if (blockerBound) await new Promise((resolve) => blocker.close(() => resolve())); - } + }), + ).rejects.toThrow("issuer does not exactly match"); }); - it("refreshes tokens, keeping the prior refresh token when omitted", async () => { + it("accepts a same-origin pathful issuer from origin-level metadata when protected-resource metadata is absent", async () => { const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const missing = absentPrm(input); + if (missing) return missing; const url = urlOf(input); - if (url === META.token_endpoint) { + if (url === "https://srv.test/.well-known/oauth-authorization-server") return jsonResponse(ORIGIN_META); + if (url === ORIGIN_META.registration_endpoint) return jsonResponse({ client_id: "origin-client" }); + if (url === ORIGIN_META.token_endpoint) { const params = new URLSearchParams(String(init?.body)); - expect(params.get("grant_type")).toBe("refresh_token"); - expect(params.get("refresh_token")).toBe("old-refresh"); - return jsonResponse({ access_token: "access-2", expires_in: 1800 }); + expect(params.get("resource")).toBeNull(); + return jsonResponse({ access_token: "origin-access", refresh_token: "origin-refresh", expires_in: 3600 }); } throw new Error(`unexpected fetch: ${url}`); }); vi.stubGlobal("fetch", fetchMock); + const { creds, authUrl } = await loginWithManualCode( + createMcpOAuthProvider({ server: "origin", url: ORIGIN_URL }), + ); + expect(creds).toMatchObject({ + access: "origin-access", + endpoint: ORIGIN_URL, + resource: undefined, + issuer: undefined, + }); + expect(new URL(authUrl).searchParams.get("resource")).toBeNull(); + expect(fetchMock.mock.calls.map(([input]) => urlOf(input))).not.toContain( + "https://srv.test/.well-known/oauth-protected-resource", + ); + }); - const provider = createMcpOAuthProvider({ server: "demo", url: "https://srv.test/mcp" }); + it("validates refresh binding and retains the protected-resource resource indicator", async () => { + const fetchMock = vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const url = urlOf(input); + if (url === RESOURCE) + return new Response("", { + status: 401, + headers: { "WWW-Authenticate": `Bearer resource_metadata="${PLANE_PRM_URL}"` }, + }); + if (url === PLANE_PRM_URL) return jsonResponse({ resource: RESOURCE, authorization_servers: [PLANE_ISSUER] }); + if (url === PLANE_META_URL) return jsonResponse(PLANE_META); + if (url === PLANE_META.token_endpoint) { + const params = new URLSearchParams(String(init?.body)); + expect(params.get("resource")).toBe(RESOURCE); + return jsonResponse({ access_token: "access-2", expires_in: 1800 }); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const provider = createMcpOAuthProvider({ server: "plane", url: RESOURCE }); const refreshed = await provider.refreshToken({ access: "access-1", refresh: "old-refresh", - expires: Date.now() - 1000, - tokenEndpoint: META.token_endpoint, + expires: 0, + endpoint: RESOURCE, + resource: RESOURCE, + issuer: PLANE_ISSUER, + tokenEndpoint: PLANE_META.token_endpoint, clientId: "client-xyz", } as never); + expect(refreshed).toMatchObject({ + access: "access-2", + refresh: "old-refresh", + endpoint: RESOURCE, + resource: RESOURCE, + issuer: PLANE_ISSUER, + }); + await expect( + provider.refreshToken({ access: "a", refresh: "r", expires: 0, endpoint: "https://other.test/mcp" } as never), + ).rejects.toThrow("not bound"); + await expect( + provider.refreshToken({ + access: "a", + refresh: "r", + expires: 0, + endpoint: RESOURCE, + resource: RESOURCE, + issuer: PLANE_ISSUER, + tokenEndpoint: "https://attacker.example/token", + clientId: "client-xyz", + } as never), + ).rejects.toThrow("token endpoint does not match"); + expect(fetchMock.mock.calls.map(([input]) => urlOf(input))).not.toContain("https://attacker.example/token"); + }); - expect(refreshed.access).toBe("access-2"); - expect(refreshed.refresh).toBe("old-refresh"); + it("keeps an origin-level resource identifier free of a synthetic trailing slash", async () => { + const resource = "https://root.example"; + const prm = "https://root.example/.well-known/oauth-protected-resource"; + const issuer = "https://root.example"; + const asMetadata = "https://root.example/.well-known/oauth-authorization-server"; + const metadata = { + issuer, + authorization_endpoint: "https://root.example/authorize", + token_endpoint: "https://root.example/token", + }; + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = urlOf(input); + if (url === "https://root.example/") return new Response("", { status: 401 }); + if (url === prm) return jsonResponse({ resource, authorization_servers: [issuer] }); + if (url === asMetadata) return jsonResponse(metadata); + if (url === metadata.token_endpoint) return jsonResponse({ access_token: "root-access" }); + throw new Error(`unexpected fetch: ${url}`); + }), + ); + const { creds, authUrl } = await loginWithManualCode( + createMcpOAuthProvider({ server: "root", url: resource, clientId: "root-client" }), + ); + expect(creds).toMatchObject({ endpoint: resource, resource, issuer }); + expect(new URL(authUrl).searchParams.get("resource")).toBe(resource); + }); + + it("preserves the resource query in RFC 9728 discovery and never probes root metadata", async () => { + const resource = "https://mcp.example/mcp?tenant=a"; + const prm = "https://mcp.example/.well-known/oauth-protected-resource/mcp?tenant=a"; + const issuer = "https://login.example/tenant"; + const asMetadata = "https://login.example/.well-known/oauth-authorization-server/tenant"; + const metadata = { + issuer, + authorization_endpoint: "https://login.example/tenant/authorize", + token_endpoint: "https://login.example/tenant/token", + }; + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = urlOf(input); + if (url === resource) return new Response("", { status: 401 }); + if (url === prm) return jsonResponse({ resource, authorization_servers: [issuer] }); + if (url === asMetadata) return jsonResponse(metadata); + if (url === metadata.token_endpoint) return jsonResponse({ access_token: "query-access" }); + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + const { creds } = await loginWithManualCode( + createMcpOAuthProvider({ server: "query", url: resource, clientId: "query-client" }), + ); + expect(creds).toMatchObject({ resource, issuer }); + expect(fetchMock.mock.calls.map(([input]) => urlOf(input))).not.toContain( + "https://mcp.example/.well-known/oauth-protected-resource", + ); + }); + + it("requires re-login when refresh discovery changes from origin-only to resource-bound", async () => { + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = urlOf(input); + if (url === RESOURCE) return new Response("", { status: 401 }); + if (url === PLANE_PRM_URL) return jsonResponse({ resource: RESOURCE, authorization_servers: [PLANE_ISSUER] }); + if (url === PLANE_META_URL) return jsonResponse(PLANE_META); + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + await expect( + createMcpOAuthProvider({ server: "plane", url: RESOURCE }).refreshToken({ + access: "origin-access", + refresh: "origin-refresh", + expires: 0, + endpoint: RESOURCE, + tokenEndpoint: PLANE_META.token_endpoint, + clientId: "origin-client", + } as never), + ).rejects.toThrow("discovery mode changed"); + expect(fetchMock.mock.calls.map(([input]) => urlOf(input))).not.toContain(PLANE_META.token_endpoint); }); - it("fails clearly when DCR is unavailable and no clientId is set", async () => { - const noReg = { ...META, registration_endpoint: undefined }; + it("rejects a redirected token POST", async () => { vi.stubGlobal( "fetch", - vi.fn(async (input: unknown) => { + vi.fn(async (input: unknown, init?: RequestInit): Promise => { + const missing = absentPrm(input); + if (missing) return missing; const url = urlOf(input); - if (url.endsWith("/.well-known/oauth-authorization-server")) return jsonResponse(noReg); + if (url === "https://srv.test/.well-known/oauth-authorization-server") return jsonResponse(ORIGIN_META); + if (url === ORIGIN_META.token_endpoint) { + expect(init?.redirect).toBe("error"); + return new Response("redirect", { status: 302, headers: { Location: "https://evil.test/token" } }); + } throw new Error(`unexpected fetch: ${url}`); }), ); - const provider = createMcpOAuthProvider({ server: "slackish", url: "https://srv.test/mcp" }); - await expect(provider.login({ onAuth: () => {}, onPrompt: async () => "" })).rejects.toThrow( - "dynamic client registration", + const provider = createMcpOAuthProvider({ server: "origin", url: ORIGIN_URL, clientId: "c" }); + await expect( + provider.refreshToken({ + access: "a", + refresh: "r", + expires: 0, + endpoint: ORIGIN_URL, + tokenEndpoint: ORIGIN_META.token_endpoint, + } as never), + ).rejects.toThrow("Token request"); + }); + + it("uses a WWW-Authenticate resource_metadata pointer before derived locations", async () => { + const pointer = "https://metadata.example/resources/plane"; + const fetchMock = vi.fn(async (input: unknown): Promise => { + const url = urlOf(input); + if (url === RESOURCE) + return new Response("", { + status: 401, + headers: { "WWW-Authenticate": `Bearer realm="mcp", resource_metadata="${pointer}"` }, + }); + if (url === pointer) return jsonResponse({ resource: RESOURCE, authorization_servers: [PLANE_ISSUER] }); + if (url === PLANE_META_URL) return jsonResponse(PLANE_META); + if (url === PLANE_META.registration_endpoint) return jsonResponse({ client_id: "pointer-client" }); + if (url === PLANE_META.token_endpoint) return jsonResponse({ access_token: "pointer-access" }); + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + const { creds } = await loginWithManualCode(createMcpOAuthProvider({ server: "plane", url: RESOURCE })); + expect(creds).toMatchObject({ access: "pointer-access", resource: RESOURCE, issuer: PLANE_ISSUER }); + expect(fetchMock.mock.calls.map(([input]) => urlOf(input))).not.toContain(PLANE_PRM_URL); + }); + + it("rejects protected-resource metadata for a different resource", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const url = urlOf(input); + if (url === RESOURCE) return new Response("", { status: 401 }); + if (url === PLANE_PRM_URL) + return jsonResponse({ resource: "https://attacker.example/mcp", authorization_servers: [PLANE_ISSUER] }); + throw new Error(`unexpected fetch: ${url}`); + }), + ); + + await expect( + createMcpOAuthProvider({ server: "plane", url: RESOURCE }).login({ + onAuth: () => {}, + onPrompt: async () => "", + }), + ).rejects.toThrow("resource does not exactly match"); + }); + + it("falls back to the next callback port when the base port is occupied", async () => { + const blocker = createServer(); + const blockerBound = await new Promise((resolve) => { + blocker.once("error", () => resolve(false)); + blocker.listen(53700, "127.0.0.1", () => resolve(true)); + }); + try { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const missing = absentPrm(input); + if (missing) return missing; + const url = urlOf(input); + if (url === "https://srv.test/.well-known/oauth-authorization-server") return jsonResponse(ORIGIN_META); + if (url === ORIGIN_META.registration_endpoint) return jsonResponse({ client_id: "c" }); + if (url === ORIGIN_META.token_endpoint) return jsonResponse({ access_token: "a", expires_in: 60 }); + throw new Error(`unexpected fetch: ${url}`); + }), + ); + const { authUrl } = await loginWithManualCode(createMcpOAuthProvider({ server: "demo", url: ORIGIN_URL })); + const redirect = new URL(authUrl).searchParams.get("redirect_uri") ?? ""; + expect(redirect).not.toContain(":53700/"); + expect(redirect).toContain(":5370"); + } finally { + if (blockerBound) await new Promise((resolve) => blocker.close(() => resolve())); + } + }); + + it("fails clearly when dynamic client registration is unavailable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (input: unknown): Promise => { + const missing = absentPrm(input); + if (missing) return missing; + const url = urlOf(input); + if (url === "https://srv.test/.well-known/oauth-authorization-server") + return jsonResponse({ ...ORIGIN_META, registration_endpoint: undefined }); + throw new Error(`unexpected fetch: ${url}`); + }), ); + await expect( + createMcpOAuthProvider({ server: "slackish", url: ORIGIN_URL }).login({ + onAuth: () => {}, + onPrompt: async () => "", + }), + ).rejects.toThrow("dynamic client registration"); }); }); - -const REDIRECT = `http://localhost:${process.env.PI_MCP_OAUTH_CALLBACK_PORT || 53700}/callback`; diff --git a/packages/ai/test/oauth.ts b/packages/ai/test/oauth.ts index bd32f3d67e..ade53377cc 100644 --- a/packages/ai/test/oauth.ts +++ b/packages/ai/test/oauth.ts @@ -1,10 +1,3 @@ -/** - * Test helper for resolving API keys from ~/.pi/agent/auth.json - * - * Supports both API key and OAuth credentials. - * OAuth tokens are automatically refreshed if expired and saved back to auth.json. - */ - import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { homedir } from "os"; import { dirname, join } from "path"; @@ -47,13 +40,6 @@ function saveAuthStorage(storage: AuthStorage): void { chmodSync(AUTH_PATH, 0o600); } -/** - * Resolve API key for a provider from ~/.pi/agent/auth.json - * - * For API key credentials, returns the key directly. - * For OAuth credentials, returns the access token (refreshing if expired and saving back). - * - */ export async function resolveApiKey(provider: string): Promise { const storage = loadAuthStorage(); const entry = storage[provider]; @@ -65,7 +51,6 @@ export async function resolveApiKey(provider: string): Promise = {}; for (const [key, value] of Object.entries(storage)) { if (value.type === "oauth") { @@ -77,7 +62,6 @@ export async function resolveApiKey(provider: string): Promise { } if (url === "https://chatgpt.com/backend-api/codex/responses") { const headers = init?.headers instanceof Headers ? init.headers : undefined; - // Verify sessionId is set in headers expect(headers?.get("session_id")).toBe(sessionId); expect(headers?.get("x-client-request-id")).toBe(sessionId); - // Verify sessionId is set in request body as prompt_cache_key const body = typeof init?.body === "string" ? (JSON.parse(init.body) as Record) : null; expect(body?.prompt_cache_key).toBe(sessionId); @@ -569,7 +567,7 @@ describe("openai-codex streaming", () => { ["gpt-5.4", "priority", 2], ["gpt-5.5", "flex", 0.5], ["gpt-5.5", "priority", 2.5], - ["gpt-5.6-sol", "priority", 2.5], + ["gpt-5.6-sol", "priority", 2], ] as const)( "uses the client-sent %s service tier for %s when Codex echoes default", async (modelId, serviceTier, multiplier) => { @@ -720,7 +718,6 @@ describe("openai-codex streaming", () => { } if (url === "https://chatgpt.com/backend-api/codex/responses") { const headers = init?.headers instanceof Headers ? init.headers : undefined; - // Verify headers are not set when sessionId is not provided expect(headers?.has("session_id")).toBe(false); expect(headers?.has("x-client-request-id")).toBe(false); @@ -752,7 +749,6 @@ describe("openai-codex streaming", () => { messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }], }; - // No sessionId provided const streamResult = streamOpenAICodexResponses(model, context, { apiKey: token }); await streamResult.result(); }); diff --git a/packages/ai/test/openai-completions-cache-control-format.test.ts b/packages/ai/test/openai-completions-cache-control-format.test.ts index f1ff9a5c7b..9b6f66c46a 100644 --- a/packages/ai/test/openai-completions-cache-control-format.test.ts +++ b/packages/ai/test/openai-completions-cache-control-format.test.ts @@ -2,7 +2,7 @@ import { Type } from "typebox"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { getModel } from "../src/models.js"; import { streamOpenAICompletions } from "../src/providers/openai-completions.js"; -import type { AssistantMessage, Model } from "../src/types.js"; +import type { AssistantMessage, Context, Model, Usage } from "../src/types.js"; interface CacheControl { type: "ephemeral"; @@ -32,6 +32,15 @@ const mockState = vi.hoisted(() => ({ lastParams: undefined as CapturedParams | undefined, })); +const emptyUsage: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, +}; + vi.mock("openai", () => { class FakeOpenAI { chat = { @@ -78,6 +87,7 @@ vi.mock("openai", () => { async function runCompletion( model: Model<"openai-completions">, options?: { cacheRetention?: "none" | "short" | "long" }, + messages?: Context["messages"], ): Promise<{ params: CapturedParams; result: AssistantMessage }> { const timestamp = Date.now(); @@ -85,7 +95,7 @@ async function runCompletion( model, { systemPrompt: "System prompt", - messages: [{ role: "user", content: "Hello", timestamp }], + messages: messages ?? [{ role: "user", content: "Hello", timestamp }], tools: [ { name: "read", @@ -169,6 +179,38 @@ describe("openai-completions cacheControlFormat", () => { expect(result.usage.cost.cacheWrite).toBeCloseTo((80 * model.cost.cacheWrite) / 1_000_000); }); + it("advances the Anthropic cache marker to a tool result", async () => { + const model = getModel("prime-inference", "anthropic/claude-haiku-4.5"); + const now = Date.now(); + const messages: Context["messages"] = [ + { role: "user", content: "Read the file", timestamp: now }, + { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "read", arguments: { path: "file.txt" } }], + api: model.api, + provider: model.provider, + model: model.id, + usage: emptyUsage, + stopReason: "toolUse", + timestamp: now + 1, + }, + { + role: "toolResult", + toolCallId: "tool-1", + toolName: "read", + content: [{ type: "text", text: "file contents" }], + isError: false, + timestamp: now + 2, + }, + ]; + + const { params } = await runCompletion(model, undefined, messages); + expect(params.messages.at(-1)).toMatchObject({ + role: "tool", + content: [{ type: "text", text: "file contents", cache_control: { type: "ephemeral" } }], + }); + }); + it("preserves Anthropic-style cache markers for OpenRouter Anthropic models", async () => { const model = getModel("openrouter", "anthropic/claude-sonnet-4"); const params = await capturePayload(model); diff --git a/packages/ai/test/openai-completions-empty-tools.test.ts b/packages/ai/test/openai-completions-empty-tools.test.ts index 0fbdc7ab34..61a65c1df4 100644 --- a/packages/ai/test/openai-completions-empty-tools.test.ts +++ b/packages/ai/test/openai-completions-empty-tools.test.ts @@ -1,13 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getModel } from "../src/models.js"; +import { CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL } from "../src/providers/cloudflare.js"; import { streamSimple } from "../src/stream.js"; import type { Model } from "../src/types.js"; -// Empty tools arrays must NOT be serialized as `tools: []` — some OpenAI-compatible -// backends (e.g. DashScope / Aliyun Qwen via compatible-mode) reject the request with -// `"[] is too short - 'tools'"` (HTTP 400) when `--no-tools` produces an empty array. -// Regression for https://github.com/earendil-works/pi-mono/issues/ - const mockState = vi.hoisted(() => ({ lastParams: undefined as unknown, lastClientOptions: undefined as unknown, @@ -55,6 +51,13 @@ vi.mock("openai", () => { return { default: FakeOpenAI }; }); +const cloudflareGatewayCompatModel: Model<"openai-completions"> = { + ...getModel("cloudflare-workers-ai", "@cf/moonshotai/kimi-k2.6"), + provider: "cloudflare-ai-gateway", + baseUrl: CLOUDFLARE_AI_GATEWAY_COMPAT_BASE_URL, + id: "workers-ai/@cf/moonshotai/kimi-k2.6", +}; + describe("openai-completions empty tools handling", () => { beforeEach(() => { mockState.lastParams = undefined; @@ -97,7 +100,7 @@ describe("openai-completions empty tools handling", () => { it("uses conservative OpenAI-compatible fields for Cloudflare AI Gateway /compat models", async () => { process.env.CLOUDFLARE_ACCOUNT_ID = "account-id"; process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id"; - const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!; + const model = cloudflareGatewayCompatModel; await streamSimple( model, @@ -182,7 +185,7 @@ describe("openai-completions empty tools handling", () => { it("sends session affinity headers for Workers AI through Cloudflare AI Gateway", async () => { process.env.CLOUDFLARE_ACCOUNT_ID = "account-id"; process.env.CLOUDFLARE_GATEWAY_ID = "gateway-id"; - const workersModel = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6")!; + const workersModel = cloudflareGatewayCompatModel; await streamSimple( workersModel, diff --git a/packages/ai/test/openai-completions-reasoning-replay.test.ts b/packages/ai/test/openai-completions-reasoning-replay.test.ts index 7f641d4e7b..10d48a064f 100644 --- a/packages/ai/test/openai-completions-reasoning-replay.test.ts +++ b/packages/ai/test/openai-completions-reasoning-replay.test.ts @@ -11,7 +11,6 @@ const emptyUsage: Usage = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; -// requiresThinkingAsText: false -> exercises the native reasoning-field replay path. const compat = { supportsStore: true, supportsDeveloperRole: true, @@ -96,8 +95,6 @@ describe("openai-completions reasoning replay", () => { ); const assistant = messages[1] as unknown as Record; - // No recorded field and provider doesn't force reasoning_content: prepend as text so the - // trace is still sent back without inventing a non-standard field. expect(assistant.reasoning_content).toBeUndefined(); expect(assistant.content).toBe("unsigned reasoning\n\nanswer"); }); @@ -122,8 +119,6 @@ describe("openai-completions reasoning replay", () => { const reasoningCompat = { ...compat, requiresReasoningContentOnAssistantMessages: true }; const messages = convertMessages( buildModel(), - // Recorded signature is "reasoning", but a reasoning_content provider must get the - // trace in reasoning_content or the reasoning_content="" default would erase it. buildContext([ { type: "thinking", thinking: "step by step", thinkingSignature: "reasoning" }, { type: "text", text: "answer" }, @@ -139,7 +134,6 @@ describe("openai-completions reasoning replay", () => { const reasoningCompat = { ...compat, requiresReasoningContentOnAssistantMessages: true }; const messages = convertMessages( buildModel(), - // Lone high surrogate (U+D800) would break JSON serialization on the next turn. buildContext([ { type: "thinking", thinking: "before\ud800after" }, { type: "text", text: "answer" }, diff --git a/packages/ai/test/openai-completions-response-model.test.ts b/packages/ai/test/openai-completions-response-model.test.ts index 32d4edf72e..a7592b0eb0 100644 --- a/packages/ai/test/openai-completions-response-model.test.ts +++ b/packages/ai/test/openai-completions-response-model.test.ts @@ -2,9 +2,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { complete } from "../src/stream.js"; import type { Model } from "../src/types.js"; -// Router/virtual ids (e.g. OpenRouter `auto`) keep `model` pinned to the -// requested id and surface the routed concrete id on `responseModel`. - const mockState = vi.hoisted(() => ({ chunks: [] as unknown[], })); diff --git a/packages/ai/test/openai-completions-tool-choice.test.ts b/packages/ai/test/openai-completions-tool-choice.test.ts index bc6befd35e..64dd360b75 100644 --- a/packages/ai/test/openai-completions-tool-choice.test.ts +++ b/packages/ai/test/openai-completions-tool-choice.test.ts @@ -760,6 +760,179 @@ describe("openai-completions tool_choice", () => { expect(writeCall).not.toHaveProperty("partialArgs"); }); + it("round-trips opaque reasoning_details without a matching tool call id", async () => { + const details = [ + { + type: "reasoning.summary", + index: 0, + format: "unknown", + summary: "brief plan", + }, + { + type: "reasoning.encrypted", + index: 1, + format: "unknown", + id: "rs_not_a_tool_call", + data: "opaque-continuation", + }, + ]; + mockState.chunks = [ + { + id: "chatcmpl-reasoning-details", + choices: [{ delta: { reasoning_details: details }, finish_reason: "stop" }], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + const first = await streamSimple( + model, + { + messages: [{ role: "user", content: "Think privately.", timestamp: 1 }], + }, + { apiKey: "test" }, + ).result(); + const opaque = first.content.find((block) => block.type === "thinking" && block.redacted); + expect(opaque).toBeDefined(); + + mockState.chunks = [ + { + id: "chatcmpl-after-replay", + choices: [{ delta: { content: "done" }, finish_reason: "stop" }], + }, + ]; + await streamSimple( + model, + { + messages: [ + { role: "user", content: "Think privately.", timestamp: 1 }, + first, + { role: "user", content: "Continue.", timestamp: 2 }, + ], + }, + { apiKey: "test" }, + ).result(); + + const params = mockState.lastParams as { messages: Array> }; + expect(params.messages[1]?.reasoning_details).toEqual(details); + expect(params.messages[1]?.content).toBe(""); + }); + + it("keeps index-less reasoning details after explicitly indexed details", async () => { + const explicitDetail = { + type: "reasoning.summary", + index: 0, + format: "unknown", + summary: "brief plan", + }; + const indexlessDetail = { + type: "reasoning.encrypted", + format: "unknown", + id: "rs_indexless", + data: "opaque-continuation", + }; + mockState.chunks = [ + { + id: "chatcmpl-reasoning-index-order", + choices: [{ delta: { reasoning_details: [explicitDetail, indexlessDetail] }, finish_reason: "stop" }], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + const first = await streamSimple( + model, + { messages: [{ role: "user", content: "Think privately.", timestamp: 1 }] }, + { apiKey: "test" }, + ).result(); + + mockState.chunks = [ + { + id: "chatcmpl-after-index-replay", + choices: [{ delta: { content: "done" }, finish_reason: "stop" }], + }, + ]; + await streamSimple( + model, + { + messages: [ + { role: "user", content: "Think privately.", timestamp: 1 }, + first, + { role: "user", content: "Continue.", timestamp: 2 }, + ], + }, + { apiKey: "test" }, + ).result(); + + const params = mockState.lastParams as { messages: Array> }; + expect(params.messages[1]?.reasoning_details).toEqual([explicitDetail, indexlessDetail]); + }); + + it("concatenates same-index reasoning detail fragments", async () => { + mockState.chunks = [ + { + id: "chatcmpl-reasoning-fragments", + choices: [ + { + delta: { + reasoning_details: [ + { type: "reasoning.text", index: 0, format: "unknown", text: "first " }, + { type: "reasoning.summary", index: 1, format: "unknown", summary: "brief " }, + ], + }, + finish_reason: null, + }, + ], + }, + { + id: "chatcmpl-reasoning-fragments", + choices: [ + { + delta: { + reasoning_details: [ + { type: "reasoning.text", index: 0, text: "second" }, + { type: "reasoning.summary", index: 1, summary: "plan" }, + ], + }, + finish_reason: "stop", + }, + ], + }, + ]; + + const { compat: _compat, ...baseModel } = getModel("openai", "gpt-4o-mini")!; + const model = { ...baseModel, api: "openai-completions" } as const; + const first = await streamSimple( + model, + { messages: [{ role: "user", content: "Think privately.", timestamp: 1 }] }, + { apiKey: "test" }, + ).result(); + + mockState.chunks = [ + { + id: "chatcmpl-after-fragment-replay", + choices: [{ delta: { content: "done" }, finish_reason: "stop" }], + }, + ]; + await streamSimple( + model, + { + messages: [ + { role: "user", content: "Think privately.", timestamp: 1 }, + first, + { role: "user", content: "Continue.", timestamp: 2 }, + ], + }, + { apiKey: "test" }, + ).result(); + + const params = mockState.lastParams as { messages: Array> }; + expect(params.messages[1]?.reasoning_details).toEqual([ + { type: "reasoning.text", index: 0, format: "unknown", text: "first second" }, + { type: "reasoning.summary", index: 1, format: "unknown", summary: "brief plan" }, + ]); + }); + it("does not double-count reasoning tokens in completion usage", async () => { mockState.chunks = [ { diff --git a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts index 27a55c4662..7035712623 100644 --- a/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts +++ b/packages/ai/test/openai-responses-reasoning-replay-e2e.test.ts @@ -74,22 +74,12 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( reasoningEffort: "high", }); - // The key assertion: no 400 error from orphaned reasoning item expect(response.stopReason, `Error: ${response.errorMessage}`).not.toBe("error"); expect(response.errorMessage).toBeFalsy(); - // Model should respond (text or tool call) expect(response.content.length).toBeGreaterThan(0); }); it("handles same-provider different-model handoff with tool calls", { retry: 2 }, async () => { - // This tests the scenario where: - // 1. Model A (gpt-5-mini) generates reasoning + function_call - // 2. User switches to Model B (gpt-5.4) - same provider, different model - // 3. transform-messages: isSameModel=false, thinking converted to text - // 4. But tool call ID still has OpenAI pairing history (fc_xxx paired with rs_xxx) - // 5. Without fix: OpenAI returns 400 "function_call without required reasoning item" - // 6. With fix: tool calls/results converted to text, conversation continues - const modelA = getModel("openai", "gpt-5-mini"); const modelB = getModel("openai", "gpt-5.4"); @@ -104,7 +94,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( timestamp: Date.now(), }; - // Get a real response from Model A with reasoning + tool call const assistantResponse = await complete( modelA, { @@ -126,7 +115,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( throw new Error("Missing tool call from OpenAI Responses - model did not use the tool"); } - // Provide a tool result const toolResult: Message = { role: "toolResult", toolCallId: toolCallBlock.id, @@ -142,7 +130,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( timestamp: Date.now(), }; - // Now continue with Model B (different model, same provider) const context: Context = { systemPrompt: "You are a helpful assistant. Answer concisely.", messages: [userMessage, assistantResponse, toolResult, followUp], @@ -158,12 +145,10 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( }, }); - // The key assertion: no 400 error from orphaned function_call expect(response.stopReason, `Error: ${response.errorMessage}`).not.toBe("error"); expect(response.errorMessage).toBeFalsy(); expect(response.content.length).toBeGreaterThan(0); - // Log what was sent for debugging const input = capturedPayload?.input as any[]; const functionCalls = input?.filter((item: any) => item.type === "function_call") || []; const reasoningItems = input?.filter((item: any) => item.type === "reasoning") || []; @@ -173,7 +158,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( console.log("- reasoning items:", reasoningItems.length); console.log("- full input:", JSON.stringify(input, null, 2)); - // Verify the model understood the context const responseText = response.content .filter((b) => b.type === "text") .map((b) => (b as any).text) @@ -182,13 +166,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( }); it("handles cross-provider handoff from Anthropic to OpenAI", { retry: 2 }, async () => { - // This tests cross-provider handoff: - // 1. Anthropic model generates thinking + function_call (toolu_xxx ID) - // 2. User switches to OpenAI - // 3. transform-messages: isSameModel=false, thinking converted to text - // 4. Tool call ID is Anthropic format (toolu_xxx), no OpenAI pairing history - // 5. Should work because foreign IDs have no pairing expectation - const anthropicModel = getModel("anthropic", "claude-sonnet-4-5"); const openaiModel = getModel("openai", "gpt-5.4"); @@ -204,7 +181,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( timestamp: Date.now(), }; - // Get a real response from Anthropic with thinking + tool call const assistantResponse = await complete( anthropicModel, { @@ -229,7 +205,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( console.log("Anthropic tool call ID:", toolCallBlock.id); - // Provide a tool result const toolResult: Message = { role: "toolResult", toolCallId: toolCallBlock.id, @@ -245,7 +220,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( timestamp: Date.now(), }; - // Now continue with Codex (different provider) const context: Context = { systemPrompt: "You are a helpful assistant. Answer concisely.", messages: [userMessage, assistantResponse, toolResult, followUp], @@ -261,7 +235,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( }, }); - // Log what was sent const input = capturedPayload?.input as any[]; const functionCalls = input?.filter((item: any) => item.type === "function_call") || []; const reasoningItems = input?.filter((item: any) => item.type === "reasoning") || []; @@ -276,12 +249,10 @@ describe.skipIf(!process.env.OPENAI_API_KEY || !process.env.ANTHROPIC_API_KEY)( ); } - // The key assertion: no 400 error expect(response.stopReason, `Error: ${response.errorMessage}`).not.toBe("error"); expect(response.errorMessage).toBeFalsy(); expect(response.content.length).toBeGreaterThan(0); - // Verify the model understood the context const responseText = response.content .filter((b) => b.type === "text") .map((b) => (b as any).text) diff --git a/packages/ai/test/openrouter-cache-write-repro.test.ts b/packages/ai/test/openrouter-cache-write-repro.test.ts index ce5abde9e8..fefe3ee13e 100644 --- a/packages/ai/test/openrouter-cache-write-repro.test.ts +++ b/packages/ai/test/openrouter-cache-write-repro.test.ts @@ -69,8 +69,6 @@ describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter cache_write repro E const second = await completeSimple(model, context, options); expect(second.stopReason, second.errorMessage).toBe("stop"); - // Regression expectation: cache_write_tokens from provider usage must be preserved. - // With the cache_control marker above, at least one of the two calls should create cache. const hasCacheWrite = first.usage.cacheWrite > 0 || second.usage.cacheWrite > 0; expect(hasCacheWrite).toBe(true); }); diff --git a/packages/ai/test/overflow.test.ts b/packages/ai/test/overflow.test.ts index 633ba66386..3343e730b3 100644 --- a/packages/ai/test/overflow.test.ts +++ b/packages/ai/test/overflow.test.ts @@ -41,8 +41,6 @@ describe("isContextOverflow", () => { }); it("does not treat Bedrock throttling 'Too many tokens' as overflow", () => { - // Bedrock returns this for HTTP 429 rate limiting, NOT context overflow. - // formatBedrockError uses a human-readable prefix for ThrottlingException. const message = createErrorMessage("Throttling error: Too many tokens, please wait before trying again."); expect(isContextOverflow(message, 200000)).toBe(false); }); diff --git a/packages/ai/test/prime-inference-models.test.ts b/packages/ai/test/prime-inference-models.test.ts index ccf748857d..cd79d4a177 100644 --- a/packages/ai/test/prime-inference-models.test.ts +++ b/packages/ai/test/prime-inference-models.test.ts @@ -16,9 +16,6 @@ describe("Prime Inference models", () => { it("registers the Prime Inference catalog", () => { const modelIds = getModels("prime-inference").map((model) => model.id); - // Lower bound, not an exact count — the catalog grows with each release and an - // exact number breaks on every routine addition. The membership checks below - // are the meaningful assertions. expect(modelIds.length).toBeGreaterThanOrEqual(90); expect(modelIds).toEqual( expect.arrayContaining([ @@ -99,8 +96,8 @@ describe("Prime Inference models", () => { expect(model.input).toEqual(["text", "image"]); expect(model.contextWindow).toBe(1048576); expect(model.maxTokens).toBe(1048576); - expect(model.cost.input).toBe(3); - expect(model.cost.output).toBe(15); + expect(model.cost.input).toBe(provider === "prime-inference" ? 3.45 : 3); + expect(model.cost.output).toBe(provider === "prime-inference" ? 17.25 : 15); } }); @@ -111,10 +108,6 @@ describe("Prime Inference models", () => { expect(gemini.input).toEqual(["text", "image"]); expect(gemini.reasoning).toBe(true); - // Modality and reasoning are read from OpenRouter's published spec for the - // same upstream model, but the Prime route enforces a smaller window and - // output cap than that spec lists (1M/16k), so the curated override wins - // for contextWindow and maxTokens. const nemotronSuper = getModel("prime-inference", "nvidia/nemotron-3-super-120b-a12b"); expect(nemotronSuper.reasoning).toBe(true); expect(nemotronSuper.input).toEqual(["text"]); @@ -214,8 +207,6 @@ describe("Prime Inference models", () => { expect(getModel("prime-inference", "anthropic/claude-sonnet-4.6").contextWindow).toBe(1000000); expect(getModel("prime-inference", "anthropic/claude-sonnet-5").contextWindow).toBe(1000000); expect(getModel("prime-inference", "anthropic/claude-haiku-4.5").contextWindow).toBe(200000); - // Confirmed against the live API: this route serves a 200k window, not the - // larger one the upstream model's published spec lists. expect(getModel("prime-inference", "anthropic/claude-sonnet-4.5").contextWindow).toBe(200000); }); diff --git a/packages/ai/test/stream.test.ts b/packages/ai/test/stream.test.ts index 7686ac6ede..5de75a4f04 100644 --- a/packages/ai/test/stream.test.ts +++ b/packages/ai/test/stream.test.ts @@ -5,7 +5,7 @@ import { Type } from "typebox"; import { fileURLToPath } from "url"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { getEnvApiKey } from "../src/env-api-keys.js"; -import { getModel } from "../src/models.js"; +import { getModel, getModels } from "../src/models.js"; import { complete, stream } from "../src/stream.js"; import type { Api, Context, ImageContent, Model, StreamOptions, Tool, ToolResultMessage } from "../src/types.js"; import { getKimiCodingTestModel } from "./kimi-test-model.js"; @@ -22,7 +22,6 @@ import { resolveApiKey } from "./oauth.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ resolveApiKey("anthropic"), resolveApiKey("github-copilot"), @@ -31,9 +30,6 @@ const oauthTokens = await Promise.all([ const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens; const primeInferenceApiKey = getEnvApiKey("prime-inference"); -// Calculator tool definition (same as examples) -// Note: Using StringEnum helper because Google's API doesn't support anyOf/const patterns -// that Type.Enum generates. Google requires { type: "string", enum: [...] } format. const calculatorSchema = Type.Object({ a: Type.Number({ description: "First number" }), b: Type.Number({ description: "Second number" }), @@ -115,11 +111,8 @@ async function handleToolCall(model: Model, options?: St if (toolCall.type === "toolCall") { expect(toolCall.name).toBe("math_operation"); accumulatedToolArgs += event.delta; - // Check that we have a parsed arguments object during streaming expect(toolCall.arguments).toBeDefined(); expect(typeof toolCall.arguments).toBe("object"); - // The arguments should be partially populated as we stream - // At minimum it should be an empty object, never undefined expect(toolCall.arguments).not.toBeNull(); } } @@ -223,13 +216,11 @@ async function handleThinking(model: Model, options?: St } async function handleImage(model: Model, options?: StreamOptionsWithExtras) { - // Check if the model supports images if (!model.input.includes("image")) { console.log(`Skipping image test - model ${model.id} doesn't support images`); return; } - // Read the test image const imagePath = join(__dirname, "data", "red-circle.png"); const imageBuffer = readFileSync(imagePath); const base64Image = imageBuffer.toString("base64"); @@ -259,7 +250,6 @@ async function handleImage(model: Model, options?: Strea const response = await complete(model, context, options); - // Check the response mentions red and circle expect(response.content.length > 0).toBeTruthy(); const textContent = response.content.find((b) => b.type === "text"); if (textContent && textContent.type === "text") { @@ -282,7 +272,6 @@ async function multiTurn(model: Model, options?: StreamO tools: [calculatorTool], }; - // Collect all text content from all assistant responses let allTextContent = ""; let hasSeenThinking = false; let hasSeenToolCalls = false; @@ -291,10 +280,8 @@ async function multiTurn(model: Model, options?: StreamO for (let turn = 0; turn < maxTurns; turn++) { const response = await complete(model, context, options); - // Add the assistant response to context context.messages.push(response); - // Process content blocks const results: ToolResultMessage[] = []; for (const block of response.content) { if (block.type === "text") { @@ -304,7 +291,6 @@ async function multiTurn(model: Model, options?: StreamO } else if (block.type === "toolCall") { hasSeenToolCalls = true; - // Process the tool call expect(block.name).toBe("math_operation"); expect(block.id).toBeTruthy(); expect(block.arguments).toBeTruthy(); @@ -322,7 +308,6 @@ async function multiTurn(model: Model, options?: StreamO result = 0; } - // Add tool result to context results.push({ role: "toolResult", toolCallId: block.id, @@ -335,17 +320,14 @@ async function multiTurn(model: Model, options?: StreamO } context.messages.push(...results); - // If we got a stop response with text content, we're likely done expect(response.stopReason, `Error: ${response.errorMessage}`).not.toBe("error"); if (response.stopReason === "stop") { break; } } - // Verify we got either thinking content or tool calls (or both) expect(hasSeenThinking || hasSeenToolCalls).toBe(true); - // The accumulated text should reference both calculations expect(allTextContent).toBeTruthy(); expect(allTextContent.includes("714")).toBe(true); expect(allTextContent.includes("887")).toBe(true); @@ -666,33 +648,6 @@ describe("Generate E2E Tests", () => { }, ); - describe.skipIf(!hasCloudflareAiGatewayCredentials())( - "Cloudflare AI Gateway → Workers AI (Kimi K2.6 via /compat)", - () => { - const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); - - it("should complete basic text generation", { retry: 3 }, async () => { - await basicTextGeneration(llm); - }); - - it("should handle tool calling", { retry: 3 }, async () => { - await handleToolCall(llm); - }); - - it("should handle streaming", { retry: 3 }, async () => { - await handleStreaming(llm); - }); - - it("should handle thinking mode", { retry: 3 }, async () => { - await handleThinking(llm, { reasoningEffort: "medium" }); - }); - - it("should handle multi-turn with thinking and tools", { retry: 3 }, async () => { - await multiTurn(llm, { reasoningEffort: "medium" }); - }); - }, - ); - describe.skipIf(!hasCloudflareAiGatewayCredentials() || !process.env.OPENAI_API_KEY)( "Cloudflare AI Gateway → OpenAI BYOK (gpt-5.1 via /openai responses)", () => { @@ -727,9 +682,10 @@ describe("Generate E2E Tests", () => { ); describe.skipIf(!hasCloudflareAiGatewayCredentials() || !process.env.ANTHROPIC_API_KEY)( - "Cloudflare AI Gateway → Anthropic BYOK (claude-sonnet-4-5 via /anthropic messages)", + "Cloudflare AI Gateway → Anthropic BYOK (Claude Sonnet 4.6 via /anthropic messages)", () => { - const llm = getModel("cloudflare-ai-gateway", "claude-sonnet-4-5"); + const llm = getModels("cloudflare-ai-gateway").find((model) => model.name === "Claude Sonnet 4.6"); + if (!llm) throw new Error("Cloudflare AI Gateway is missing Claude Sonnet 4.6"); const options = { headers: { Authorization: `Bearer ${process.env.ANTHROPIC_API_KEY}` } }; const thinkingOptions = { ...options, @@ -1138,11 +1094,6 @@ describe("Generate E2E Tests", () => { }, ); - // ========================================================================= - // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) - // Tokens are resolved at module level (see oauthTokens above) - // ========================================================================= - describe("Anthropic OAuth Provider (claude-sonnet-4-6)", () => { const model = getModel("anthropic", "claude-sonnet-4-6"); @@ -1479,7 +1430,6 @@ describe("Generate E2E Tests", () => { }); }); - // Check if ollama is installed and local LLM tests are enabled let ollamaInstalled = false; if (!process.env.PI_NO_LOCAL_LLM) { try { @@ -1495,7 +1445,6 @@ describe("Generate E2E Tests", () => { let ollamaProcess: ChildProcess | null = null; beforeAll(async () => { - // Check if model is available, if not pull it try { execSync("ollama list | grep -q 'gpt-oss:20b'", { stdio: "ignore" }); } catch { @@ -1508,13 +1457,11 @@ describe("Generate E2E Tests", () => { } } - // Start ollama server ollamaProcess = spawn("ollama", ["serve"], { detached: false, stdio: "ignore", }); - // Wait for server to be ready await new Promise((resolve) => { const checkServer = async () => { try { @@ -1551,7 +1498,6 @@ describe("Generate E2E Tests", () => { }, 30000); // 30 second timeout for setup afterAll(() => { - // Kill ollama server if (ollamaProcess) { ollamaProcess.kill("SIGTERM"); ollamaProcess = null; diff --git a/packages/ai/test/tokens.test.ts b/packages/ai/test/tokens.test.ts index 5fceca1e06..cdf9dd2f9e 100644 --- a/packages/ai/test/tokens.test.ts +++ b/packages/ai/test/tokens.test.ts @@ -9,10 +9,9 @@ type StreamOptionsWithExtras = StreamOptions & Record; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; +import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; import { resolveApiKey } from "./oauth.js"; -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ resolveApiKey("anthropic"), resolveApiKey("github-copilot"), @@ -51,9 +50,6 @@ async function testTokensOnAbort(llm: Model, options: St expect(msg.stopReason).toBe("aborted"); - // OpenAI providers, OpenAI Codex, zai, and Amazon Bedrock only send usage in the final chunk, - // so when aborted they have no token stats. Anthropic and Google send usage information early in the stream. - // MiniMax and Kimi report input tokens but not output tokens differently on aborted requests. if ( llm.api === "openai-completions" || llm.api === "mistral-conversations" || @@ -67,18 +63,15 @@ async function testTokensOnAbort(llm: Model, options: St expect(msg.usage.input).toBe(0); expect(msg.usage.output).toBe(0); } else if (llm.provider === "minimax") { - // MiniMax M2.7 does not report token usage for aborted requests. expect(msg.usage.input).toBe(0); expect(msg.usage.output).toBe(0); } else if (llm.provider === "kimi-coding") { - // Kimi reports input tokens early but output tokens only in the final chunk. expect(msg.usage.input).toBeGreaterThan(0); expect(msg.usage.output).toBe(0); } else { expect(msg.usage.input).toBeGreaterThan(0); expect(msg.usage.output).toBeGreaterThan(0); - // Some providers (Copilot) have zero cost rates if (llm.cost.input > 0) { expect(msg.usage.cost.input).toBeGreaterThan(0); expect(msg.usage.cost.total).toBeGreaterThan(0); @@ -166,14 +159,6 @@ describe("Token Statistics on Abort", () => { }); }); - describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider", () => { - const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); - - it("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { - await testTokensOnAbort(llm); - }); - }); - describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider", () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); @@ -225,11 +210,7 @@ describe("Token Statistics on Abort", () => { describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing) Provider", () => { const llm = getModel("xiaomi", "mimo-v2.5-pro"); - // FIXME(xiaomi): Xiaomi's Anthropic-compatible stream does not populate - // usage in the message_start event the way Anthropic does — usage only - // arrives at message_stop. Aborting mid-stream therefore loses input/output - // token counts. Non-streaming usage works (see total-tokens.test.ts). - // Re-enable once upstream sends usage in message_start. + // Xiaomi only reports this streaming usage at message_stop, after an abort. it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { await testTokensOnAbort(llm); }); @@ -238,8 +219,6 @@ describe("Token Statistics on Abort", () => { describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN) Provider", () => { const llm = getModel("xiaomi-token-plan-cn", "mimo-v2.5-pro"); - // FIXME(xiaomi): see the API-billing block above — same upstream streaming - // usage limitation applies to Token Plan endpoints. it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { await testTokensOnAbort(llm); }); @@ -248,8 +227,6 @@ describe("Token Statistics on Abort", () => { describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS) Provider", () => { const llm = getModel("xiaomi-token-plan-ams", "mimo-v2.5-pro"); - // FIXME(xiaomi): see the API-billing block above — same upstream streaming - // usage limitation applies to Token Plan endpoints. it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { await testTokensOnAbort(llm); }); @@ -258,17 +235,11 @@ describe("Token Statistics on Abort", () => { describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP) Provider", () => { const llm = getModel("xiaomi-token-plan-sgp", "mimo-v2.5-pro"); - // FIXME(xiaomi): see the API-billing block above — same upstream streaming - // usage limitation applies to Token Plan endpoints. it.skip("should include token stats when aborted mid-stream", { retry: 3, timeout: 30000 }, async () => { await testTokensOnAbort(llm); }); }); - // ========================================================================= - // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) - // ========================================================================= - describe("Anthropic OAuth Provider", () => { const llm = getModel("anthropic", "claude-sonnet-4-6"); diff --git a/packages/ai/test/tool-call-id-normalization.test.ts b/packages/ai/test/tool-call-id-normalization.test.ts index 0655f75484..a41eaeba73 100644 --- a/packages/ai/test/tool-call-id-normalization.test.ts +++ b/packages/ai/test/tool-call-id-normalization.test.ts @@ -1,15 +1,3 @@ -/** - * Tool Call ID Normalization Tests - * - * Tests that tool call IDs from OpenAI Responses API (github-copilot, openai-codex, opencode) - * are properly normalized when sent to other providers. - * - * OpenAI Responses API generates IDs in format: {call_id}|{id} - * where {id} can be 400+ chars with special characters (+, /, =). - * - * Regression test for: https://github.com/earendil-works/pi-mono/issues/1022 - */ - import { Type } from "typebox"; import { describe, expect, it } from "vitest"; import { getModel } from "../src/models.js"; @@ -17,12 +5,10 @@ import { completeSimple, getEnvApiKey } from "../src/stream.js"; import type { AssistantMessage, Message, Tool, ToolResultMessage } from "../src/types.js"; import { resolveApiKey } from "./oauth.js"; -// Resolve API keys const copilotToken = await resolveApiKey("github-copilot"); const openrouterKey = getEnvApiKey("openrouter"); const codexToken = await resolveApiKey("openai-codex"); -// Simple echo tool for testing const echoToolSchema = Type.Object({ message: Type.String({ description: "Message to echo back" }), }); @@ -33,15 +19,6 @@ const echoTool: Tool = { parameters: echoToolSchema, }; -/** - * Test 1: Live cross-provider handoff - * - * 1. Use github-copilot gpt-5.2-codex to generate a tool call - * 2. Switch to openrouter openai/gpt-5.2-codex and complete - * 3. Switch to openai-codex gpt-5.2-codex and complete - * - * Both should succeed without "call_id too long" errors. - */ describe("Tool Call ID Normalization - Live Handoff", () => { it.skipIf(!copilotToken || !openrouterKey)( "github-copilot -> openrouter should normalize pipe-separated IDs", @@ -49,7 +26,6 @@ describe("Tool Call ID Normalization - Live Handoff", () => { const copilotModel = getModel("github-copilot", "gpt-5.2-codex"); const openrouterModel = getModel("openrouter", "openai/gpt-5.2-codex"); - // Step 1: Generate tool call with github-copilot const userMessage: Message = { role: "user", content: "Use the echo tool to echo 'hello world'", @@ -72,13 +48,11 @@ describe("Tool Call ID Normalization - Live Handoff", () => { expect(toolCall).toBeDefined(); expect(toolCall!.type).toBe("toolCall"); - // Verify it's a pipe-separated ID (OpenAI Responses format) if (toolCall?.type === "toolCall") { expect(toolCall.id).toContain("|"); console.log(`Tool call ID from github-copilot: ${toolCall.id.slice(0, 80)}...`); } - // Create tool result const toolResult: ToolResultMessage = { role: "toolResult", toolCallId: (toolCall as any).id, @@ -88,7 +62,6 @@ describe("Tool Call ID Normalization - Live Handoff", () => { timestamp: Date.now(), }; - // Step 2: Complete with openrouter (uses openai-completions API) const openrouterResponse = await completeSimple( openrouterModel, { @@ -104,7 +77,6 @@ describe("Tool Call ID Normalization - Live Handoff", () => { { apiKey: openrouterKey }, ); - // Should NOT fail with "call_id too long" error expect(openrouterResponse.stopReason, `OpenRouter error: ${openrouterResponse.errorMessage}`).not.toBe( "error", ); @@ -119,7 +91,6 @@ describe("Tool Call ID Normalization - Live Handoff", () => { const copilotModel = getModel("github-copilot", "gpt-5.2-codex"); const codexModel = getModel("openai-codex", "gpt-5.2-codex"); - // Step 1: Generate tool call with github-copilot const userMessage: Message = { role: "user", content: "Use the echo tool to echo 'test message'", @@ -141,7 +112,6 @@ describe("Tool Call ID Normalization - Live Handoff", () => { const toolCall = assistantResponse.content.find((c) => c.type === "toolCall"); expect(toolCall).toBeDefined(); - // Create tool result const toolResult: ToolResultMessage = { role: "toolResult", toolCallId: (toolCall as any).id, @@ -151,7 +121,6 @@ describe("Tool Call ID Normalization - Live Handoff", () => { timestamp: Date.now(), }; - // Step 2: Complete with openai-codex (uses openai-codex-responses API) const codexResponse = await completeSimple( codexModel, { @@ -167,7 +136,6 @@ describe("Tool Call ID Normalization - Live Handoff", () => { { apiKey: codexToken }, ); - // Should NOT fail with ID validation error expect(codexResponse.stopReason, `Codex error: ${codexResponse.errorMessage}`).not.toBe("error"); expect(codexResponse.errorMessage).toBeUndefined(); }, @@ -175,18 +143,10 @@ describe("Tool Call ID Normalization - Live Handoff", () => { ); }); -/** - * Test 2: Prefilled context with exact failing IDs from issue #1022 - * - * Uses the exact tool call ID format that caused the error: - * "call_xxx|very_long_base64_with_special_chars+/=" - */ describe("Tool Call ID Normalization - Prefilled Context", () => { - // Exact tool call ID from issue #1022 JSONL const FAILING_TOOL_CALL_ID = "call_pAYbIr76hXIjncD9UE4eGfnS|t5nnb2qYMFWGSsr13fhCd1CaCu3t3qONEPuOudu4HSVEtA8YJSL6FAZUxvoOoD792VIJWl91g87EdqsCWp9krVsdBysQoDaf9lMCLb8BS4EYi4gQd5kBQBYLlgD71PYwvf+TbMD9J9/5OMD42oxSRj8H+vRf78/l2Xla33LWz4nOgsddBlbvabICRs8GHt5C9PK5keFtzyi3lsyVKNlfduK3iphsZqs4MLv4zyGJnvZo/+QzShyk5xnMSQX/f98+aEoNflEApCdEOXipipgeiNWnpFSHbcwmMkZoJhURNu+JEz3xCh1mrXeYoN5o+trLL3IXJacSsLYXDrYTipZZbJFRPAucgbnjYBC+/ZzJOfkwCs+Gkw7EoZR7ZQgJ8ma+9586n4tT4cI8DEhBSZsWMjrCt8dxKg=="; - // Build prefilled context with the failing ID function buildPrefilledMessages(): Message[] { const userMessage: Message = { role: "user", @@ -253,7 +213,6 @@ describe("Tool Call ID Normalization - Prefilled Context", () => { { apiKey: openrouterKey }, ); - // Should NOT fail with "call_id too long" error expect(response.stopReason, `OpenRouter error: ${response.errorMessage}`).not.toBe("error"); if (response.errorMessage) { expect(response.errorMessage).not.toContain("call_id"); @@ -279,7 +238,6 @@ describe("Tool Call ID Normalization - Prefilled Context", () => { { apiKey: codexToken }, ); - // Should NOT fail with ID validation error expect(response.stopReason, `Codex error: ${response.errorMessage}`).not.toBe("error"); if (response.errorMessage) { expect(response.errorMessage).not.toContain("id"); diff --git a/packages/ai/test/tool-call-without-result.test.ts b/packages/ai/test/tool-call-without-result.test.ts index 39c314a9a2..8a76350c5d 100644 --- a/packages/ai/test/tool-call-without-result.test.ts +++ b/packages/ai/test/tool-call-without-result.test.ts @@ -10,10 +10,9 @@ type StreamOptionsWithExtras = StreamOptions & Record; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; +import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; import { resolveApiKey } from "./oauth.js"; -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ resolveApiKey("anthropic"), resolveApiKey("github-copilot"), @@ -21,7 +20,6 @@ const oauthTokens = await Promise.all([ ]); const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens; -// Simple calculate tool const calculateSchema = Type.Object({ expression: Type.String({ description: "The mathematical expression to evaluate" }), }); @@ -33,27 +31,23 @@ const calculateTool: Tool = { }; async function testToolCallWithoutResult(model: Model, options: StreamOptionsWithExtras = {}) { - // Step 1: Create context with the calculate tool const context: Context = { systemPrompt: "You are a helpful assistant. Use the calculate tool when asked to perform calculations.", messages: [], tools: [calculateTool], }; - // Step 2: Ask the LLM to make a tool call context.messages.push({ role: "user", content: "Please calculate 25 * 18 using the calculate tool.", timestamp: Date.now(), }); - // Step 3: Get the assistant's response (should contain a tool call) const firstResponse = await complete(model, context, options); context.messages.push(firstResponse); console.log("First response:", JSON.stringify(firstResponse, null, 2)); - // Verify the response contains a tool call const hasToolCall = firstResponse.content.some((block) => block.type === "toolCall"); expect(hasToolCall).toBe(true); @@ -61,26 +55,19 @@ async function testToolCallWithoutResult(model: Model, o throw new Error("Expected assistant to make a tool call, but none was found"); } - // Step 4: Send a user message WITHOUT providing tool result - // This simulates the scenario where a tool call was aborted/cancelled context.messages.push({ role: "user", content: "Never mind, just tell me what is 2+2?", timestamp: Date.now(), }); - // Step 5: The fix should filter out the orphaned tool call, and the request should succeed const secondResponse = await complete(model, context, options); console.log("Second response:", JSON.stringify(secondResponse, null, 2)); - // The request should succeed (not error) - that's the main thing we're testing expect(secondResponse.stopReason).not.toBe("error"); - // Should have some content in the response expect(secondResponse.content.length).toBeGreaterThan(0); - // The LLM may choose to answer directly or make a new tool call - either is fine - // The important thing is it didn't fail with the orphaned tool call error const textContent = secondResponse.content .filter((block) => block.type === "text") .map((block) => (block.type === "text" ? block.text : "")) @@ -89,15 +76,10 @@ async function testToolCallWithoutResult(model: Model, o expect(toolCalls || textContent.length).toBeGreaterThan(0); console.log("Answer:", textContent); - // Verify the stop reason is either "stop" or "toolUse" (new tool call) expect(["stop", "toolUse"]).toContain(secondResponse.stopReason); } describe("Tool Call Without Result Tests", () => { - // ========================================================================= - // API Key-based providers - // ========================================================================= - describe.skipIf(!process.env.GEMINI_API_KEY)("Google Provider", () => { const model = getModel("google", "gemini-2.5-flash"); @@ -177,14 +159,6 @@ describe("Tool Call Without Result Tests", () => { }); }); - describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider", () => { - const model = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); - - it("should filter out tool calls without corresponding tool results", { retry: 3, timeout: 30000 }, async () => { - await testToolCallWithoutResult(model); - }); - }); - describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider", () => { const model = getModel("huggingface", "moonshotai/Kimi-K2.5"); @@ -273,10 +247,6 @@ describe("Tool Call Without Result Tests", () => { }); }); - // ========================================================================= - // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) - // ========================================================================= - describe("Anthropic OAuth Provider", () => { const model = getModel("anthropic", "claude-haiku-4-5"); diff --git a/packages/ai/test/total-tokens.test.ts b/packages/ai/test/total-tokens.test.ts index 06156baeff..c4da819dbd 100644 --- a/packages/ai/test/total-tokens.test.ts +++ b/packages/ai/test/total-tokens.test.ts @@ -1,17 +1,3 @@ -/** - * Test totalTokens field across all providers. - * - * totalTokens represents the total number of tokens processed by the LLM, - * including input (with cache) and output (with thinking). This is the - * base for calculating context size for the next request. - * - * - OpenAI Completions: Uses native total_tokens field - * - OpenAI Responses: Uses native total_tokens field - * - Google: Uses native totalTokenCount field - * - Anthropic: Computed as input + output + cacheRead + cacheWrite - * - Other OpenAI-compatible providers: Uses native total_tokens field - */ - import { describe, expect, it } from "vitest"; import { getModel } from "../src/models.js"; import { complete } from "../src/stream.js"; @@ -23,10 +9,9 @@ type StreamOptionsWithExtras = StreamOptions & Record; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; +import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; import { resolveApiKey } from "./oauth.js"; -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ resolveApiKey("anthropic"), resolveApiKey("github-copilot"), @@ -34,7 +19,6 @@ const oauthTokens = await Promise.all([ ]); const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens; -// Generate a long system prompt to trigger caching (>2k bytes for most providers) const LONG_SYSTEM_PROMPT = `You are a helpful assistant. Be concise in your responses. Here is some additional context that makes this system prompt long enough to trigger caching: @@ -51,7 +35,6 @@ async function testTotalTokensWithCache( llm: Model, options: StreamOptionsWithExtras = {}, ): Promise<{ first: Usage; second: Usage }> { - // First request - no cache const context1: Context = { systemPrompt: LONG_SYSTEM_PROMPT, messages: [ @@ -66,7 +49,6 @@ async function testTotalTokensWithCache( const response1 = await complete(llm, context1, options); expect(response1.stopReason).toBe("stop"); - // Second request - should trigger cache read (same system prompt, add conversation) const context2: Context = { systemPrompt: LONG_SYSTEM_PROMPT, messages: [ @@ -101,10 +83,6 @@ function assertTotalTokensEqualsComponents(usage: Usage) { } describe("totalTokens field", () => { - // ========================================================================= - // Anthropic - // ========================================================================= - describe.skipIf(!process.env.ANTHROPIC_API_KEY)("Anthropic (API Key)", () => { it("claude-sonnet-4-5 - should return totalTokens equal to sum of components", { retry: 3, @@ -121,7 +99,6 @@ describe("totalTokens field", () => { assertTotalTokensEqualsComponents(first); assertTotalTokensEqualsComponents(second); - // Anthropic should have cache activity const hasCache = second.cacheRead > 0 || second.cacheWrite > 0 || first.cacheWrite > 0; expect(hasCache).toBe(true); }); @@ -143,17 +120,12 @@ describe("totalTokens field", () => { assertTotalTokensEqualsComponents(first); assertTotalTokensEqualsComponents(second); - // Anthropic should have cache activity const hasCache = second.cacheRead > 0 || second.cacheWrite > 0 || first.cacheWrite > 0; expect(hasCache).toBe(true); }, ); }); - // ========================================================================= - // OpenAI - // ========================================================================= - describe.skipIf(!process.env.OPENAI_API_KEY)("OpenAI Completions", () => { it("gpt-4o-mini - should return totalTokens equal to sum of components", { retry: 3, @@ -212,10 +184,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Google - // ========================================================================= - describe.skipIf(!process.env.GEMINI_API_KEY)("Google", () => { it("gemini-2.5-flash - should return totalTokens equal to sum of components", { retry: 3, @@ -234,10 +202,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // xAI - // ========================================================================= - describe.skipIf(!process.env.XAI_API_KEY)("xAI", () => { it("grok-4.3 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { const llm = getModel("xai", "grok-4.3"); @@ -253,10 +217,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Groq - // ========================================================================= - describe.skipIf(!process.env.GROQ_API_KEY)("Groq", () => { it("openai/gpt-oss-120b - should return totalTokens equal to sum of components", { retry: 3, @@ -275,10 +235,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Cerebras - // ========================================================================= - describe.skipIf(!process.env.CEREBRAS_API_KEY)("Cerebras", () => { it("gpt-oss-120b - should return totalTokens equal to sum of components", { retry: 3, @@ -297,10 +253,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Cloudflare Workers AI - // ========================================================================= - describe.skipIf(!hasCloudflareWorkersAICredentials())("Cloudflare Workers AI", () => { it("@cf/moonshotai/kimi-k2.6 - should return totalTokens equal to sum of components", { retry: 3, @@ -321,34 +273,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Cloudflare AI Gateway - // ========================================================================= - - describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway", () => { - it("workers-ai/@cf/moonshotai/kimi-k2.6 - should return totalTokens equal to sum of components", { - retry: 3, - timeout: 60000, - }, async () => { - const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); - - console.log(`\nCloudflare AI Gateway / ${llm.id}:`); - const { first, second } = await testTotalTokensWithCache(llm, { - apiKey: process.env.CLOUDFLARE_API_KEY, - }); - - logUsage("First request", first); - logUsage("Second request", second); - - assertTotalTokensEqualsComponents(first); - assertTotalTokensEqualsComponents(second); - }); - }); - - // ========================================================================= - // Hugging Face - // ========================================================================= - describe.skipIf(!process.env.HF_TOKEN)("Hugging Face", () => { it("Kimi-K2.5 - should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); @@ -364,10 +288,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // z.ai - // ========================================================================= - describe.skipIf(!process.env.ZAI_API_KEY)("z.ai", () => { it("should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { const llm = getZaiTestModel(); @@ -383,10 +303,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Mistral - // ========================================================================= - describe.skipIf(!process.env.MISTRAL_API_KEY)("Mistral", () => { it("devstral-medium-latest - should return totalTokens equal to sum of components", { retry: 3, @@ -405,10 +321,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // MiniMax - // ========================================================================= - describe.skipIf(!process.env.MINIMAX_API_KEY)("MiniMax", () => { it("MiniMax-M2.7 - should return totalTokens equal to sum of components", { retry: 3, @@ -427,10 +339,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Xiaomi MiMo - // ========================================================================= - describe.skipIf(!process.env.XIAOMI_API_KEY)("Xiaomi MiMo (API billing)", () => { it("mimo-v2.5-pro - should return totalTokens equal to sum of components", { retry: 3, @@ -449,10 +357,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Xiaomi MiMo Token Plan CN - // ========================================================================= - describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_CN_API_KEY)("Xiaomi MiMo Token Plan (CN)", () => { it("mimo-v2.5-pro - should return totalTokens equal to sum of components", { retry: 3, @@ -473,10 +377,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Xiaomi MiMo Token Plan AMS - // ========================================================================= - describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_AMS_API_KEY)("Xiaomi MiMo Token Plan (AMS)", () => { it("mimo-v2.5-pro - should return totalTokens equal to sum of components", { retry: 3, @@ -497,10 +397,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Xiaomi MiMo Token Plan SGP - // ========================================================================= - describe.skipIf(!process.env.XIAOMI_TOKEN_PLAN_SGP_API_KEY)("Xiaomi MiMo Token Plan (SGP)", () => { it("mimo-v2.5-pro - should return totalTokens equal to sum of components", { retry: 3, @@ -521,10 +417,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Kimi For Coding - // ========================================================================= - describe.skipIf(!process.env.KIMI_API_KEY)("Kimi For Coding", () => { it("should return totalTokens equal to sum of components", { retry: 3, timeout: 60000 }, async () => { const llm = getKimiCodingTestModel(); @@ -540,10 +432,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // Vercel AI Gateway - // ========================================================================= - describe.skipIf(!process.env.AI_GATEWAY_API_KEY)("Vercel AI Gateway", () => { it("google/gemini-2.5-flash - should return totalTokens equal to sum of components", { retry: 3, @@ -562,10 +450,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // OpenRouter - Multiple backend providers - // ========================================================================= - describe.skipIf(!process.env.OPENROUTER_API_KEY)("OpenRouter", () => { it("anthropic/claude-sonnet-4 - should return totalTokens equal to sum of components", { retry: 3, @@ -648,10 +532,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // GitHub Copilot (OAuth) - // ========================================================================= - describe("GitHub Copilot (OAuth)", () => { it.skipIf(!githubCopilotToken)( "gpt-5-mini - should return totalTokens equal to sum of components", @@ -688,12 +568,6 @@ describe("totalTokens field", () => { ); }); - // ========================================================================= - // ========================================================================= - - // ========================================================================= - // ========================================================================= - describe.skipIf(!hasBedrockCredentials())("Amazon Bedrock", () => { it("claude-sonnet-4-5 - should return totalTokens equal to sum of components", { retry: 3, @@ -712,10 +586,6 @@ describe("totalTokens field", () => { }); }); - // ========================================================================= - // OpenAI Codex (OAuth) - // ========================================================================= - describe("OpenAI Codex (OAuth)", () => { it.skipIf(!openaiCodexToken)( "gpt-5.2-codex - should return totalTokens equal to sum of components", diff --git a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts index 7508e74b80..3fe07d4e15 100644 --- a/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts +++ b/packages/ai/test/transform-messages-copilot-openai-to-anthropic.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { transformMessages } from "../src/providers/transform-messages.js"; import type { AssistantMessage, Message, Model, ToolCall } from "../src/types.js"; -// Normalize function matching what anthropic.ts uses function anthropicNormalizeToolCallId( id: string, _model: Model<"anthropic-messages">, @@ -80,7 +79,6 @@ describe("OpenAI to Anthropic session migration for Copilot Claude", () => { const result = transformMessages(messages, model, anthropicNormalizeToolCallId); const assistantMsg = result.find((m) => m.role === "assistant") as AssistantMessage; - // Thinking block should be converted to text since models differ const textBlocks = assistantMsg.content.filter((b) => b.type === "text"); const thinkingBlocks = assistantMsg.content.filter((b) => b.type === "thinking"); expect(thinkingBlocks).toHaveLength(0); diff --git a/packages/ai/test/unicode-surrogate.test.ts b/packages/ai/test/unicode-surrogate.test.ts index 3d9945c981..b0c05a73ee 100644 --- a/packages/ai/test/unicode-surrogate.test.ts +++ b/packages/ai/test/unicode-surrogate.test.ts @@ -10,13 +10,11 @@ type StreamOptionsWithExtras = StreamOptions & Record; import { hasAzureOpenAICredentials, resolveAzureDeploymentName } from "./azure-utils.js"; import { hasBedrockCredentials } from "./bedrock-utils.js"; -import { hasCloudflareAiGatewayCredentials, hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; +import { hasCloudflareWorkersAICredentials } from "./cloudflare-utils.js"; import { resolveApiKey } from "./oauth.js"; -// Empty schema for test tools - must be proper OBJECT type for Cloud Code Assist const emptySchema = Type.Object({}); -// Resolve OAuth tokens at module level (async, runs before tests) const oauthTokens = await Promise.all([ resolveApiKey("anthropic"), resolveApiKey("github-copilot"), @@ -24,20 +22,8 @@ const oauthTokens = await Promise.all([ ]); const [anthropicOAuthToken, githubCopilotToken, openaiCodexToken] = oauthTokens; -/** - * Test for Unicode surrogate pair handling in tool results. - * - * Issue: When tool results contain emoji or other characters outside the Basic Multilingual Plane, - * they may be incorrectly serialized as unpaired surrogates, causing "no low surrogate in string" - * errors when sent to the API provider. - * - * Example error from Anthropic: - * "The request body is not valid JSON: no low surrogate in string: line 1 column 197667" - */ - async function testEmojiInToolResults(llm: Model, options: StreamOptionsWithExtras = {}) { const toolCallId = llm.provider === "mistral" ? "testtool1" : "test_1"; - // Simulate a tool that returns emoji const context: Context = { systemPrompt: "You are a helpful assistant.", messages: [ @@ -80,7 +66,6 @@ async function testEmojiInToolResults(llm: Model, option ], }; - // Add tool result with various problematic Unicode characters const toolResult: ToolResultMessage = { role: "toolResult", toolCallId: toolCallId, @@ -107,14 +92,12 @@ async function testEmojiInToolResults(llm: Model, option context.messages.push(toolResult); - // Add follow-up user message context.messages.push({ role: "user", content: "Summarize the tool result briefly.", timestamp: Date.now(), }); - // This should not throw a surrogate pair error const response = await complete(llm, context, options); expect(response.stopReason).not.toBe("error"); @@ -166,7 +149,6 @@ async function testRealWorldLinkedInData(llm: Model, opt ], }; - // Real-world tool result from LinkedIn with emoji const toolResult: ToolResultMessage = { role: "toolResult", toolCallId: toolCallId, @@ -203,7 +185,6 @@ Unanswered Comments: 2 timestamp: Date.now(), }); - // This should not throw a surrogate pair error const response = await complete(llm, context, options); expect(response.stopReason).not.toBe("error"); @@ -255,8 +236,6 @@ async function testUnpairedHighSurrogate(llm: Model, opt ], }; - // Construct a string with an intentionally unpaired high surrogate - // This simulates what might happen if text processing corrupts emoji const unpairedSurrogate = String.fromCharCode(0xd83d); // High surrogate without low surrogate const toolResult: ToolResultMessage = { @@ -276,8 +255,6 @@ async function testUnpairedHighSurrogate(llm: Model, opt timestamp: Date.now(), }); - // This should not throw a surrogate pair error - // The unpaired surrogate should be sanitized before sending to API const response = await complete(llm, context, options); expect(response.stopReason).not.toBe("error"); @@ -368,10 +345,6 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); - // ========================================================================= - // OAuth-based providers (credentials from ~/.pi/agent/oauth.json) - // ========================================================================= - describe("Anthropic OAuth Provider Unicode Handling", () => { const llm = getModel("anthropic", "claude-haiku-4-5"); @@ -516,22 +489,6 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => { }); }); - describe.skipIf(!hasCloudflareAiGatewayCredentials())("Cloudflare AI Gateway Provider Unicode Handling", () => { - const llm = getModel("cloudflare-ai-gateway", "workers-ai/@cf/moonshotai/kimi-k2.6"); - - it("should handle emoji in tool results", { retry: 3, timeout: 30000 }, async () => { - await testEmojiInToolResults(llm); - }); - - it("should handle real-world LinkedIn comment data with emoji", { retry: 3, timeout: 30000 }, async () => { - await testRealWorldLinkedInData(llm); - }); - - it("should handle unpaired high surrogate (0xD83D) in tool results", { retry: 3, timeout: 30000 }, async () => { - await testUnpairedHighSurrogate(llm); - }); - }); - describe.skipIf(!process.env.HF_TOKEN)("Hugging Face Provider Unicode Handling", () => { const llm = getModel("huggingface", "moonshotai/Kimi-K2.5"); diff --git a/packages/ai/test/xhigh.test.ts b/packages/ai/test/xhigh.test.ts index ef487369f5..bb2eb2070d 100644 --- a/packages/ai/test/xhigh.test.ts +++ b/packages/ai/test/xhigh.test.ts @@ -17,7 +17,6 @@ function makeContext(): Context { describe.skipIf(!process.env.OPENAI_API_KEY)("xhigh reasoning", () => { describe("codex-max (supports xhigh)", () => { - // Note: codex models only support the responses API, not chat completions it("should work with openai-responses", async () => { const model = getModel("openai", "gpt-5.1-codex-max"); const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); @@ -42,7 +41,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY)("xhigh reasoning", () => { const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); for await (const _ of s) { - // drain events } const response = await s.result(); @@ -60,7 +58,6 @@ describe.skipIf(!process.env.OPENAI_API_KEY)("xhigh reasoning", () => { const s = stream(model, makeContext(), { reasoningEffort: "xhigh" }); for await (const _ of s) { - // drain events } const response = await s.result(); diff --git a/packages/coding-agent/.changes/README.md b/packages/coding-agent/.changes/README.md new file mode 100644 index 0000000000..86744da404 --- /dev/null +++ b/packages/coding-agent/.changes/README.md @@ -0,0 +1,5 @@ +# Changelog fragments + +One `.md` per PR containing the bullet line(s) (e.g. `- Fixed ...`) that describe the change +for this package. `scripts/release.mjs` folds these into the release section of CHANGELOG.md and +deletes them. See CONTRIBUTING.md. diff --git a/packages/coding-agent/.changes/acp-mcp-native-tools.md b/packages/coding-agent/.changes/acp-mcp-native-tools.md new file mode 100644 index 0000000000..a119e226f4 --- /dev/null +++ b/packages/coding-agent/.changes/acp-mcp-native-tools.md @@ -0,0 +1 @@ +- Added native callable tools for MCP servers supplied by ACP clients. ([#2002](https://github.com/PrimeIntellect-ai/prime-agent/pull/2002)) diff --git a/packages/coding-agent/.changes/acp-semantic-edges-delivery.md b/packages/coding-agent/.changes/acp-semantic-edges-delivery.md new file mode 100644 index 0000000000..39f436571d --- /dev/null +++ b/packages/coding-agent/.changes/acp-semantic-edges-delivery.md @@ -0,0 +1 @@ +- Registered the per-session semantic-edge ledger with the agent-traces outbox as its own kind-tagged entry: durable upload intent at persist, an append-only byte cursor that never re-counts unchanged ledgers, startup catch-up counting, and pruning when a ledger is deleted with its session. No delivery endpoint exists yet, so pending ledgers are counted but never sent. diff --git a/packages/coding-agent/.changes/acp-semantic-edges-producer.md b/packages/coding-agent/.changes/acp-semantic-edges-producer.md new file mode 100644 index 0000000000..201ef64460 --- /dev/null +++ b/packages/coding-agent/.changes/acp-semantic-edges-producer.md @@ -0,0 +1 @@ +- Added an ACP semantic-edges-v1 producer: each agent session appends an append-only `semantic-edges.jsonl` ledger beside its session artifacts, every provider turn and compaction summary call carries one opaque request ID on `X-ACP-Model-Request-ID` and `Idempotency-Key` (minted before the call, committed or failed when its stream resolves, and stable across retry attempts of the same call body), spawned subagents record their parent session and spawning request while successful children record their return, and `deriveSemanticEdges` folds a session tree's ledgers into commit-gated `continuation`/`subagent_call`/`subagent_return`/`compaction` edges matching the verifiers semantic-edges-v1 schema. Derivation only — nothing publishes or reads the ledger yet. diff --git a/packages/coding-agent/.changes/durable-shared-task-coordination.md b/packages/coding-agent/.changes/durable-shared-task-coordination.md new file mode 100644 index 0000000000..596a80ba9d --- /dev/null +++ b/packages/coding-agent/.changes/durable-shared-task-coordination.md @@ -0,0 +1 @@ +- Added a durable recursive task graph with scoped delegation plans, inherited handoffs, trusted shared evidence, historical claim coverage, and a graph-wide token budget. diff --git a/packages/coding-agent/.changes/eng-5838-traces-outbox.md b/packages/coding-agent/.changes/eng-5838-traces-outbox.md new file mode 100644 index 0000000000..1911ba4011 --- /dev/null +++ b/packages/coding-agent/.changes/eng-5838-traces-outbox.md @@ -0,0 +1 @@ +- Reworked agent-trace upload scheduling as a disk-cursor outbox: upload intent and per-session uploaded-content cursors persist as one small entry file per session under `agent-traces-outbox/` in the agent dir, a startup catch-up uploads anything a previous process never finished (pruning cursors of deleted session files), scheduled and catch-up uploads never re-send unchanged sessions (the explicit `/traces upload` command still force-uploads), and rate-limited uploads reschedule (honoring an advertised Retry-After) instead of sleeping. Session disposal and process exit no longer wait on trace uploads at all, and upload timers never keep the process alive; the exit drain barrier is gone (the startup catch-up replaces it). diff --git a/packages/coding-agent/.changes/eng-5847-heartbeat-idle-status.md b/packages/coding-agent/.changes/eng-5847-heartbeat-idle-status.md new file mode 100644 index 0000000000..ec19f21051 --- /dev/null +++ b/packages/coding-agent/.changes/eng-5847-heartbeat-idle-status.md @@ -0,0 +1,5 @@ +- Fixed sessions with armed heartbeats showing as Running forever in the agents view; between firings they now list as Idle with the heartbeat badge and a `heartbeat · next