0.10.2 candidate: endpoint-scoped catalog, tiered pricing and release verification (#6968)

Squash of the 0.10.2 candidate branch wip/0.10.2-6396-handoff-20261012:
endpoint-scoped model catalog and tiered pricing (#6396), the takeover
fixes found while qualifying it, and the release-verification tooling.
The full commit list and per-commit evidence are in PR #6968.

Qualification of the squashed tree (head 704d435db1, a clean merge of
main b5e270b690 into ba01538bc9 with no manual conflict resolutions):
- Hosted CI on 704d435db1: 40 checks passed, 4 skipped (Cloudflare
  deploy and its approval gate, fork-only macOS budget/eval, docs), 0
  failed. Run 38848133339.
- nextest: Ubuntu 19101/19101 passed (34 skipped), Windows 18357/18357
  passed (27 skipped), macOS 19104/19104 passed (33 skipped).
- Local on the same head: clippy with CI's flags clean; 667/667 focused
  nextest; npm test 547 Node (540 passed, 7 skipped, 0 failed) and 772
  Vitest passed; npm run check:web exit 0.
- Main was frozen from b5e270b690 until this squash, so the squashed
  tree is the tested tree.

Not covered here: the crates.io publish, tags and the GitHub release,
which remain the founder's. Merged under the founder's 2026-10-13
authorization to squash #6968 after its final head passes CI and review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
Hunter BownandClaude Opus 5.5 authored and GitHub committed 2026-10-16 14:35:46 +08:00
1 parent b5e270b690
commit be980da403
117 files changed
+8869 -1495

No files matched your search

+7 -7
View File
@@ -795,9 +795,9 @@ jobs:
# (fork PRs #6431, #6417), too close to the old 90-minute limit.
# On 2026-10-04 a PR leg spent 111 minutes compiling with no cache and
# was cancelled at 120 with 12,356 tests passed and none failed. The
# limit stays a literal (release-workflows.test.js, #5496); 165 bounds
# hangs for every leg until the CI speed redesign lands.
timeout-minutes: 165
# limit stays a literal (release-workflows.test.js, #5496); 210 bounds
# cold compilation and the complete suite without disabling test deadlines.
timeout-minutes: 210
# macOS legs go to the self-hosted Mac ONLY when all three hold: the
# change is heavy, the event is trusted (not a fork PR), and the
# CW_SELF_HOSTED_MAC repo variable is 'true'. That variable is the kill
@@ -870,12 +870,12 @@ jobs:
- name: Install macOS LLVM tools
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
run: rustup component add llvm-tools --toolchain stable
- name: Omit macOS test debug metadata
- name: Omit macOS build debug metadata
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
shell: bash
# Match the existing full-workspace CNB gate. Keep debug assertions and
# every test; only disposable test debug tables change on this lane.
run: echo "CARGO_PROFILE_TEST_DEBUG=0" >> "$GITHUB_ENV"
# every test; only disposable build debug tables change on this lane.
run: printf '%s\n' 'CARGO_PROFILE_DEV_DEBUG=0' 'CARGO_PROFILE_TEST_DEBUG=0' >> "$GITHUB_ENV"
- uses: mozilla-actions/[email protected]
id: sccache
continue-on-error: true
@@ -1259,7 +1259,7 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && github.event_name != 'pull_request' && ((github.event_name == 'workflow_dispatch' && inputs.workspace_test_mode == 'shared-process-twice') || !(needs.changes.outputs.trusted == 'true' && vars.CW_SELF_HOSTED_MAC == 'true'))
# Cold hosted builds consumed 73 minutes before eval could start (F run
# 37497498829). Match Test's compile allowance; RSS/eval limits stay intact.
timeout-minutes: 165
timeout-minutes: 210
runs-on: macos-latest
steps:
- uses: actions/checkout@v7
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
build:
name: Build ${{ matrix.platform }}
# Native builds use the same finite cold-build allowance as release/CI.
timeout-minutes: 165
timeout-minutes: 210
strategy:
fail-fast: false
matrix:
+49 -1
View File
@@ -86,7 +86,7 @@ jobs:
name: Build ${{ matrix.platform }}
# Both cold macOS artifact builds on 0.10.1 exhausted 90 minutes inside
# compilation. Keep the full CI build budget; packaging stays capped below.
timeout-minutes: 165
timeout-minutes: 210
# FreeBSD is a source-build target validated via `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked`
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd). The 7×1 prebuilt matrix stays 7 targets;
# FreeBSD has no prebuilt asset, no npm binary, and no matrix bloat — it builds from source.
@@ -326,6 +326,27 @@ jobs:
extension=""
if [[ "${TARGET_PLATFORM}" == windows-* ]]; then extension=.exe; fi
node scripts/release/stage-compiled-host.mjs --bun "${bun_binary}" --compiled-image "${proof_dir}/codewhale-extension-host${extension}" --native-receipt "${proof_dir}/native-receipt.json" --runtime-closure "${RUNTIME_CLOSURE_DIR}/${TARGET_PLATFORM}.json" --target "${TARGET_PLATFORM}" --version "${RELEASE_VERSION}" --source-sha "${GITHUB_SHA}" --output-dir "compiled-host-${TARGET_PLATFORM}"
- name: Check every staged Windows executable import
if: startsWith(matrix.platform, 'windows-')
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$binaries = @('${{ matrix.cli_artifact }}', '${{ matrix.shim_artifact }}', '${{ matrix.compat_tui_artifact }}')
$hostDirectory = 'compiled-host-${{ matrix.platform }}'
if (Test-Path $hostDirectory) {
$binaries += @(Get-ChildItem -Path $hostDirectory -Filter *.exe -Recurse -File | ForEach-Object { $_.FullName })
}
python scripts/release/check-windows-imports.py --expected-machine '${{ matrix.platform }}' --output '${{ runner.temp }}/windows-imports-${{ matrix.platform }}' @binaries
if ($LASTEXITCODE -ne 0) { throw 'Windows static import qualification failed' }
- name: Retain Windows import receipts
if: always() && startsWith(matrix.platform, 'windows-')
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: windows-imports-${{ matrix.platform }}-${{ github.sha }}
path: ${{ runner.temp }}/windows-imports-${{ matrix.platform }}/
if-no-files-found: warn
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: inputs.compiled_host_delivery && contains(format(',{0},', inputs.compiled_host_targets), format(',{0},', matrix.platform))
with:
@@ -453,6 +474,33 @@ jobs:
if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) {
throw "CodeWhaleSetup.exe was not produced"
}
- name: Check Windows installer and embedded executables
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$evidenceDirectory = '${{ runner.temp }}/windows-installer-imports'
$embeddedDirectory = Join-Path $evidenceDirectory 'embedded'
New-Item -Path $evidenceDirectory -ItemType Directory -Force | Out-Null
& 7z l 'scripts/installer/CodeWhaleSetup.exe' | Out-File (Join-Path $evidenceDirectory 'archive-listing.txt') -Encoding utf8
if ($LASTEXITCODE -ne 0) { throw 'NSIS embedded payload inventory failed' }
& 7z x 'scripts/installer/CodeWhaleSetup.exe' "-o$embeddedDirectory" '$PLUGINSDIR/*.dll' | Out-File (Join-Path $evidenceDirectory 'archive-extraction.txt') -Encoding utf8
if ($LASTEXITCODE -ne 0) { throw 'NSIS embedded plugin extraction failed' }
$plugins = @(Get-ChildItem -Path $embeddedDirectory -Filter *.dll -Recurse -File)
if ($plugins.Name -notcontains 'System.dll' -or $plugins.Name -notcontains 'nsDialogs.dll') { throw 'Expected MUI2 System and nsDialogs plugin payloads' }
$binaries = @(Get-ChildItem -Path scripts/installer -Filter *.exe -File | ForEach-Object { $_.FullName })
if ($binaries.Count -lt 3) { throw 'Expected installer, CLI and shim executables' }
$binaries += @($plugins | ForEach-Object { $_.FullName })
python scripts/release/check-windows-imports.py --output $evidenceDirectory @binaries
if ($LASTEXITCODE -ne 0) { throw 'Windows installer static import qualification failed' }
- name: Retain Windows installer import receipts
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: windows-installer-imports-${{ github.sha }}
path: ${{ runner.temp }}/windows-installer-imports/
if-no-files-found: warn
retention-days: ${{ inputs.retention_days }}
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: CodeWhaleSetup.exe
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
name: Workspace parity
# Cold check, Clippy, executable and test builds exceeded the old 45m
# limit before the suite started. Retain the full CI test-job budget.
timeout-minutes: 165
timeout-minutes: 210
runs-on: ubuntu-latest
steps:
# Every caller's resolve job already proved GITHUB_SHA equals the
+9 -6
View File
@@ -88,12 +88,15 @@ base prompt". Two more corollaries earned here:
- **Write down what a design does not do**, beside the behaviour it owns — a
short known-limitations note in the owning module. A stated limit stops the
next reader from assuming a capability that was never built.
- **Agents do not comment on issues or PRs** (founder, 2026-09-22). Spend the
time on code: evidence goes in the commit message and PR body, claims go in
Linear. Do not reply to review bots or post status, "superseded", or
"for the record" notes. The one exception is closing or superseding a human
contributor's PR or issue: one sentence saying why, with the link. The PR and
issue review workflows are disabled; re-enable one only by founder decision.
- **Substantive contributor replies are authorized for the 0.10.2 release**
(founder, 2026-10-13). Thank people specifically for their reports, diagnosis,
fixes and testing; link the carrying commit or PR and distinguish candidate
work from a shipped release. Status, answers and useful questions are welcome;
repetitive progress posts and bot noise are not. Evidence stays in commits
and PR bodies; ownership stays in Linear. Outside this release, the previous
no-comment rule remains, except a one-sentence reason when closing or
superseding a human contribution. Review workflows remain disabled unless
the founder separately authorizes enabling them.
- **A user feature lands with its registry row.** A new command, `[features]`
flag, provider or user-visible feature adds or updates its row in
`docs/features.toml` in the same change;
+138 -21
View File
@@ -7,17 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Contributors
- **[@printf033](https://github.com/printf033)** — replaced the SELinux-denied root-directory anchor of confined-file I/O with an `O_PATH` walk (Linux-cfg-gated) and a re-materialized final parent, restoring workflow journal, compaction checkpoint and artifact writes on Android/Termux ([#6983](https://github.com/codewhale-hq/Codewhale/pull/6983)); reported the Android confined-I/O failure with strace evidence and an on-device reproducer ([#6980](https://github.com/codewhale-hq/Codewhale/issues/6980)).
## [0.10.2] - 2026-10-10
## [0.10.2] - 2026-10-13
- OpenHarmony builds on Windows select Ninja, with an SDK-bundled fallback and
a clear error when it is unavailable. The CLI keeps its download and Work
context integrity checks available on OHOS by sharing an ungated SHA-256
helper (thanks @shenjackyuanjie, #6989; reported in #6988).
- Windows mouse-wheel scrolling is restored after returning from the external
editor. The handoff now releases both Windows console mouse capture and
terminal mouse modes. Thanks @SparkofSpike for distinguishing terminal
scrollback from transcript scrolling in #6652; the separate hours-long
scrolling slowdown still needs reproduction.
- Shell classification accepts PowerShell’s call operator, quoted ampersands
and combined output redirection without losing destructive commands in
nested shell payloads or after command separators. Thanks @SparkofSpike
for the diagnosis and fix in #6929, and @jayanthvee for the detailed
Windows report in #6963.
- Windows commands that cannot be classified use the normal approval path
with a direct explanation. Ask can request approval and Auto-Review can
consult its reviewer; an unclassified command is no longer reported as an
npm-launcher kill. Actual unbounded `node.exe` image kills keep their hold
in reviewing modes; Full Access continues to bypass that floor. Thanks
@jayanthvee for separating these cases in #6963.
- Pasting while a modal is open no longer changes a history-search query
hidden underneath it. Existing modal key ownership and hidden-caret guards
remain in place. Windows Terminal IME preedit is still controlled by the
terminal; native IME retesting remains open (#2323).
- Model lists, resolution, picker controls and Runtime facts use the same
endpoint-scoped catalog facts. Cost estimates preserve complete price tiers,
calculate exact decimal amounts per request, and freeze rates at dispatch.
Missing rates or required usage breakdowns stay unpriced; public prices do
not establish account access. The offline seed records its source snapshot
and keeps absent default wire IDs as dated compatibility fallbacks. Refs #6396.
- Desktop and HTTP app-server turns feed an existing shared pet companion when
its followed session has no live producer. Automatic selection is checked
atomically by the owner; a live TUI keeps its source. The Runtime producer
@@ -134,7 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
or run commands.
- `bash` now moves a foreground command to the background after 30 s and
reports the elapsed time and how long ago it last printed output.
`task_shell_start` is removed; use `bash` with `background=true`.
`task_shell_start` is removed.
- Every change of effective permission now writes a `Permissions changed to …`
line in the transcript, and runtime threads record it as a `status` item and
a `thread.permissions_changed` event.
@@ -154,6 +178,13 @@ launcher guidance.
### Added
- Standard Compute is available as a bundled compatible provider, with the
`standardcompute` router default and authenticated model discovery. Its
billing basis remains Unknown (#6985).
- Sub-agents retain permitted code-mode tool composition. Nested calls keep
the child's approvals, write scope, tool timeout and work deadline; approval
waits do not consume that tool timeout. Refused nested calls still do not
count toward Fleet's denial-loop guard. Refs #6841 (CW-7033).
- `/connect [<provider>]` signs in to a model provider that a plugin adds,
inside the TUI, and `/disconnect <provider>` removes that sign-in.
`/login` and `/logout` are for your Codewhale account only. Plugins that
@@ -229,6 +260,25 @@ launcher guidance.
`CODEWHALE_TELEMETRY_SURFACE`, including `vscode-extension`. This changes the
session label while preserving collection settings and opt-outs
([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
- `codewhale account computers files` saves a Computer's workspace file (up to
16 MiB) to account storage, lists its versions, and restores (up to 4 MiB)
or downloads one. Restoring over an existing file needs its current
`--expected-revision`; a download verifies size and SHA-256 and never
overwrites a local file. `codewhale account computers delete` now asks for
`--discard-files`. This is experimental.
- Runtime API: `GET /v1/goals` lists goal records across threads, newest
update first, with `status` and cursor paging. Goals whose thread no longer
exists are left out, and a cursor issued under a different `status` is `400`.
- Runtime API: `GET /v1/approvals` rows add a redacted `target` (a URL's host
or a workspace path) and a one-line `summary`, plus `standing_rule` (`allow`
or `deny`) when a remembered rule or grant answered instead of a person. A
shell command keeps only its program name and plain arguments. Rows recorded
earlier omit these fields.
- Runtime API: automations carry a `revision`. `PATCH`, `run`, `pause` and
`resume` accept `expected_revision` and answer `409` `revision_conflict` on a
mismatch. Create and those four accept an `Idempotency-Key`; a retry replays
the first success instead of creating or running twice. `DELETE` honors
neither. Clients check `capabilities.automation_mutation_preconditions`.
### Changed
@@ -293,16 +343,38 @@ launcher guidance.
this package" now lists the skipped rows, each with its package and reason,
and the Native composition refusal names the unresolved rows. Both name at
most five rows and count the rest.
- Full Access runs the calls it grants. The built-in Auto-Review floor
(publish-like commands, destructive background or headless work, and the
Windows npm-launcher hold) applies only in Ask, Auto-Review and Never, and no
longer blocks a call under Full Access, including a detached agent's.
- `file_search` is one of the model's default tools from the first turn
instead of being loaded through `tool_search`
([#6934](https://github.com/codewhale-hq/Codewhale/issues/6934)).
- `fim_edit` is offered only on routes that can run it: Chat Completions
routes other than OpenCode Zen. The default DeepSeek Flash route speaks
Responses and no longer lists it
([#6933](https://github.com/codewhale-hq/Codewhale/issues/6933)).
- Runtime session grants (`"remember": true`) are stored in the Runtime data
directory instead of memory. After a restart a thread's grants stay
suspended, and matching calls ask again, until an authenticated client
resumes the thread. A grant store that cannot be read holds no grants, so
every call asks.
### Fixed
- Windows 10 1607 starts again. 0.10.1 stopped at load with 0xC0000139
because `DeriveCapabilitySidsFromName` is missing there; it is now resolved
at runtime (#6953).
- Runtime child completions retain their original parent turn after the parent
becomes idle or starts a newer turn; terminal receipts remain durable and
deduplicated (CW-7414).
- Windows Native extension hosts resolve `DeriveCapabilitySidsFromName`
at runtime and return an unsupported error during LPAC setup when the
export is unavailable (#6953). Windows 10 1607 startup and Native-host
acceptance remain unverified.
- `codewhale resume` no longer refuses a session whose saved history still
holds a raw sub-agent handoff (#6954).
- A saved session over 8 MiB can be resumed and forked again. Journal entries
off the current branch are archived instead of refusing the session (#6955).
A session whose current branch alone is over 8 MiB is still refused, and the
refusal now gives its size.
- Snapshot restore and `/undo` work with older git, such as the 2.43 that
Ubuntu 24.04 ships. They failed there with "only one reference expected".
- Cancelling your own background shell job no longer reports it as finished
@@ -339,10 +411,26 @@ launcher guidance.
a recorded session message outside the system-prompt layer.
- The pet shows sub-agents while they run, and a finished one no longer stays
"working". Half-block pet sprites are drawn crisply instead of blurred.
- A provider's retry delay is honored when a 429 carries it in the body
instead of a `Retry-After` header (#6923).
- A rate-limit 429 with a body retry delay of up to 120 s retries the interrupted
model request. This includes ambiguous current-quota wording with billing
guidance when the body supplies a bounded delay; explicit exhausted account,
billing and subscription limits remain terminal (Refs #6923, thanks @Statter).
- Claude sign-in keeps its PKCE verifier out of the sign-in URL and gives its
pasted authorization code one terminal input reader. Failed input restores
the TUI; live subscription sign-in remains experimental (Refs #6932).
- Oversized-session refusals preserve compaction errors and no longer assume
that the active conversation alone caused the remaining size limit
(Refs #6955, thanks @7jrxt42BxFZo4iAnN4CX).
- A Codewhale home reached through a symlink or Windows junction keeps saving
artifacts and compacting (#6947).
- Sub-agent state and transcripts also save through a relocated Codewhale
home when its junction target already exists. Containment checks use the
same resolved state root throughout the operation (Refs #6949, thanks
@SparkofSpike).
- Saving a DeepSeek key in the TUI writes the provider's durable-key marker,
so its saved credential remains visible after restart while another
provider is active. Older saves missing that marker need one re-save
(Refs #6972, thanks @gaord).
- A workflow gate that blocks its own role is rejected when the plan is
admitted instead of deadlocking the run (#6945).
- Auto-Review gives its guardian an output budget a reasoning model can think
@@ -448,17 +536,37 @@ launcher guidance.
a reused process ID, or a publication from another user, store or config
is still refused
([#6915](https://github.com/codewhale-hq/Codewhale/issues/6915)).
- `/network allow <host>` and `/network deny <host>` take effect on the next
tool call without a restart
([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928)). Under a
managed `[network]` overlay or a Fleet `network_access = false`, editing your
own `[network]` table cannot widen the policy: a denial from either layer
stands, a deny-by-default authority keeps its own allow list, proxy and
fake-IP exceptions, a prompt-by-default authority adds your hosts to its own,
and audit logging stays on if either layer asks for it.
- A workflow's final receipt is no longer dropped when its completion inbox is
full, and each agent's queue of messages to its parent holds at most 64; a
send past that returns an error instead of growing without bound
([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)).
- `cargo +<toolchain> build`, `scripts/dev-test.sh` and `scripts/dev-cargo.sh`,
also through a wrapper, count as heavy commands for resource admission
([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)).
- Sub-agent state is written atomically, with the file and its directory
flushed before the rename
([#6555](https://github.com/codewhale-hq/Codewhale/issues/6555)), and image
search reads its seed and result bodies through the size cap
([#6554](https://github.com/codewhale-hq/Codewhale/issues/6554)).
### Known limits
- Ambiguous current-quota 429s without a body retry delay of at most 120 s
still end the turn; a header alone does not change that classification.
Explicit exhausted account, billing and subscription limits remain terminal
regardless of a retry hint. Gemini limits without an accepted body delay are
not automatically retried
([#6923](https://github.com/codewhale-hq/Codewhale/issues/6923)).
- Idle CPU has not been measured on FreeBSD or Linux for this release
([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)).
- Models.dev prices with context tiers are not applied. A turn at or above a
model's first context tier reads as unpriced ("pricing tier unavailable")
and is counted as missing from the cost total, instead of being priced at
its base rate. The bundled catalog has no tiered rows, so this appears only
after a live models.dev refresh
([#6396](https://github.com/codewhale-hq/Codewhale/issues/6396)).
- Shell commands run by delegated agents appear in `/jobs` and the work dock
with their owner, not in the agent's own detail view; named terminal
sessions live in the terminal dock
@@ -467,6 +575,13 @@ launcher guidance.
be changed inside the `F5` inspector. The browser studio and the macOS
window are not views in the cycle, and `Ctrl+]` cannot open the work bar
while the pet main view is showing.
- Computer Use is a preview. Its observation diffs, fresh element targets and
named action batches were checked against a macOS fixture app only;
packaged installs, installed-model use and sharing the desktop with a person
working at the same time have not been qualified.
- The `GET /v1/approvals` `target` and `summary` redaction is heuristic: a
secret passed as a plain word argument is not recognized, so treat approval
history as sensitive.
### Maintenance
@@ -478,13 +593,15 @@ launcher guidance.
### Contributors
- **[@henrikdukefoss](https://github.com/henrikdukefoss)** — added the Standard Compute compatible-host descriptor, with live model discovery and its billing basis kept Unknown ([#6985](https://github.com/codewhale-hq/Codewhale/pull/6985)).
- **[@shenjackyuanjie](https://github.com/shenjackyuanjie)** — diagnosed and fixed the Windows OpenHarmony CMake generator and CLI hashing-module build failures ([#6988](https://github.com/codewhale-hq/Codewhale/issues/6988), [#6989](https://github.com/codewhale-hq/Codewhale/pull/6989)).
- **[@printf033](https://github.com/printf033)** — replaced the SELinux-denied root-directory anchor of confined-file I/O with an `O_PATH` walk (Linux-cfg-gated) and a re-materialized final parent, restoring workflow journal, compaction checkpoint and artifact writes on Android/Termux ([#6983](https://github.com/codewhale-hq/Codewhale/pull/6983)); reported the Android confined-I/O failure with strace evidence and an on-device reproducer ([#6980](https://github.com/codewhale-hq/Codewhale/issues/6980)). Source change; compiled Android and device acceptance remain unqualified.
- **[@aboimpinto](https://github.com/aboimpinto)** — moved config-command parsing and replies onto portable contracts while the host retains effects ([#6973](https://github.com/codewhale-hq/Codewhale/pull/6973)).
- **[@henrikdukefoss](https://github.com/henrikdukefoss)** — added the Standard Compute compatible-host descriptor, with live model discovery and its billing basis kept Unknown ([#6985](https://github.com/codewhale-hq/Codewhale/pull/6985)).
- **[@LIghtJUNction](https://github.com/LIghtJUNction)** — plugin shell install and in-TUI plugin OAuth sign-in ([#6950](https://github.com/codewhale-hq/Codewhale/pull/6950)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), and a relocated Codewhale home ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), Windows ampersand classification ([#6929](https://github.com/codewhale-hq/Codewhale/pull/6929)), and a relocated Codewhale home including sub-agent state ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947), [#6949](https://github.com/codewhale-hq/Codewhale/pull/6949)).
- **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)).
- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)) and workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)).
- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)) and the DeepSeek saved-key marker diagnosis and fix ([#6972](https://github.com/codewhale-hq/Codewhale/issues/6972), [#6974](https://github.com/codewhale-hq/Codewhale/pull/6974)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)), workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)), proxy-independent MCP loopback checks ([#6971](https://github.com/codewhale-hq/Codewhale/pull/6971), [#6975](https://github.com/codewhale-hq/Codewhale/pull/6975)), and durable steering/child-approval acceptance checks ([#6967](https://github.com/codewhale-hq/Codewhale/pull/6967), [#6966](https://github.com/codewhale-hq/Codewhale/pull/6966)).
- **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)).
- **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)).
- **[@BX166](https://github.com/BX166)** — reported three provider failure modes measured on AICraft's own traffic: a cold model that reads as a dead connection, a listed model that fails on every call, and a response cut at the output ceiling that reports `stop` ([#6889](https://github.com/codewhale-hq/Codewhale/issues/6889)).
Generated
+49 -1
View File
@@ -181,6 +181,12 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayvec"
version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "ascii"
version = "1.1.0"
@@ -571,6 +577,30 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"borsh-derive",
"bytes",
"cfg_aliases 0.2.2",
]
[[package]]
name = "borsh-derive"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
dependencies = [
"once_cell",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "bstr"
version = "1.13.0"
@@ -855,7 +885,6 @@ dependencies = [
"chrono",
"clap",
"clap_complete",
"codewhale-agent",
"codewhale-app-server",
"codewhale-build-support",
"codewhale-config",
@@ -937,6 +966,7 @@ dependencies = [
"fd-lock",
"libc",
"ring",
"rust_decimal",
"semver",
"serde",
"serde_json",
@@ -1263,6 +1293,7 @@ dependencies = [
"base64 0.23.1",
"chrono",
"clap",
"codewhale-agent",
"codewhale-app-server",
"codewhale-build-support",
"codewhale-cloud-facts",
@@ -4858,6 +4889,23 @@ dependencies = [
"triomphe",
]
[[package]]
name = "rust_decimal"
version = "1.43.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
dependencies = [
"arrayvec",
"borsh",
"bytes",
"num-traits",
"rand 0.8.7",
"rand 0.9.5",
"serde",
"serde_json",
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
-1
View File
@@ -28,7 +28,6 @@ anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
codewhale-tui = { path = "../tui", version = "0.10.2", default-features = false }
codewhale-agent = { path = "../agent", version = "0.10.2" }
codewhale-app-server = { path = "../app-server", version = "0.10.2" }
codewhale-config = { path = "../config", version = "0.10.2" }
codewhale-lane = { path = "../lane", version = "0.10.2" }
+83 -9
View File
@@ -16,7 +16,6 @@ use std::process::Command;
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate};
use codewhale_agent::ModelRegistry;
use codewhale_app_server::RuntimeControlFrontend;
use codewhale_config::credentials::{
clear_provider_api_key_from_config, provider_slot, set_provider_api_key,
@@ -5794,7 +5793,6 @@ fn run_model_command(
Ok(())
}
ModelCommand::Resolve { model, provider } => {
let registry = ModelRegistry::default();
// Only `model resolve --provider X` is a hypothetical. The
// top-level `--provider` is the route this process is actually on,
// and it is already folded into `resolved_runtime` — treating it as
@@ -5838,7 +5836,48 @@ fn run_model_command(
"requested: {}",
if source.is_explicit() { model } else { "" }
);
println!("resolved: {model}");
let kind = ProviderKind::parse(provider).unwrap_or(ProviderKind::Custom);
let identity = if kind == ProviderKind::Custom {
store.config.named_custom_provider_id().unwrap_or(provider)
} else {
provider
};
let base = if kind == resolved_runtime.provider {
resolved_runtime.base_url.clone()
} else {
store
.config
.providers
.for_provider(kind)
.base_url
.clone()
.unwrap_or_else(|| kind.provider().default_base_url().into())
};
let candidate = codewhale_tui::route_preferences::resolve_model(
store.path(),
identity,
&base,
Some(model),
);
println!(
"resolved: {}",
candidate
.as_ref()
.map_or(model, |route| route.wire_model_id().as_str())
);
match candidate.as_ref() {
Ok(route) => {
if let Some(canonical) = route.canonical_model() {
println!("canonical_model: {}", canonical.as_str());
}
println!("limits: {}", serde_json::to_string(&route.limits())?);
println!(
"capabilities: {}",
serde_json::to_string(&route.capabilities())?
);
}
Err(error) => println!("route_error: {error}"),
}
println!("provider: {provider}");
println!("used_fallback: {}", !source.is_explicit());
println!(
@@ -5860,12 +5899,47 @@ fn run_model_command(
// inside a named route. The subcommand provider wins; otherwise
// the configured runtime provider remains authoritative. Model
// text never authorizes switching providers or credential slots.
let provider_hint = subcommand_provider.or(Some(resolved_runtime.provider));
let resolved = registry.resolve(queried, provider_hint)?;
println!("requested: {}", resolved.requested.unwrap_or_default());
println!("resolved: {}", resolved.resolved.id);
println!("provider: {}", resolved.resolved.provider.as_str());
println!("used_fallback: {}", resolved.used_fallback);
let provider = subcommand_provider.unwrap_or(resolved_runtime.provider);
let identity = if provider == ProviderKind::Custom && subcommand_provider.is_none() {
store
.config
.named_custom_provider_id()
.unwrap_or(provider.as_str())
} else {
provider.as_str()
};
let base = if provider == resolved_runtime.provider {
resolved_runtime.base_url.clone()
} else {
store
.config
.providers
.for_provider(provider)
.base_url
.clone()
.unwrap_or_else(|| provider.provider().default_base_url().into())
};
let candidate = codewhale_tui::route_preferences::resolve_model(
store.path(),
identity,
&base,
queried,
)?;
println!("requested: {}", queried.unwrap_or_default());
println!("resolved: {}", candidate.wire_model_id().as_str());
println!("provider: {identity}");
println!(
"used_fallback: {}",
queried.is_none() || queried == Some("auto")
);
if let Some(canonical) = candidate.canonical_model() {
println!("canonical_model: {}", canonical.as_str());
}
println!("limits: {}", serde_json::to_string(&candidate.limits())?);
println!(
"capabilities: {}",
serde_json::to_string(&candidate.capabilities())?
);
println!(
"provider_source: {}",
if subcommand_provider.is_some() {
+23 -5
View File
@@ -113,6 +113,22 @@ fn resolve_reports_a_provider_scoped_model_as_explicitly_configured() {
Some("config [providers.*].model"),
"{report:?}"
);
let base = codewhale_config::ProviderKind::Zai
.provider()
.default_base_url();
let configured = format!(
"provider = 'zai'\n[providers.zai]\napi_key = 'fixture-only'\nmodel = 'GLM-5.2'\n[[custom_models]]\nprovider = 'zai'\nbase_url = '{base}'\nid = 'operator-only-fixture'\nlimit = {{ context = 96000, output = 8000 }}\n"
);
let report = resolve_with_config(&configured, &["operator-only-fixture"]);
assert_eq!(
report.get("resolved").map(String::as_str),
Some("operator-only-fixture")
);
assert_eq!(report.get("provider").map(String::as_str), Some("zai"));
assert_eq!(
report.get("used_fallback").map(String::as_str),
Some("false")
);
}
#[test]
@@ -323,12 +339,14 @@ fn top_level_provider_and_model_flags_report_the_runtime_route() {
/// `kimi-k3`).
#[test]
fn moonshot_k3_products_resolve_without_crossing_providers() {
for model in ["kimi-k3", "k3"] {
let report = resolve_with_global_flags(
"provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\n",
&[],
&[model, "--provider", "moonshot"],
for (model, base_url) in [
("kimi-k3", "https://api.moonshot.ai/v1"),
("k3", "https://api.kimi.com/coding/v1"),
] {
let config = format!(
"provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\nbase_url = \"{base_url}\"\n"
);
let report = resolve_with_global_flags(&config, &[], &[model, "--provider", "moonshot"]);
assert_eq!(
report.get("provider").map(String::as_str),
+1
View File
@@ -16,6 +16,7 @@ anyhow.workspace = true
base64 = "0.23.1"
chrono.workspace = true
ring = "0.17.14"
rust_decimal = { version = "1.43.0", default-features = false, features = ["std"] }
semver.workspace = true
codewhale-execpolicy = { path = "../execpolicy", version = "0.10.2" }
codewhale-paths = { path = "../paths", version = "0.10.2" }
+11 -66
View File
@@ -1,15 +1,11 @@
{
"_about": "Codewhale's corrections to Models.dev rows (#6396). Applied to the bundled seed and to live Models.dev rows alike, above both and below signed cloud facts and provider rosters, so a correction holds on every install, online or offline. Corrections only fix rows that exist: they never add or hide one. Each entry says why; a correction whose value now equals upstream is stale and should be deleted. See docs/CATALOG_REFRESH.md.",
"revision": "2026-09-26",
"revision": "2026-10-12",
"providers": [
{
"provider": "deepseek",
"pricing_withheld": "DeepSeek bills by time of day (off-peak rates and a weekend rule). The time-aware table in crates/tui/src/pricing.rs prices these routes; one flat catalog rate would disagree with it."
},
{
"provider": "xai",
"pricing_withheld": "xAI doubles its rates once a prompt passes 200K tokens. A flat catalog rate would understate every long session."
},
{
"provider": "xiaomi-mimo",
"pricing_withheld": "Published PAYG rates apply only to sk- pay-as-you-go keys. Token Plan keys draw on quota, and the catalog cannot tell the two billing surfaces apart."
@@ -65,60 +61,9 @@
"id": "GLM-5.3",
"pricing_withheld": "Codewhale's default Z.ai route is the GLM Coding Plan (DEFAULT_ZAI_BASE_URL, api.z.ai/api/coding/paas/v4), which bills credit multipliers, not USD per token; see the seed's pending_release_metadata."
},
{
"provider": "openrouter",
"id": "deepseek/deepseek-v4-pro",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "openrouter",
"id": "deepseek/deepseek-v4-flash",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "together",
"id": "deepseek-ai/DeepSeek-V4-Pro",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "together",
"id": "deepseek-ai/DeepSeek-V4-Flash",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "fireworks",
"id": "accounts/fireworks/models/deepseek-v4-pro",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "novita",
"id": "deepseek/deepseek-v4-pro",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "novita",
"id": "deepseek/deepseek-v4-flash",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "siliconflow",
"id": "deepseek-ai/DeepSeek-V4-Pro",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "siliconflow",
"id": "deepseek-ai/DeepSeek-V4-Flash",
"pricing_withheld": "Codewhale prices hosted DeepSeek from the reviewed provider-docs table in crates/tui/src/pricing.rs; a catalog rate would outrank it (for Fireworks V4 Pro the table has 1.74 and Models.dev 1.20 per 1M input)."
},
{
"provider": "novita",
"id": "deepseek/deepseek-v4-pro",
"max_output": 384000,
"reason": "DeepSeek publishes a 384K output limit; Models.dev lists 393216 for Novita (384 x 1024). Which one the API accepts is unverified, and the lower bound is accepted either way."
},
{
"provider": "novita",
"id": "deepseek/deepseek-v4-flash",
"max_output": 384000,
"reason": "DeepSeek publishes a 384K output limit; Models.dev lists 393216 for Novita (384 x 1024). Which one the API accepts is unverified, and the lower bound is accepted either way."
},
@@ -128,16 +73,6 @@
"reasoning_options": [],
"reason": "xAI documents no reasoning_effort control for grok-4.3, so Codewhale sends none (6be755f31 / #6501); Models.dev lists an effort ladder."
},
{
"provider": "minimax",
"id": "MiniMax-M3",
"pricing_withheld": "MiniMax doubles M3 rates once a prompt passes 512K tokens. A flat catalog rate would understate long sessions."
},
{
"provider": "minimax-anthropic",
"id": "MiniMax-M3",
"pricing_withheld": "MiniMax doubles M3 rates once a prompt passes 512K tokens. A flat catalog rate would understate long sessions."
},
{
"provider": "minimax",
"id": "MiniMax-M3",
@@ -409,6 +344,16 @@
"cache_read_per_m": 0.25
},
"reason": "Arcee publishes no cache rate (docs.arcee.ai/get-started/pricing), so cache hits bill at the input rate, as in the reviewed table in crates/tui/src/pricing.rs; Models.dev lists a 0.06 cache-read rate."
},
{
"provider": "fireworks",
"id": "accounts/fireworks/models/deepseek-v4-pro",
"pricing": {
"input_per_m": 1.74,
"output_per_m": 3.48,
"cache_read_per_m": 0.145
},
"reason": "Fireworks hosts DeepSeek V4 Pro at its own published rates, as in the reviewed table in crates/tui/src/pricing.rs; Models.dev lists a flat 1.2/1.2 rate with a 0.6 cache-read rate for this deployment."
}
],
"reviewed": {
File diff suppressed because it is too large. Load diff
+2 -1
View File
@@ -1218,7 +1218,8 @@ impl CatalogCompiler {
.chain(self.overrides)
{
if let Some(lower) = merged.get(&row.merge_key()) {
row.complete_from(lower, None);
let base_url = row.provider_api.clone();
row.complete_from(lower, base_url.as_deref());
}
merged.insert(row.merge_key(), row);
}
+38 -6
View File
@@ -53,6 +53,7 @@ impl ConfiguredModel {
provider: self.provider.clone(),
wire_model_id: self.id.clone(),
endpoint_key: "chat".into(),
provider_api: Some(self.base_url.clone()),
limit: self.limit.clone(),
cost: self.cost.clone(),
modalities: self.modalities.clone(),
@@ -115,11 +116,12 @@ pub fn validate_configured_models(models: &[ConfiguredModel]) -> anyhow::Result<
{
return Err(invalid("limit"));
}
if model
.cost
.as_ref()
.is_some_and(|cost| !crate::pricing::catalog_cost_is_valid(cost))
{
if model.cost.as_ref().is_some_and(|cost| {
!crate::pricing::catalog_cost_is_valid(cost)
|| cost.context_tiers().iter().any(|tier| {
tier.tier.kind != "context" || !tier.cost.context_tiers().is_empty()
})
}) {
return Err(invalid("cost"));
}
if model.extras.keys().any(|key| {
@@ -187,7 +189,37 @@ fn deserialize_limit<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ModelsDev
deserialize_known(d, &["context", "input", "output"])
}
fn deserialize_cost<'de, D: Deserializer<'de>>(d: D) -> Result<Option<ModelsDevCost>, D::Error> {
deserialize_known(d, &["input", "output", "cache_read", "cache_write"])
fn supported_fields(value: &toml::Value, is_tier: bool) -> bool {
let Some(table) = value.as_table() else {
return false;
};
table.iter().all(|(key, value)| match key.as_str() {
"input" | "output" | "cache_read" | "cache_write" | "reasoning" | "input_audio"
| "output_audio" => true,
"tiers" => value
.as_array()
.is_some_and(|tiers| tiers.iter().all(|tier| supported_fields(tier, true))),
"context_over_200k" => supported_fields(value, false),
"tier" if is_tier => value.as_table().is_some_and(|tier| {
tier.keys()
.all(|key| matches!(key.as_str(), "type" | "size"))
}),
_ => false,
})
}
Option::<toml::Value>::deserialize(d)?
.map(|value| {
if !supported_fields(&value, false) {
return Err(serde::de::Error::custom(
"unsupported nested custom model metadata field",
));
}
value
.try_into()
.map_err(|_| serde::de::Error::custom("invalid custom model metadata"))
})
.transpose()
}
fn deserialize_modalities<'de, D: Deserializer<'de>>(
d: D,
+22 -5
View File
@@ -1262,15 +1262,32 @@ fn bundled_asset_pricing_is_honest() {
Some(131_072)
);
// M3 has input-length and service tiers that the flat catalog cost shape
// cannot represent, so the bundled route row stays honestly unpriced.
// M3 publishes a context tier at 512K tokens. The row ships priced only
// because the tier travels with it; dropping the tier would underbill.
let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
assert!(minimax_m3.cost.is_none());
let cost = minimax_m3
.cost
.as_ref()
.expect("M3 is priced with its tier");
assert_eq!(cost.input, Some(0.30));
assert_eq!(cost.output, Some(1.20));
let tiers = cost.tiers.as_ref().expect("M3 keeps its context tier");
assert!(tiers.iter().any(|t| t.tier.size == 512_000));
// Grok 4.6 also has a prompt-length tier, starting at 200K input tokens.
// The usage-aware TUI table prices it; a flat catalog row would underbill.
// It likewise ships priced with the tier preserved for usage-aware pricing.
let grok_46 = find(&rows, "xai", "grok-4.6");
assert!(grok_46.cost.is_none());
let cost = grok_46
.cost
.as_ref()
.expect("Grok 4.6 is priced with its tier");
assert_eq!(cost.input, Some(2.0));
assert_eq!(cost.output, Some(6.0));
let tiers = cost
.tiers
.as_ref()
.expect("Grok 4.6 keeps its context tier");
assert!(tiers.iter().any(|t| t.tier.size == 200_000));
let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
let cost = minimax_m2_7.cost.as_ref().expect("M2.7 is priced");
+8 -4
View File
@@ -178,7 +178,11 @@ impl ModelReferenceCard {
let Some(pricing) = self.pricing.as_ref() else {
return "unknown".to_string();
};
if pricing.input_per_million.is_none() && pricing.output_per_million.is_none() {
if matches!(
pricing.status_label_at(crate::catalog::now_unix()),
"stale" | "unverified" | "unavailable"
) || (pricing.input_per_million.is_none() && pricing.output_per_million.is_none())
{
return "unknown".to_string();
}
let symbol = currency_symbol(&pricing.currency);
@@ -548,12 +552,12 @@ mod tests {
.expect("bundled minimax row");
assert_eq!(minimax.price_label(), "$0.30 / $1.20 per Mtok");
// MiniMax-M3 ships without verified per-token pricing, so its price
// stays honestly unknown rather than inheriting a sibling's rate.
// MiniMax-M3 carries its own base rate and a context tier.
let minimax_m3 = db
.find("minimax", "MiniMax-M3")
.expect("bundled minimax m3 row");
assert_eq!(minimax_m3.price_label(), "unknown");
assert_eq!(minimax_m3.price_label(), "$0.30 / $1.20 per Mtok");
assert_eq!(minimax_m3.pricing.as_ref().unwrap().context_tiers.len(), 1);
}
#[test]
+2 -4
View File
@@ -519,7 +519,7 @@ fn context_tier_type() -> String {
}
impl ModelsDevCost {
fn validate(&self) -> serde_json::Result<()> {
pub(crate) fn validate(&self) -> serde_json::Result<()> {
if [
self.input,
self.output,
@@ -578,9 +578,7 @@ impl ModelsDevCost {
#[must_use]
pub fn context_tiers(&self) -> Vec<ModelsDevCostTier> {
let mut tiers = self.tiers.clone().unwrap_or_default();
if !tiers
.iter()
.any(|tier| tier.tier.kind == "context" && tier.tier.size == 200_000)
if self.tiers.is_none()
&& let Some(cost) = &self.context_over_200k
{
tiers.push(ModelsDevCostTier {
+418 -69
View File
@@ -18,10 +18,11 @@
//! never a fabricated token price, and never an implicit "free" for
//! local/custom/subscription routes.
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use crate::catalog::{CatalogOffering, CatalogSource};
use crate::models_dev::ModelsDevCost;
use crate::models_dev::{ModelsDevCost, ModelsDevCostTier};
use crate::route::PricingSku;
/// Billing currency for a pricing row. Models.dev publishes USD per-million
@@ -38,13 +39,12 @@ pub enum Currency {
/// Where a pricing row came from. Retained so the UI can show provenance and so
/// stale/unknown prices are never silently treated as authoritative.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum PricingProvenance {
/// Sourced from the Models.dev catalog — the bundled snapshot or a live
/// refresh of it. Both are the same third-party claim about a model, so
/// they share a provenance; `effective_at` is `Some` only for the refresh.
/// Published-price estimate from the offline Models.dev snapshot.
ModelsDevBundled,
ModelsDevLive,
/// From a provider live `/models` (or pricing) refresh — a rate the
/// provider itself published for one endpoint, not a catalog's report of it.
ProviderLive,
@@ -55,6 +55,7 @@ pub enum PricingProvenance {
/// User-supplied override (custom endpoint, enterprise terms, local route).
UserOverride,
/// No sourced price.
#[default]
Unknown,
/// From the Codewhale-owned signed/bundled catalog.
CodewhaleCatalog,
@@ -68,6 +69,7 @@ impl PricingProvenance {
pub fn label(&self) -> &'static str {
match self {
Self::ModelsDevBundled => "models_dev_bundled",
Self::ModelsDevLive => "models_dev_live",
Self::ProviderLive => "provider_live",
Self::ProviderDocs => "provider_docs",
Self::UserOverride => "user_override",
@@ -148,7 +150,7 @@ impl LivePricingDefect {
///
/// Producing this from provider-specific usage payloads (Chat Completions,
/// Responses, Anthropic) is a separate concern; this layer only consumes it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TokenUsage {
/// Non-cached input (prompt) tokens.
pub input: u64,
@@ -164,6 +166,14 @@ pub struct TokenUsage {
pub cache_read: u64,
/// Cache-write (cache-creation) tokens, billed at the cache-write rate.
pub cache_write: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_audio: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_audio: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub request_usages: Vec<TokenUsage>,
}
/// A canonical billable token class.
@@ -177,11 +187,22 @@ pub enum TokenClass {
Output,
CacheRead,
CacheWrite,
Reasoning,
InputAudio,
OutputAudio,
}
impl TokenClass {
/// Every class, in reporting order.
pub const ALL: [Self; 4] = [Self::Input, Self::Output, Self::CacheRead, Self::CacheWrite];
pub const ALL: [Self; 7] = [
Self::Input,
Self::Output,
Self::CacheRead,
Self::CacheWrite,
Self::Reasoning,
Self::InputAudio,
Self::OutputAudio,
];
/// Stable, non-localized identifier for logs, JSON, and scorecards.
#[must_use]
@@ -191,6 +212,9 @@ impl TokenClass {
Self::Output => "output",
Self::CacheRead => "cache_read",
Self::CacheWrite => "cache_write",
Self::Reasoning => "reasoning",
Self::InputAudio => "input_audio",
Self::OutputAudio => "output_audio",
}
}
@@ -202,6 +226,9 @@ impl TokenClass {
Self::Output => usage.output,
Self::CacheRead => usage.cache_read,
Self::CacheWrite => usage.cache_write,
Self::Reasoning => usage.reasoning.unwrap_or(0),
Self::InputAudio => usage.input_audio.unwrap_or(0),
Self::OutputAudio => usage.output_audio.unwrap_or(0),
}
}
}
@@ -211,7 +238,7 @@ impl TokenClass {
/// Prices are per million tokens in [`Currency`]. Any field may be unknown
/// (`None`); [`OfferingPricing::estimate_cost`] refuses to invent a number for a
/// used class whose price is unknown.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct OfferingPricing {
/// Provider id serving the offering.
pub provider: String,
@@ -234,11 +261,32 @@ pub struct OfferingPricing {
/// Cache-write price per million tokens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_per_million: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_per_million: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_audio_per_million: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_audio_per_million: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub context_tiers: Vec<ModelsDevCostTier>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub context_tiers_inclusive: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub rate_withheld: bool,
/// Where the price came from.
pub provenance: PricingProvenance,
/// Unix seconds the price was fetched / became effective, when known.
/// Actual rate-effective time, when the source declares one. Old serialized
/// provider rows used this as a fetch time; `fetched_at` takes precedence.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fetched_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream_last_updated: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_ref: Option<String>,
/// Fingerprint of the base URL this price was fetched from, for
/// [`PricingProvenance::ProviderLive`] rows.
///
@@ -251,16 +299,30 @@ pub struct OfferingPricing {
pub endpoint_fingerprint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QuotedCost {
pub amount: String,
pub currency: Currency,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PricingQuoteError {
MissingRate(TokenClass),
MissingUsage(TokenClass),
InvalidUsage,
UnsupportedTier,
InvalidRate,
}
impl OfferingPricing {
/// Derive a pricing row from a catalog offering's `cost`, when priced.
///
/// Returns `None` when the offering carries no cost, or a cost object with
/// no concrete price field — those routes are *unknown*, not free, and the
/// caller should render them as such (see [`route_pricing_sku`]).
/// Returns `None` when the offering carries no cost. An explicitly empty
/// cost object stays withheld, so another source cannot refill it.
///
/// Models.dev `cost` values are USD per million tokens, so the currency is
/// [`Currency::Usd`]; provenance and `effective_at` follow the offering's
/// [`CatalogSource`].
/// [`Currency::Usd`]; provenance and `fetched_at` follow the offering's
/// [`CatalogSource`]. The upstream update date stays separate.
#[must_use]
pub fn from_catalog_offering(offering: &CatalogOffering) -> Option<Self> {
Self::from_catalog_offering_at(offering, crate::catalog::now_unix())
@@ -288,13 +350,14 @@ impl OfferingPricing {
if !catalog_cost_is_valid(cost) {
return None;
}
if cost.input.is_none()
let rate_withheld = cost.input.is_none()
&& cost.output.is_none()
&& cost.cache_read.is_none()
&& cost.cache_write.is_none()
{
return None;
}
&& cost.reasoning.is_none()
&& cost.input_audio.is_none()
&& cost.output_audio.is_none()
&& cost.context_tiers().is_empty();
Some(Self {
provider: offering.provider.clone(),
wire_model_id: offering.wire_model_id.clone(),
@@ -304,8 +367,34 @@ impl OfferingPricing {
output_per_million: cost.output,
cache_read_per_million: cost.cache_read,
cache_write_per_million: cost.cache_write,
reasoning_per_million: cost.reasoning,
input_audio_per_million: cost.input_audio,
output_audio_per_million: cost.output_audio,
context_tiers: cost.context_tiers(),
context_tiers_inclusive: offering.provider == "xai",
rate_withheld,
provenance: provenance_from_source(source),
effective_at: effective_at_from_source(source),
effective_at: None,
fetched_at: fetched_at_from_source(source),
expires_at: match source {
CatalogSource::CloudFacts { valid_until, .. } => *valid_until,
CatalogSource::Live { fetched_at, .. }
| CatalogSource::ModelsDevLive { fetched_at } => {
fetched_at.checked_add(LIVE_PRICING_MAX_AGE_SECS)
}
_ => None,
},
upstream_last_updated: offering.last_updated.clone(),
source_ref: matches!(
source,
CatalogSource::Bundled | CatalogSource::ModelsDevLive { .. }
)
.then(|| {
format!(
"https://models.dev/catalog.json#{}:{}",
offering.provider, offering.wire_model_id
)
}),
endpoint_fingerprint: endpoint_fingerprint_from_source(source),
})
}
@@ -317,17 +406,58 @@ impl OfferingPricing {
|| self.output_per_million.is_some()
|| self.cache_read_per_million.is_some()
|| self.cache_write_per_million.is_some()
|| self.reasoning_per_million.is_some()
|| self.input_audio_per_million.is_some()
|| self.output_audio_per_million.is_some()
|| !self.context_tiers.is_empty()
}
/// Whether this price is older than `max_age_secs` at `now_unix`.
///
/// Rows without an `effective_at` (bundled snapshot / user override) carry
/// no fetch clock and are not considered age-stale here; live rows are.
/// Live rows age against their fetch clock. Declared effective dates do
/// not become fetch dates; explicit expiry applies to every source.
#[must_use]
pub fn is_stale(&self, now_unix: u64, max_age_secs: u64) -> bool {
match self.effective_at {
Some(t) => now_unix.saturating_sub(t) >= max_age_secs,
None => false,
self.expires_at.is_some_and(|expires| now_unix >= expires)
|| (matches!(
self.provenance,
PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive
) && self
.fetched_at
.or(self.effective_at)
.is_some_and(|time| time > now_unix || now_unix - time >= max_age_secs))
}
#[must_use]
pub fn status_label_at(&self, now: u64) -> &'static str {
if self.rate_withheld || !self.has_any_price() {
return "unavailable";
}
if self.is_stale(now, LIVE_PRICING_MAX_AGE_SECS) {
return "stale";
}
if matches!(
self.provenance,
PricingProvenance::ProviderLive | PricingProvenance::ModelsDevLive
) && self
.live_pricing_defect(
self.endpoint_fingerprint.as_deref(),
Some(now),
LIVE_PRICING_MAX_AGE_SECS,
)
.is_some()
{
return "unverified";
}
match self.provenance {
PricingProvenance::Unknown => "unverified",
PricingProvenance::ModelsDevBundled | PricingProvenance::CodewhaleCatalog => {
"bundled_estimate"
}
PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive => "live_estimate",
PricingProvenance::ProviderDocs => "documented_estimate",
PricingProvenance::UserOverride => "operator_estimate",
PricingProvenance::CloudFacts => "verified_cloud_estimate",
}
}
@@ -351,22 +481,27 @@ impl OfferingPricing {
now_unix: Option<u64>,
max_age_secs: u64,
) -> Option<LivePricingDefect> {
if self.provenance != PricingProvenance::ProviderLive {
if !matches!(
self.provenance,
PricingProvenance::ProviderLive | PricingProvenance::ModelsDevLive
) {
return None;
}
let Some(row_fingerprint) = self.endpoint_fingerprint.as_deref() else {
return Some(LivePricingDefect::MissingEndpointFingerprint);
};
let Some(route_fingerprint) = route_endpoint_fingerprint else {
return Some(LivePricingDefect::UnknownRouteEndpoint);
};
if row_fingerprint != route_fingerprint {
return Some(LivePricingDefect::EndpointMismatch {
row_fingerprint: row_fingerprint.to_string(),
route_fingerprint: route_fingerprint.to_string(),
});
if self.provenance == PricingProvenance::ProviderLive {
let Some(row_fingerprint) = self.endpoint_fingerprint.as_deref() else {
return Some(LivePricingDefect::MissingEndpointFingerprint);
};
let Some(route_fingerprint) = route_endpoint_fingerprint else {
return Some(LivePricingDefect::UnknownRouteEndpoint);
};
if row_fingerprint != route_fingerprint {
return Some(LivePricingDefect::EndpointMismatch {
row_fingerprint: row_fingerprint.to_string(),
route_fingerprint: route_fingerprint.to_string(),
});
}
}
let Some(effective_at) = self.effective_at else {
let Some(fetched_at) = self.fetched_at.or(self.effective_at) else {
return Some(LivePricingDefect::MissingTimestamp);
};
// Without a clock the age is unknowable, so the row stays unproven
@@ -374,8 +509,11 @@ impl OfferingPricing {
let Some(now_unix) = now_unix else {
return Some(LivePricingDefect::MissingTimestamp);
};
let age_secs = now_unix.saturating_sub(effective_at);
if age_secs >= max_age_secs {
let age_secs = now_unix.saturating_sub(fetched_at);
if fetched_at > now_unix
|| age_secs >= max_age_secs
|| self.expires_at.is_some_and(|expires| now_unix >= expires)
{
return Some(LivePricingDefect::Stale {
age_secs,
max_age_secs,
@@ -392,6 +530,9 @@ impl OfferingPricing {
TokenClass::Output => self.output_per_million,
TokenClass::CacheRead => self.cache_read_per_million,
TokenClass::CacheWrite => self.cache_write_per_million,
TokenClass::Reasoning => self.reasoning_per_million,
TokenClass::InputAudio => self.input_audio_per_million,
TokenClass::OutputAudio => self.output_audio_per_million,
}
}
@@ -402,37 +543,205 @@ impl OfferingPricing {
/// gap instead of presenting a silently under-counted total.
#[must_use]
pub fn unpriced_used_classes(&self, usage: &TokenUsage) -> Vec<TokenClass> {
if !usage.request_usages.is_empty() {
if usage.request_usages.len() > 1024
|| usage
.request_usages
.iter()
.any(|request| !request.request_usages.is_empty())
{
return Vec::new();
}
return usage
.request_usages
.iter()
.flat_map(|request| self.unpriced_used_classes(request))
.collect::<std::collections::BTreeSet<_>>()
.into_iter()
.collect();
}
let Ok((cost, billable)) = self.rates_and_usage(usage) else {
return Vec::new();
};
TokenClass::ALL
.into_iter()
.filter(|class| class.tokens(usage) > 0 && self.price_per_million(*class).is_none())
.filter(|class| class.tokens(&billable) > 0 && rate_for_class(&cost, *class).is_none())
.collect()
}
/// Estimate the cost of `usage` in this row's [`Currency`].
///
/// Returns `None` if any usage class with a non-zero token count has an
/// unknown price — the estimate would otherwise silently under-report. With
/// all-zero usage the cost is `Some(0.0)`.
#[must_use]
pub fn estimate_cost(&self, usage: &TokenUsage) -> Option<f64> {
let mut total = 0.0_f64;
for class in TokenClass::ALL {
let tokens = class.tokens(usage);
if tokens > 0 {
let price = self.price_per_million(class)?;
// Per-turn token counts are far below 2^53, so this cast is
// exact; revisit if TokenUsage ever aggregates across sessions.
let component = (tokens as f64 / 1_000_000.0) * price;
if !component.is_finite() || component < 0.0 {
return None;
}
total += component;
if !total.is_finite() || total < 0.0 {
return None;
fn rates_and_usage(
&self,
usage: &TokenUsage,
) -> Result<(ModelsDevCost, TokenUsage), PricingQuoteError> {
let base = self.cost_structure();
if !catalog_cost_is_valid(&base) {
return Err(PricingQuoteError::InvalidRate);
}
if self
.context_tiers
.iter()
.any(|tier| tier.tier.kind != "context" || !tier.cost.context_tiers().is_empty())
{
return Err(PricingQuoteError::UnsupportedTier);
}
let total_input = usage
.input
.checked_add(usage.cache_read)
.and_then(|total| total.checked_add(usage.cache_write))
.ok_or(PricingQuoteError::InvalidUsage)?;
let cost = self
.context_tiers
.iter()
.filter(|tier| {
if self.context_tiers_inclusive {
total_input >= tier.tier.size
} else {
total_input > tier.tier.size
}
})
.max_by_key(|tier| tier.tier.size)
.map_or(base, |tier| tier.cost.clone());
let mut billable = usage.clone();
let reasoning = usage.reasoning.unwrap_or(0);
let audio_output = usage.output_audio.unwrap_or(0);
let audio_input = usage.input_audio.unwrap_or(0);
if reasoning
.checked_add(audio_output)
.is_none_or(|total| total > usage.output)
|| audio_input > usage.input
{
return Err(PricingQuoteError::InvalidUsage);
}
for (class, tokens, rate, total) in [
(
TokenClass::Reasoning,
usage.reasoning,
cost.reasoning,
usage.output,
),
(
TokenClass::InputAudio,
usage.input_audio,
cost.input_audio,
total_input,
),
(
TokenClass::OutputAudio,
usage.output_audio,
cost.output_audio,
usage.output,
),
] {
if rate.is_some() && tokens.is_none() && total > 0 {
return Err(PricingQuoteError::MissingUsage(class));
}
}
Some(total)
if audio_input > 0 && (usage.cache_read > 0 || usage.cache_write > 0) {
return Err(PricingQuoteError::MissingUsage(TokenClass::InputAudio));
}
billable.input = usage.input - audio_input;
billable.output = usage.output - audio_output;
if cost.reasoning.is_some() {
billable.output -= reasoning;
} else {
billable.reasoning = None;
}
Ok((cost, billable))
}
#[must_use]
pub fn cost_structure(&self) -> ModelsDevCost {
ModelsDevCost {
input: self.input_per_million,
output: self.output_per_million,
cache_read: self.cache_read_per_million,
cache_write: self.cache_write_per_million,
reasoning: self.reasoning_per_million,
input_audio: self.input_audio_per_million,
output_audio: self.output_audio_per_million,
tiers: (!self.context_tiers.is_empty()).then(|| self.context_tiers.clone()),
context_over_200k: None,
}
}
pub fn quote_cost(&self, usage: &TokenUsage) -> Result<QuotedCost, PricingQuoteError> {
if self.rate_withheld {
return Err(PricingQuoteError::MissingRate(TokenClass::Input));
}
if !usage.request_usages.is_empty() {
if usage.request_usages.len() > 1024 {
return Err(PricingQuoteError::InvalidUsage);
}
let mut aggregate = [0_u64; 4];
let mut total = Decimal::ZERO;
for request in &usage.request_usages {
if !request.request_usages.is_empty() {
return Err(PricingQuoteError::InvalidUsage);
}
for (sum, count) in aggregate.iter_mut().zip([
request.input,
request.output,
request.cache_read,
request.cache_write,
]) {
*sum = sum
.checked_add(count)
.ok_or(PricingQuoteError::InvalidUsage)?;
}
let quote = self.quote_cost(request)?;
let amount = Decimal::from_str_exact(&quote.amount)
.map_err(|_| PricingQuoteError::InvalidRate)?;
total = add_decimal_exact(total, amount)?;
}
if aggregate
!= [
usage.input,
usage.output,
usage.cache_read,
usage.cache_write,
]
{
return Err(PricingQuoteError::InvalidUsage);
}
return Ok(QuotedCost {
amount: total.normalize().to_string(),
currency: self.currency.clone(),
});
}
let (cost, billable) = self.rates_and_usage(usage)?;
let mut total = Decimal::ZERO;
for class in TokenClass::ALL {
let tokens = class.tokens(&billable);
if tokens == 0 {
continue;
}
let rate = rate_for_class(&cost, class).ok_or(PricingQuoteError::MissingRate(class))?;
let rate = Decimal::from_str_exact(&rate.to_string())
.map_err(|_| PricingQuoteError::InvalidRate)?;
let mut coefficient = rate
.mantissa()
.checked_mul(i128::from(tokens))
.ok_or(PricingQuoteError::InvalidRate)?;
let mut scale = rate.scale() + 6;
while scale > 0 && coefficient % 10 == 0 {
coefficient /= 10;
scale -= 1;
}
let component = Decimal::try_from_i128_with_scale(coefficient, scale)
.map_err(|_| PricingQuoteError::InvalidRate)?;
total = add_decimal_exact(total, component)?;
}
Ok(QuotedCost {
amount: total.normalize().to_string(),
currency: self.currency.clone(),
})
}
/// Estimate in major currency units; floating point is a display projection
/// of the exact decimal quote, never a second calculation authority.
#[must_use]
pub fn estimate_cost(&self, usage: &TokenUsage) -> Option<f64> {
self.quote_cost(usage).ok()?.amount.parse().ok()
}
/// Project to the coarse route-facing meter shape.
@@ -460,8 +769,8 @@ impl OfferingPricing {
/// An offering with a usable input/output rate becomes [`PricingSku::Token`];
/// everything else — no cost, a cost object with no concrete price, or a
/// cache-only price — becomes [`PricingSku::UnknownOrStale`] rather than a
/// fabricated zero price. (`from_catalog_offering` collapses the unpriced case
/// to `None`; `to_route_sku` collapses the cache-only case.)
/// fabricated zero price. An explicit empty cost stays withheld;
/// `to_route_sku` keeps both it and a cache-only rate unknown.
#[must_use]
pub fn route_pricing_sku(offering: &CatalogOffering) -> PricingSku {
OfferingPricing::from_catalog_offering(offering)
@@ -519,18 +828,58 @@ pub const MAX_PLAUSIBLE_PRICE_PER_MILLION: f64 = 100_000.0;
/// silently under-counted bill, which is worse than no price at all.
#[must_use]
pub fn catalog_cost_is_valid(cost: &ModelsDevCost) -> bool {
[cost.input, cost.output, cost.cache_read, cost.cache_write]
cost.validate().is_ok()
&& cost.tiers.as_ref().is_none_or(|tiers| tiers.len() <= 128)
&& [
cost.input,
cost.output,
cost.cache_read,
cost.cache_write,
cost.reasoning,
cost.input_audio,
cost.output_audio,
]
.into_iter()
.flatten()
.all(|price| price.is_finite() && (0.0..=MAX_PLAUSIBLE_PRICE_PER_MILLION).contains(&price))
&& cost
.tiers
.iter()
.flatten()
.all(|tier| catalog_cost_is_valid(&tier.cost))
&& cost
.context_over_200k
.as_ref()
.is_none_or(|cost| catalog_cost_is_valid(cost))
}
fn add_decimal_exact(total: Decimal, component: Decimal) -> Result<Decimal, PricingQuoteError> {
let next = total
.checked_add(component)
.ok_or(PricingQuoteError::InvalidRate)?;
if next.checked_sub(total) != Some(component) {
return Err(PricingQuoteError::InvalidRate);
}
Ok(next)
}
fn rate_for_class(cost: &ModelsDevCost, class: TokenClass) -> Option<f64> {
match class {
TokenClass::Input => cost.input,
TokenClass::Output => cost.output,
TokenClass::CacheRead => cost.cache_read,
TokenClass::CacheWrite => cost.cache_write,
TokenClass::Reasoning => cost.reasoning,
TokenClass::InputAudio => cost.input_audio,
TokenClass::OutputAudio => cost.output_audio,
}
}
fn provenance_from_source(source: &CatalogSource) -> PricingProvenance {
match source {
CatalogSource::CloudFacts { .. } => PricingProvenance::CloudFacts,
CatalogSource::Bundled | CatalogSource::ModelsDevLive { .. } => {
PricingProvenance::ModelsDevBundled
}
CatalogSource::Bundled => PricingProvenance::ModelsDevBundled,
CatalogSource::ModelsDevLive { .. } => PricingProvenance::ModelsDevLive,
CatalogSource::Live { .. } => PricingProvenance::ProviderLive,
CatalogSource::ConfigOverride | CatalogSource::UserOverride => {
PricingProvenance::UserOverride
@@ -539,7 +888,7 @@ fn provenance_from_source(source: &CatalogSource) -> PricingProvenance {
}
}
fn effective_at_from_source(source: &CatalogSource) -> Option<u64> {
fn fetched_at_from_source(source: &CatalogSource) -> Option<u64> {
match source {
CatalogSource::Live { fetched_at, .. }
| CatalogSource::ModelsDevLive { fetched_at }
+46 -5
View File
@@ -42,7 +42,7 @@ fn maps_models_dev_cost_with_bundled_provenance_in_usd() {
#[test]
fn malformed_catalog_prices_fail_closed_at_every_projection() {
for invalid in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -0.01] {
for field in 0..4 {
for field in 0..7 {
let mut offering = priced(CatalogSource::Bundled);
offering.provider = "openrouter".to_string();
offering.wire_model_id = "openai/gpt-5.5".to_string();
@@ -52,6 +52,9 @@ fn malformed_catalog_prices_fail_closed_at_every_projection() {
1 => cost.output = Some(invalid),
2 => cost.cache_read = Some(invalid),
3 => cost.cache_write = Some(invalid),
4 => cost.reasoning = Some(invalid),
5 => cost.input_audio = Some(invalid),
6 => cost.output_audio = Some(invalid),
_ => unreachable!(),
}
assert!(
@@ -94,7 +97,7 @@ fn absurd_catalog_prices_fail_closed_at_every_projection() {
f64::MAX,
];
for price in absurd {
for field in 0..4 {
for field in 0..7 {
let mut offering = priced(CatalogSource::Bundled);
let cost = offering.cost.as_mut().expect("cost fixture");
match field {
@@ -102,6 +105,9 @@ fn absurd_catalog_prices_fail_closed_at_every_projection() {
1 => cost.output = Some(price),
2 => cost.cache_read = Some(price),
3 => cost.cache_write = Some(price),
4 => cost.reasoning = Some(price),
5 => cost.input_audio = Some(price),
6 => cost.output_audio = Some(price),
_ => unreachable!(),
}
assert!(
@@ -139,14 +145,15 @@ fn absurd_catalog_prices_fail_closed_at_every_projection() {
}
#[test]
fn live_source_carries_provider_live_provenance_and_effective_at() {
fn live_source_carries_provider_live_provenance_and_fetch_time() {
let src = CatalogSource::Live {
base_url_fingerprint: "fp".into(),
fetched_at: 1_700,
};
let p = OfferingPricing::from_catalog_offering(&priced(src)).expect("priced");
assert_eq!(p.provenance, PricingProvenance::ProviderLive);
assert_eq!(p.effective_at, Some(1_700));
assert_eq!(p.effective_at, None);
assert_eq!(p.fetched_at, Some(1_700));
assert_eq!(p.endpoint_fingerprint.as_deref(), Some("fp"));
}
@@ -246,7 +253,10 @@ fn no_cost_or_empty_cost_object_is_unknown() {
// A cost object present but with no concrete price is still unknown.
offering.cost = Some(ModelsDevCost::default());
assert!(OfferingPricing::from_catalog_offering(&offering).is_none());
let withheld =
OfferingPricing::from_catalog_offering(&offering).expect("explicit withheld rate");
assert!(withheld.rate_withheld);
assert_eq!(withheld.estimate_cost(&TokenUsage::default()), None);
}
#[test]
@@ -258,6 +268,12 @@ fn estimate_cost_sums_priced_classes() {
output: 500_000,
cache_read: 2_000_000,
cache_write: 0,
reasoning: None,
input_audio: None,
output_audio: None,
request_usages: Vec::new(),
};
let cost = p.estimate_cost(&usage).expect("priced classes estimate");
assert!((cost - 0.546).abs() < 1e-9, "got {cost}");
@@ -273,6 +289,12 @@ fn estimate_cost_is_none_when_a_used_class_is_unpriced() {
output: 0,
cache_read: 0,
cache_write: 10,
reasoning: None,
input_audio: None,
output_audio: None,
request_usages: Vec::new(),
};
assert!(p.estimate_cost(&usage).is_none());
}
@@ -303,6 +325,8 @@ fn finite_rates_that_overflow_the_computed_total_fail_closed() {
provenance: PricingProvenance::ModelsDevBundled,
effective_at: None,
endpoint_fingerprint: None,
..Default::default()
};
let usage = TokenUsage {
input: u64::MAX,
@@ -362,6 +386,8 @@ fn user_override_pricing_round_trips_and_carries_no_secrets() {
provenance: PricingProvenance::UserOverride,
effective_at: None,
endpoint_fingerprint: None,
..Default::default()
};
let json = serde_json::to_string_pretty(&pricing).expect("serialize");
let back: OfferingPricing = serde_json::from_str(&json).expect("round-trip");
@@ -488,6 +514,12 @@ fn unpriced_used_classes_names_exactly_what_makes_an_estimate_fail_closed() {
output: 1_000_000,
cache_read: 1_000_000,
cache_write: 0,
reasoning: None,
input_audio: None,
output_audio: None,
request_usages: Vec::new(),
};
assert!(pricing.unpriced_used_classes(&no_write).is_empty());
assert_eq!(pricing.estimate_cost(&no_write), Some(0.28 + 0.42 + 0.028));
@@ -517,6 +549,12 @@ fn token_class_labels_and_counts_stay_aligned_with_token_usage() {
output: 2,
cache_read: 3,
cache_write: 4,
reasoning: None,
input_audio: None,
output_audio: None,
request_usages: Vec::new(),
};
let seen: Vec<(&str, u64)> = TokenClass::ALL
.into_iter()
@@ -529,6 +567,9 @@ fn token_class_labels_and_counts_stay_aligned_with_token_usage() {
("output", 2),
("cache_read", 3),
("cache_write", 4),
("reasoning", 0),
("input_audio", 0),
("output_audio", 0),
]
);
}
@@ -27,13 +27,15 @@
"id": "claude-fable-5"
},
{
"id": "claude-haiku-4-5"
"id": "claude-haiku-4-5",
"canonical": "anthropic/claude-haiku-4-5"
},
{
"id": "claude-opus-4-8"
},
{
"id": "claude-opus-5"
"id": "claude-opus-5",
"canonical": "anthropic/claude-opus-5"
},
{
"id": "claude-sonnet-4-6",
@@ -64,6 +66,7 @@
"models": [
{
"id": "trinity-large-thinking",
"canonical": "arcee-ai/trinity-large-thinking",
"default": true
},
{
@@ -198,20 +201,28 @@
"models": [
{
"id": "deepseek-flash",
"canonical": "deepseek-flash",
"canonical": "deepseek/deepseek-v4.1-flash",
"default": true
},
{
"id": "deepseek-v4-flash",
"canonical": "deepseek-v4-flash"
"canonical": "deepseek/deepseek-v4.1-flash"
},
{
"id": "deepseek-v4-flash-vision-exp",
"canonical": "deepseek-v4-flash"
"canonical": "deepseek/deepseek-v4.1-flash"
},
{
"id": "deepseek-v4-pro",
"canonical": "deepseek-v4-pro"
"canonical": "deepseek/deepseek-v4-pro-0813"
},
{
"id": "deepseek-v4-pro-0813",
"canonical": "deepseek/deepseek-v4-pro-0813"
},
{
"id": "deepseek-v4.1-flash",
"canonical": "deepseek/deepseek-v4.1-flash"
}
],
"transport": "model-aware"
@@ -352,14 +363,17 @@
],
"models": [
{
"id": "muse-spark-1.1"
"id": "muse-spark-1.1",
"canonical": "meta/muse-spark-1.1"
},
{
"id": "muse-spark-1.2",
"canonical": "meta/muse-spark-1.2",
"default": true
},
{
"id": "muse-spark-1.2-contributor"
"id": "muse-spark-1.2-contributor",
"canonical": "meta/muse-spark-1.2"
}
],
"transport": "chat-completions"
@@ -479,29 +493,29 @@
],
"models": [
{
"id": "deepseek-v4-flash-0731"
"id": "deepseek-v4-flash-0731",
"canonical": "deepseek/deepseek-v4-flash-0731"
},
{
"id": "deepseek-v4-pro"
"id": "deepseek-v4-pro",
"canonical": "deepseek/deepseek-v4-pro"
},
{
"id": "glm-5.2"
"id": "glm-5.2",
"canonical": "zhipuai/glm-5.2"
},
{
"id": "qwen3.6-flash"
},
{
"id": "qwen3.7-max"
},
{
"id": "qwen3.7-plus"
"id": "qwen3.7-plus",
"canonical": "alibaba/qwen3.7-plus"
},
{
"id": "qwen3.8-max",
"canonical": "alibaba/qwen3.8-max",
"default": true
},
{
"id": "qwen3.8-max-preview"
"id": "qwen3.8-max-preview",
"canonical": "alibaba/qwen3.8-max-preview"
}
],
"transport": "chat-completions"
@@ -525,29 +539,29 @@
],
"models": [
{
"id": "deepseek-v4-flash-0731"
"id": "deepseek-v4-flash-0731",
"canonical": "deepseek/deepseek-v4-flash-0731"
},
{
"id": "deepseek-v4-pro"
"id": "deepseek-v4-pro",
"canonical": "deepseek/deepseek-v4-pro"
},
{
"id": "glm-5.2"
"id": "glm-5.2",
"canonical": "zhipuai/glm-5.2"
},
{
"id": "qwen3.6-flash"
},
{
"id": "qwen3.7-max"
},
{
"id": "qwen3.7-plus"
"id": "qwen3.7-plus",
"canonical": "alibaba/qwen3.7-plus"
},
{
"id": "qwen3.8-max",
"canonical": "alibaba/qwen3.8-max",
"default": true
},
{
"id": "qwen3.8-max-preview"
"id": "qwen3.8-max-preview",
"canonical": "alibaba/qwen3.8-max-preview"
}
],
"transport": "anthropic-messages"
@@ -571,29 +585,37 @@
],
"models": [
{
"id": "deepseek-v4-flash-0731"
"id": "deepseek-v4-flash-0731",
"canonical": "deepseek/deepseek-v4-flash-0731"
},
{
"id": "deepseek-v4-pro"
"id": "deepseek-v4-pro",
"canonical": "deepseek/deepseek-v4-pro"
},
{
"id": "glm-5.2"
"id": "glm-5.2",
"canonical": "zhipuai/glm-5.2"
},
{
"id": "qwen3.6-flash"
"id": "qwen3.6-flash",
"canonical": "alibaba/qwen3.6-flash"
},
{
"id": "qwen3.7-max"
"id": "qwen3.7-max",
"canonical": "alibaba/qwen3.7-max"
},
{
"id": "qwen3.7-plus"
"id": "qwen3.7-plus",
"canonical": "alibaba/qwen3.7-plus"
},
{
"id": "qwen3.8-max",
"canonical": "alibaba/qwen3.8-max",
"default": true
},
{
"id": "qwen3.8-max-preview"
"id": "qwen3.8-max-preview",
"canonical": "alibaba/qwen3.8-max-preview"
}
],
"transport": "chat-completions"
@@ -617,29 +639,37 @@
],
"models": [
{
"id": "deepseek-v4-flash-0731"
"id": "deepseek-v4-flash-0731",
"canonical": "deepseek/deepseek-v4-flash-0731"
},
{
"id": "deepseek-v4-pro"
"id": "deepseek-v4-pro",
"canonical": "deepseek/deepseek-v4-pro"
},
{
"id": "glm-5.2"
"id": "glm-5.2",
"canonical": "zhipuai/glm-5.2"
},
{
"id": "qwen3.6-flash"
"id": "qwen3.6-flash",
"canonical": "alibaba/qwen3.6-flash"
},
{
"id": "qwen3.7-max"
"id": "qwen3.7-max",
"canonical": "alibaba/qwen3.7-max"
},
{
"id": "qwen3.7-plus"
"id": "qwen3.7-plus",
"canonical": "alibaba/qwen3.7-plus"
},
{
"id": "qwen3.8-max",
"canonical": "alibaba/qwen3.8-max",
"default": true
},
{
"id": "qwen3.8-max-preview"
"id": "qwen3.8-max-preview",
"canonical": "alibaba/qwen3.8-max-preview"
}
],
"transport": "anthropic-messages"
@@ -667,13 +697,16 @@
},
{
"id": "kimi-k2.7-code",
"canonical": "moonshotai/kimi-k2.7-code",
"default": true
},
{
"id": "kimi-k2.7-code-highspeed"
"id": "kimi-k2.7-code-highspeed",
"canonical": "moonshotai/kimi-k2.7-code"
},
{
"id": "kimi-k3"
"id": "kimi-k3",
"canonical": "moonshotai/kimi-k3"
}
],
"transport": "chat-completions"
@@ -695,13 +728,9 @@
}
],
"models": [
{
"id": "deepseek/deepseek-v4-flash",
"canonical": "deepseek-v4-flash"
},
{
"id": "deepseek/deepseek-v4-pro",
"canonical": "deepseek-v4-pro",
"canonical": "deepseek/deepseek-v4-pro",
"default": true
}
],
@@ -791,16 +820,20 @@
},
{
"id": "gpt-5.6",
"canonical": "openai/gpt-5.6-sol",
"default": true
},
{
"id": "gpt-5.6-luna"
"id": "gpt-5.6-luna",
"canonical": "openai/gpt-5.6-luna"
},
{
"id": "gpt-5.6-sol"
"id": "gpt-5.6-sol",
"canonical": "openai/gpt-5.6-sol"
},
{
"id": "gpt-5.6-terra"
"id": "gpt-5.6-terra",
"canonical": "openai/gpt-5.6-terra"
}
],
"transport": "chat-completions"
@@ -898,42 +931,51 @@
"models": [
{
"id": "deepseek/deepseek-v4-flash",
"canonical": "deepseek-v4-flash"
"canonical": "deepseek/deepseek-v4-flash"
},
{
"id": "deepseek/deepseek-v4-pro",
"canonical": "deepseek-v4-pro",
"canonical": "deepseek/deepseek-v4-pro",
"default": true
},
{
"id": "dots-studio/dots-3-note-preview:free"
},
{
"id": "minimax/minimax-m3"
"id": "minimax/minimax-m3",
"canonical": "minimax/MiniMax-M3"
},
{
"id": "qwen/qwen3.6-35b-a3b"
"id": "qwen/qwen3.6-35b-a3b",
"canonical": "alibaba/qwen3.6-35b-a3b"
},
{
"id": "qwen/qwen3.6-flash"
"id": "qwen/qwen3.6-flash",
"canonical": "alibaba/qwen3.6-flash"
},
{
"id": "qwen/qwen3.6-plus"
"id": "qwen/qwen3.6-plus",
"canonical": "alibaba/qwen3.6-plus"
},
{
"id": "qwen/qwen3.7-plus"
"id": "qwen/qwen3.7-plus",
"canonical": "alibaba/qwen3.7-plus"
},
{
"id": "qwen/qwen3.8-flash"
"id": "qwen/qwen3.8-flash",
"canonical": "alibaba/qwen3.8-flash"
},
{
"id": "z-ai/glm-5.2"
"id": "z-ai/glm-5.2",
"canonical": "zhipuai/glm-5.2"
},
{
"id": "z-ai/glm-5.3"
"id": "z-ai/glm-5.3",
"canonical": "zhipuai/glm-5.3"
},
{
"id": "z-ai/glm-5.3-flash"
"id": "z-ai/glm-5.3-flash",
"canonical": "zhipuai/glm-5.3-flash"
}
],
"transport": "chat-completions"
@@ -1035,11 +1077,11 @@
"models": [
{
"id": "deepseek-ai/DeepSeek-V4-Flash",
"canonical": "deepseek-v4-flash"
"canonical": "deepseek/deepseek-v4-flash"
},
{
"id": "deepseek-ai/DeepSeek-V4-Pro",
"canonical": "deepseek-v4-pro",
"canonical": "deepseek/deepseek-v4-pro",
"default": true
}
],
@@ -1089,10 +1131,12 @@
},
{
"id": "step-3.7-flash",
"canonical": "stepfun/step-3.7-flash",
"default": true
},
{
"id": "step-5-preview"
"id": "step-5-preview",
"canonical": "stepfun/step-5-preview"
}
],
"transport": "chat-completions"
@@ -1227,14 +1271,17 @@
"id": "grok-4.3"
},
{
"id": "grok-4.5"
"id": "grok-4.5",
"canonical": "xai/grok-4.5"
},
{
"id": "grok-4.6",
"canonical": "xai/grok-4.6",
"default": true
},
{
"id": "grok-4.7"
"id": "grok-4.7",
"canonical": "xai/grok-4.7"
}
],
"transport": "chat-completions"
@@ -1300,17 +1347,21 @@
"id": "GLM-5-Turbo"
},
{
"id": "GLM-5.2"
"id": "GLM-5.2",
"canonical": "zhipuai/glm-5.2"
},
{
"id": "GLM-5.3",
"canonical": "zhipuai/glm-5.3",
"default": true
},
{
"id": "GLM-5.3-Flash"
"id": "GLM-5.3-Flash",
"canonical": "zhipuai/glm-5.3-flash"
},
{
"id": "glm-5.1"
"id": "glm-5.1",
"canonical": "zhipuai/glm-5.1"
}
],
"transport": "chat-completions"
+32
View File
@@ -610,6 +610,8 @@ impl RouteResolver {
&& matches!(provider_kind, ProviderKind::Deepseek | ProviderKind::Zai);
let mut casefold_match = None;
let mut casefold_ambiguous = false;
let mut suffix_match = None;
let mut suffix_ambiguous = false;
for offering in &self.offerings {
if offering.provider != *provider_id {
continue;
@@ -622,6 +624,33 @@ impl RouteResolver {
if matches_canonical || matches_wire {
return Ok(ResolvedOffering::from_offering(offering));
}
// Aggregator wire ids carry an org namespace (`deepseek/<id>`)
// while saved selectors and canonical joins use the bare logical
// id. A bare selector that uniquely names the last segment of
// one wire id owned by THIS provider resolves to that offering;
// ambiguity stays unmatched rather than guessing.
let wire_tail = offering
.wire_model_id
.as_str()
.rsplit('/')
.next()
.unwrap_or_default();
let canonical_tail = offering
.canonical_model
.as_ref()
.map(|model| model.as_str().rsplit('/').next().unwrap_or_default());
if class == ProviderClass::Aggregator
&& !raw.contains('/')
&& (wire_tail == raw || canonical_tail == Some(raw))
{
match suffix_match {
None => suffix_match = Some(offering),
Some(first) if first.wire_model_id != offering.wire_model_id => {
suffix_ambiguous = true;
}
Some(_) => {}
}
}
if allow_casefold_wire_match
&& offering.wire_model_id.as_str().eq_ignore_ascii_case(raw)
{
@@ -632,6 +661,9 @@ impl RouteResolver {
}
}
}
if !suffix_ambiguous && let Some(offering) = suffix_match {
return Ok(ResolvedOffering::from_offering(offering));
}
if !casefold_ambiguous && let Some(offering) = casefold_match {
return Ok(ResolvedOffering::from_offering(offering));
}
+41
View File
@@ -192,6 +192,38 @@ fn invalid_limits_prices_units_and_authority_fail_closed() {
("input = 0.4", "input = inf"),
("input = 0.4", "input = 0.4, currency = 'CNY'"),
("input = 0.4", "input = 0.4, unit = 'per_token'"),
(
"input = 0.4",
"input = 0.4, tiers = [{ input = 0.8, tier = { type = 'time', size = 1000 } }]",
),
(
"input = 0.4",
"input = 0.4, tiers = [{ input = 0.8, tiers = [{ input = 1.2, tier = { type = 'context', size = 2000 } }], tier = { type = 'context', size = 1000 } }]",
),
(
"input = 0.4",
"input = 0.4, context_over_200k = { input = 0.8, tiers = [{ input = 1.2, tier = { type = 'context', size = 300000 } }] }",
),
(
"input = 0.4",
"input = 0.4, tiers = [{ input = 0.8, currency = 'CNY', tier = { type = 'context', size = 1000 } }]",
),
(
"input = 0.4",
"input = 0.4, tiers = [{ input = 0.8, unit = 'per_token', tier = { type = 'context', size = 1000 } }]",
),
(
"input = 0.4",
"input = 0.4, tiers = [{ input = 0.8, tier = { type = 'context', size = 1000, unit = 'request' } }]",
),
(
"input = 0.4",
"input = 0.4, context_over_200k = { input = 0.8, currency = 'CNY' }",
),
(
"input = 0.4",
"input = 0.4, context_over_200k = { input = 0.8, unit = 'per_token' }",
),
("future_note =", "source ="),
("future_note =", "canonical_model ="),
("future_note =", "api_key ="),
@@ -204,6 +236,15 @@ fn invalid_limits_prices_units_and_authority_fail_closed() {
"{before} -> {after}"
);
}
for schedule in [
"tiers = [{ input = 0.8, output = 3.2, tier = { type = 'context', size = 1000 } }]",
"tiers = [{ input = 0.8, output = 3.2, tier = { size = 1000 } }]",
"context_over_200k = { input = 0.8, output = 3.2 }",
] {
let valid = FIXTURE.replace("input = 0.4", &format!("input = 0.4, {schedule}"));
let parsed = toml::from_str::<ConfigToml>(&valid).expect("supported configured schedule");
validate_configured_models(parsed.custom_models.as_deref().unwrap()).unwrap();
}
let mut duplicate = models();
let mut other = duplicate[0].clone();
other.base_url = "https://MODELS.example.test:443/v1/".into();
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "tarifa invàlida",
"CostReasonCurrencyMissing": "tarifa en aquesta moneda no disponible",
"CostReasonUsageConflict": "recomptes de tokens contradictoris",
"CostReasonUsageBreakdown": "desglossament de tokens no disponible",
"CostChipUnknown": "cost: desconegut ({reasons})",
"CostChipSubtotal": "subtotal {amount} + desconegut ({reasons})",
"CostChipSavedSubtotal": "subtotal desat {amount} + desconegut ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "ungültiger Tarif",
"CostReasonCurrencyMissing": "Tarif in dieser Währung fehlt",
"CostReasonUsageConflict": "widersprüchliche Tokenzahlen",
"CostReasonUsageBreakdown": "Aufschlüsselung der Token-Nutzung nicht verfügbar",
"CostChipUnknown": "Kosten: unbekannt ({reasons})",
"CostChipSubtotal": "Zwischensumme {amount} + unbekannt ({reasons})",
"CostChipSavedSubtotal": "gespeicherte Zwischensumme {amount} + unbekannt ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "invalid rate",
"CostReasonCurrencyMissing": "currency rate unavailable",
"CostReasonUsageConflict": "conflicting token counts",
"CostReasonUsageBreakdown": "token usage breakdown unavailable",
"CostChipUnknown": "cost: unknown ({reasons})",
"CostChipSubtotal": "subtotal {amount} + unknown ({reasons})",
"CostChipSavedSubtotal": "saved subtotal {amount} + unknown ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "tarifa inválida",
"CostReasonCurrencyMissing": "tarifa en esta moneda no disponible",
"CostReasonUsageConflict": "recuentos de tokens contradictorios",
"CostReasonUsageBreakdown": "desglose de tokens no disponible",
"CostChipUnknown": "costo: desconocido ({reasons})",
"CostChipSubtotal": "subtotal {amount} + desconocido ({reasons})",
"CostChipSavedSubtotal": "subtotal guardado {amount} + desconocido ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "tarif invalide",
"CostReasonCurrencyMissing": "tarif dans cette devise indisponible",
"CostReasonUsageConflict": "comptages de tokens contradictoires",
"CostReasonUsageBreakdown": "répartition des tokens indisponible",
"CostChipUnknown": "coût : inconnu ({reasons})",
"CostChipSubtotal": "sous-total {amount} + inconnu ({reasons})",
"CostChipSavedSubtotal": "sous-total enregistré {amount} + inconnu ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "अमान्य दर",
"CostReasonCurrencyMissing": "इस मुद्रा की दर उपलब्ध नहीं",
"CostReasonUsageConflict": "टोकन गणनाओं में विरोध",
"CostReasonUsageBreakdown": "टोकन उपयोग का विवरण उपलब्ध नहीं",
"CostChipUnknown": "लागत: अज्ञात ({reasons})",
"CostChipSubtotal": "उप-योग {amount} + अज्ञात ({reasons})",
"CostChipSavedSubtotal": "सहेजा गया उप-योग {amount} + अज्ञात ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "tarif tidak valid",
"CostReasonCurrencyMissing": "tarif mata uang tidak tersedia",
"CostReasonUsageConflict": "jumlah token bertentangan",
"CostReasonUsageBreakdown": "rincian penggunaan token tidak tersedia",
"CostChipUnknown": "biaya: belum diketahui ({reasons})",
"CostChipSubtotal": "subtotal {amount} + belum diketahui ({reasons})",
"CostChipSavedSubtotal": "subtotal tersimpan {amount} + belum diketahui ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "無効な料金",
"CostReasonCurrencyMissing": "通貨の料金情報なし",
"CostReasonUsageConflict": "トークン数が不整合",
"CostReasonUsageBreakdown": "トークン使用量の内訳が不明",
"CostChipUnknown": "費用: 不明({reasons})",
"CostChipSubtotal": "小計 {amount} + 不明({reasons})",
"CostChipSavedSubtotal": "保存済み小計 {amount} + 不明({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "잘못된 요금",
"CostReasonCurrencyMissing": "해당 통화 요금 없음",
"CostReasonUsageConflict": "토큰 수 불일치",
"CostReasonUsageBreakdown": "토큰 사용량 내역을 알 수 없음",
"CostChipUnknown": "비용: 알 수 없음 ({reasons})",
"CostChipSubtotal": "소계 {amount} + 알 수 없음 ({reasons})",
"CostChipSavedSubtotal": "저장된 소계 {amount} + 알 수 없음 ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "tarifa inválida",
"CostReasonCurrencyMissing": "tarifa nesta moeda indisponível",
"CostReasonUsageConflict": "contagens de tokens conflitantes",
"CostReasonUsageBreakdown": "detalhamento de tokens indisponível",
"CostChipUnknown": "custo: desconhecido ({reasons})",
"CostChipSubtotal": "subtotal {amount} + desconhecido ({reasons})",
"CostChipSavedSubtotal": "subtotal salvo {amount} + desconhecido ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "некорректный тариф",
"CostReasonCurrencyMissing": "тариф в этой валюте недоступен",
"CostReasonUsageConflict": "противоречивые счётчики токенов",
"CostReasonUsageBreakdown": "разбивка токенов недоступна",
"CostChipUnknown": "стоимость: неизвестна ({reasons})",
"CostChipSubtotal": "подытог {amount} + неизвестно ({reasons})",
"CostChipSavedSubtotal": "сохранённый подытог {amount} + неизвестно ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "некоректний тариф",
"CostReasonCurrencyMissing": "тариф у цій валюті недоступний",
"CostReasonUsageConflict": "суперечливі лічильники токенів",
"CostReasonUsageBreakdown": "деталізація токенів недоступна",
"CostChipUnknown": "вартість: невідома ({reasons})",
"CostChipSubtotal": "проміжний підсумок {amount} + невідомо ({reasons})",
"CostChipSavedSubtotal": "збережений проміжний підсумок {amount} + невідомо ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "đơn giá không hợp lệ",
"CostReasonCurrencyMissing": "thiếu giá cho loại tiền",
"CostReasonUsageConflict": "số token không nhất quán",
"CostReasonUsageBreakdown": "không có chi tiết sử dụng token",
"CostChipUnknown": "chi phí: chưa rõ ({reasons})",
"CostChipSubtotal": "tạm tính {amount} + chưa rõ ({reasons})",
"CostChipSavedSubtotal": "tạm tính đã lưu {amount} + chưa rõ ({reasons})",
+1
View File
@@ -2413,6 +2413,7 @@
"CostReasonInvalidRate": "费率无效",
"CostReasonCurrencyMissing": "缺少该币种费率",
"CostReasonUsageConflict": "token 计数冲突",
"CostReasonUsageBreakdown": "token 用量明细不可用",
"CostChipUnknown": "费用:未知({reasons})",
"CostChipSubtotal": "小计 {amount} + 未知({reasons})",
"CostChipSavedSubtotal": "已保存小计 {amount} + 未知({reasons})",
+1
View File
@@ -2411,6 +2411,7 @@
"CostReasonInvalidRate": "費率無效",
"CostReasonCurrencyMissing": "缺少該幣別費率",
"CostReasonUsageConflict": "token 計數衝突",
"CostReasonUsageBreakdown": "token 用量明細不可用",
"CostChipUnknown": "費用:未知({reasons})",
"CostChipSubtotal": "小計 {amount} + 未知({reasons})",
"CostChipSavedSubtotal": "已儲存小計 {amount} + 未知({reasons})",
+2
View File
@@ -145,6 +145,7 @@ pub enum MessageId {
CostReasonInvalidRate,
CostReasonCurrencyMissing,
CostReasonUsageConflict,
CostReasonUsageBreakdown,
CostChipUnknown,
CostChipSubtotal,
CostChipSavedSubtotal,
@@ -2818,6 +2819,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
MessageId::CostReasonInvalidRate,
MessageId::CostReasonCurrencyMissing,
MessageId::CostReasonUsageConflict,
MessageId::CostReasonUsageBreakdown,
MessageId::CostChipUnknown,
MessageId::CostChipSubtotal,
MessageId::CostChipSavedSubtotal,
+6
View File
@@ -176,6 +176,12 @@ pub struct Usage {
pub prompt_cache_write_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_audio_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_audio_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub request_usages: Vec<Usage>,
/// Approximate input tokens spent re-sending prior `reasoning_content`
/// across user-message boundaries in DeepSeek V4 thinking-mode tool-calling
/// turns (V4 §5.1.1 "Interleaved Thinking"). Estimated client-side at
+5
View File
@@ -98,6 +98,11 @@ mod tests {
reasoning_tokens: Some(5),
reasoning_replay_tokens: Some(2),
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
},
}
}
+138 -21
View File
@@ -7,17 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Contributors
- **[@printf033](https://github.com/printf033)** — replaced the SELinux-denied root-directory anchor of confined-file I/O with an `O_PATH` walk (Linux-cfg-gated) and a re-materialized final parent, restoring workflow journal, compaction checkpoint and artifact writes on Android/Termux ([#6983](https://github.com/codewhale-hq/Codewhale/pull/6983)); reported the Android confined-I/O failure with strace evidence and an on-device reproducer ([#6980](https://github.com/codewhale-hq/Codewhale/issues/6980)).
## [0.10.2] - 2026-10-10
## [0.10.2] - 2026-10-13
- OpenHarmony builds on Windows select Ninja, with an SDK-bundled fallback and
a clear error when it is unavailable. The CLI keeps its download and Work
context integrity checks available on OHOS by sharing an ungated SHA-256
helper (thanks @shenjackyuanjie, #6989; reported in #6988).
- Windows mouse-wheel scrolling is restored after returning from the external
editor. The handoff now releases both Windows console mouse capture and
terminal mouse modes. Thanks @SparkofSpike for distinguishing terminal
scrollback from transcript scrolling in #6652; the separate hours-long
scrolling slowdown still needs reproduction.
- Shell classification accepts PowerShell’s call operator, quoted ampersands
and combined output redirection without losing destructive commands in
nested shell payloads or after command separators. Thanks @SparkofSpike
for the diagnosis and fix in #6929, and @jayanthvee for the detailed
Windows report in #6963.
- Windows commands that cannot be classified use the normal approval path
with a direct explanation. Ask can request approval and Auto-Review can
consult its reviewer; an unclassified command is no longer reported as an
npm-launcher kill. Actual unbounded `node.exe` image kills keep their hold
in reviewing modes; Full Access continues to bypass that floor. Thanks
@jayanthvee for separating these cases in #6963.
- Pasting while a modal is open no longer changes a history-search query
hidden underneath it. Existing modal key ownership and hidden-caret guards
remain in place. Windows Terminal IME preedit is still controlled by the
terminal; native IME retesting remains open (#2323).
- Model lists, resolution, picker controls and Runtime facts use the same
endpoint-scoped catalog facts. Cost estimates preserve complete price tiers,
calculate exact decimal amounts per request, and freeze rates at dispatch.
Missing rates or required usage breakdowns stay unpriced; public prices do
not establish account access. The offline seed records its source snapshot
and keeps absent default wire IDs as dated compatibility fallbacks. Refs #6396.
- Desktop and HTTP app-server turns feed an existing shared pet companion when
its followed session has no live producer. Automatic selection is checked
atomically by the owner; a live TUI keeps its source. The Runtime producer
@@ -134,7 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
or run commands.
- `bash` now moves a foreground command to the background after 30 s and
reports the elapsed time and how long ago it last printed output.
`task_shell_start` is removed; use `bash` with `background=true`.
`task_shell_start` is removed.
- Every change of effective permission now writes a `Permissions changed to …`
line in the transcript, and runtime threads record it as a `status` item and
a `thread.permissions_changed` event.
@@ -154,6 +178,13 @@ launcher guidance.
### Added
- Standard Compute is available as a bundled compatible provider, with the
`standardcompute` router default and authenticated model discovery. Its
billing basis remains Unknown (#6985).
- Sub-agents retain permitted code-mode tool composition. Nested calls keep
the child's approvals, write scope, tool timeout and work deadline; approval
waits do not consume that tool timeout. Refused nested calls still do not
count toward Fleet's denial-loop guard. Refs #6841 (CW-7033).
- `/connect [<provider>]` signs in to a model provider that a plugin adds,
inside the TUI, and `/disconnect <provider>` removes that sign-in.
`/login` and `/logout` are for your Codewhale account only. Plugins that
@@ -229,6 +260,25 @@ launcher guidance.
`CODEWHALE_TELEMETRY_SURFACE`, including `vscode-extension`. This changes the
session label while preserving collection settings and opt-outs
([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
- `codewhale account computers files` saves a Computer's workspace file (up to
16 MiB) to account storage, lists its versions, and restores (up to 4 MiB)
or downloads one. Restoring over an existing file needs its current
`--expected-revision`; a download verifies size and SHA-256 and never
overwrites a local file. `codewhale account computers delete` now asks for
`--discard-files`. This is experimental.
- Runtime API: `GET /v1/goals` lists goal records across threads, newest
update first, with `status` and cursor paging. Goals whose thread no longer
exists are left out, and a cursor issued under a different `status` is `400`.
- Runtime API: `GET /v1/approvals` rows add a redacted `target` (a URL's host
or a workspace path) and a one-line `summary`, plus `standing_rule` (`allow`
or `deny`) when a remembered rule or grant answered instead of a person. A
shell command keeps only its program name and plain arguments. Rows recorded
earlier omit these fields.
- Runtime API: automations carry a `revision`. `PATCH`, `run`, `pause` and
`resume` accept `expected_revision` and answer `409` `revision_conflict` on a
mismatch. Create and those four accept an `Idempotency-Key`; a retry replays
the first success instead of creating or running twice. `DELETE` honors
neither. Clients check `capabilities.automation_mutation_preconditions`.
### Changed
@@ -293,16 +343,38 @@ launcher guidance.
this package" now lists the skipped rows, each with its package and reason,
and the Native composition refusal names the unresolved rows. Both name at
most five rows and count the rest.
- Full Access runs the calls it grants. The built-in Auto-Review floor
(publish-like commands, destructive background or headless work, and the
Windows npm-launcher hold) applies only in Ask, Auto-Review and Never, and no
longer blocks a call under Full Access, including a detached agent's.
- `file_search` is one of the model's default tools from the first turn
instead of being loaded through `tool_search`
([#6934](https://github.com/codewhale-hq/Codewhale/issues/6934)).
- `fim_edit` is offered only on routes that can run it: Chat Completions
routes other than OpenCode Zen. The default DeepSeek Flash route speaks
Responses and no longer lists it
([#6933](https://github.com/codewhale-hq/Codewhale/issues/6933)).
- Runtime session grants (`"remember": true`) are stored in the Runtime data
directory instead of memory. After a restart a thread's grants stay
suspended, and matching calls ask again, until an authenticated client
resumes the thread. A grant store that cannot be read holds no grants, so
every call asks.
### Fixed
- Windows 10 1607 starts again. 0.10.1 stopped at load with 0xC0000139
because `DeriveCapabilitySidsFromName` is missing there; it is now resolved
at runtime (#6953).
- Runtime child completions retain their original parent turn after the parent
becomes idle or starts a newer turn; terminal receipts remain durable and
deduplicated (CW-7414).
- Windows Native extension hosts resolve `DeriveCapabilitySidsFromName`
at runtime and return an unsupported error during LPAC setup when the
export is unavailable (#6953). Windows 10 1607 startup and Native-host
acceptance remain unverified.
- `codewhale resume` no longer refuses a session whose saved history still
holds a raw sub-agent handoff (#6954).
- A saved session over 8 MiB can be resumed and forked again. Journal entries
off the current branch are archived instead of refusing the session (#6955).
A session whose current branch alone is over 8 MiB is still refused, and the
refusal now gives its size.
- Snapshot restore and `/undo` work with older git, such as the 2.43 that
Ubuntu 24.04 ships. They failed there with "only one reference expected".
- Cancelling your own background shell job no longer reports it as finished
@@ -339,10 +411,26 @@ launcher guidance.
a recorded session message outside the system-prompt layer.
- The pet shows sub-agents while they run, and a finished one no longer stays
"working". Half-block pet sprites are drawn crisply instead of blurred.
- A provider's retry delay is honored when a 429 carries it in the body
instead of a `Retry-After` header (#6923).
- A rate-limit 429 with a body retry delay of up to 120 s retries the interrupted
model request. This includes ambiguous current-quota wording with billing
guidance when the body supplies a bounded delay; explicit exhausted account,
billing and subscription limits remain terminal (Refs #6923, thanks @Statter).
- Claude sign-in keeps its PKCE verifier out of the sign-in URL and gives its
pasted authorization code one terminal input reader. Failed input restores
the TUI; live subscription sign-in remains experimental (Refs #6932).
- Oversized-session refusals preserve compaction errors and no longer assume
that the active conversation alone caused the remaining size limit
(Refs #6955, thanks @7jrxt42BxFZo4iAnN4CX).
- A Codewhale home reached through a symlink or Windows junction keeps saving
artifacts and compacting (#6947).
- Sub-agent state and transcripts also save through a relocated Codewhale
home when its junction target already exists. Containment checks use the
same resolved state root throughout the operation (Refs #6949, thanks
@SparkofSpike).
- Saving a DeepSeek key in the TUI writes the provider's durable-key marker,
so its saved credential remains visible after restart while another
provider is active. Older saves missing that marker need one re-save
(Refs #6972, thanks @gaord).
- A workflow gate that blocks its own role is rejected when the plan is
admitted instead of deadlocking the run (#6945).
- Auto-Review gives its guardian an output budget a reasoning model can think
@@ -448,17 +536,37 @@ launcher guidance.
a reused process ID, or a publication from another user, store or config
is still refused
([#6915](https://github.com/codewhale-hq/Codewhale/issues/6915)).
- `/network allow <host>` and `/network deny <host>` take effect on the next
tool call without a restart
([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928)). Under a
managed `[network]` overlay or a Fleet `network_access = false`, editing your
own `[network]` table cannot widen the policy: a denial from either layer
stands, a deny-by-default authority keeps its own allow list, proxy and
fake-IP exceptions, a prompt-by-default authority adds your hosts to its own,
and audit logging stays on if either layer asks for it.
- A workflow's final receipt is no longer dropped when its completion inbox is
full, and each agent's queue of messages to its parent holds at most 64; a
send past that returns an error instead of growing without bound
([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)).
- `cargo +<toolchain> build`, `scripts/dev-test.sh` and `scripts/dev-cargo.sh`,
also through a wrapper, count as heavy commands for resource admission
([#6560](https://github.com/codewhale-hq/Codewhale/issues/6560)).
- Sub-agent state is written atomically, with the file and its directory
flushed before the rename
([#6555](https://github.com/codewhale-hq/Codewhale/issues/6555)), and image
search reads its seed and result bodies through the size cap
([#6554](https://github.com/codewhale-hq/Codewhale/issues/6554)).
### Known limits
- Ambiguous current-quota 429s without a body retry delay of at most 120 s
still end the turn; a header alone does not change that classification.
Explicit exhausted account, billing and subscription limits remain terminal
regardless of a retry hint. Gemini limits without an accepted body delay are
not automatically retried
([#6923](https://github.com/codewhale-hq/Codewhale/issues/6923)).
- Idle CPU has not been measured on FreeBSD or Linux for this release
([#6728](https://github.com/codewhale-hq/Codewhale/issues/6728)).
- Models.dev prices with context tiers are not applied. A turn at or above a
model's first context tier reads as unpriced ("pricing tier unavailable")
and is counted as missing from the cost total, instead of being priced at
its base rate. The bundled catalog has no tiered rows, so this appears only
after a live models.dev refresh
([#6396](https://github.com/codewhale-hq/Codewhale/issues/6396)).
- Shell commands run by delegated agents appear in `/jobs` and the work dock
with their owner, not in the agent's own detail view; named terminal
sessions live in the terminal dock
@@ -467,6 +575,13 @@ launcher guidance.
be changed inside the `F5` inspector. The browser studio and the macOS
window are not views in the cycle, and `Ctrl+]` cannot open the work bar
while the pet main view is showing.
- Computer Use is a preview. Its observation diffs, fresh element targets and
named action batches were checked against a macOS fixture app only;
packaged installs, installed-model use and sharing the desktop with a person
working at the same time have not been qualified.
- The `GET /v1/approvals` `target` and `summary` redaction is heuristic: a
secret passed as a plain word argument is not recognized, so treat approval
history as sensitive.
### Maintenance
@@ -478,13 +593,15 @@ launcher guidance.
### Contributors
- **[@henrikdukefoss](https://github.com/henrikdukefoss)** — added the Standard Compute compatible-host descriptor, with live model discovery and its billing basis kept Unknown ([#6985](https://github.com/codewhale-hq/Codewhale/pull/6985)).
- **[@shenjackyuanjie](https://github.com/shenjackyuanjie)** — diagnosed and fixed the Windows OpenHarmony CMake generator and CLI hashing-module build failures ([#6988](https://github.com/codewhale-hq/Codewhale/issues/6988), [#6989](https://github.com/codewhale-hq/Codewhale/pull/6989)).
- **[@printf033](https://github.com/printf033)** — replaced the SELinux-denied root-directory anchor of confined-file I/O with an `O_PATH` walk (Linux-cfg-gated) and a re-materialized final parent, restoring workflow journal, compaction checkpoint and artifact writes on Android/Termux ([#6983](https://github.com/codewhale-hq/Codewhale/pull/6983)); reported the Android confined-I/O failure with strace evidence and an on-device reproducer ([#6980](https://github.com/codewhale-hq/Codewhale/issues/6980)). Source change; compiled Android and device acceptance remain unqualified.
- **[@aboimpinto](https://github.com/aboimpinto)** — moved config-command parsing and replies onto portable contracts while the host retains effects ([#6973](https://github.com/codewhale-hq/Codewhale/pull/6973)).
- **[@henrikdukefoss](https://github.com/henrikdukefoss)** — added the Standard Compute compatible-host descriptor, with live model discovery and its billing basis kept Unknown ([#6985](https://github.com/codewhale-hq/Codewhale/pull/6985)).
- **[@LIghtJUNction](https://github.com/LIghtJUNction)** — plugin shell install and in-TUI plugin OAuth sign-in ([#6950](https://github.com/codewhale-hq/Codewhale/pull/6950)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), and a relocated Codewhale home ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), Windows ampersand classification ([#6929](https://github.com/codewhale-hq/Codewhale/pull/6929)), and a relocated Codewhale home including sub-agent state ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947), [#6949](https://github.com/codewhale-hq/Codewhale/pull/6949)).
- **[@dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)).
- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)) and workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)).
- **[@gaord](https://github.com/gaord)** — embedder telemetry surface support ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)) and the DeepSeek saved-key marker diagnosis and fix ([#6972](https://github.com/codewhale-hq/Codewhale/issues/6972), [#6974](https://github.com/codewhale-hq/Codewhale/pull/6974)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — localized profile replies ([#6919](https://github.com/codewhale-hq/Codewhale/pull/6919)), workflow gates that block their own role ([#6952](https://github.com/codewhale-hq/Codewhale/pull/6952)), proxy-independent MCP loopback checks ([#6971](https://github.com/codewhale-hq/Codewhale/pull/6971), [#6975](https://github.com/codewhale-hq/Codewhale/pull/6975)), and durable steering/child-approval acceptance checks ([#6967](https://github.com/codewhale-hq/Codewhale/pull/6967), [#6966](https://github.com/codewhale-hq/Codewhale/pull/6966)).
- **[@jayanthvee](https://github.com/jayanthvee)** — Windows npm launcher guidance ([#6906](https://github.com/codewhale-hq/Codewhale/pull/6906)).
- **[@asto18089](https://github.com/asto18089)** — callback-window reference fix for longer browser sign-in waits ([#6865](https://github.com/codewhale-hq/Codewhale/issues/6865)).
- **[@BX166](https://github.com/BX166)** — reported three provider failure modes measured on AICraft's own traffic: a cold model that reads as a dead connection, a listed model that fails on every call, and a response cut at the output ceiling that reports `stop` ([#6889](https://github.com/codewhale-hq/Codewhale/issues/6889)).
+2
View File
@@ -47,6 +47,7 @@ exclude = [
"assets/mark-*.png",
"assets/skills-catalog-matrix.json",
"extension-host/test/**",
"extension-host/node_modules/**",
"extension-host/build.mjs",
"extension-host/compile-host.mjs",
"extension-host/package-lock.json",
@@ -88,6 +89,7 @@ codewhale-sanitize = { path = "../sanitize", version = "0.10.2" }
codewhale-memory = { path = "../memory", version = "0.10.2" }
ahash = "0.8"
anyhow.workspace = true
codewhale-agent = { path = "../agent", version = "0.10.2" }
codewhale-app-server = { path = "../app-server", version = "0.10.2" }
codewhale-config = { path = "../config", version = "0.10.2" }
codewhale-cloud-facts = { path = "../cloud-facts", version = "0.10.2" }
+174 -7
View File
@@ -332,6 +332,7 @@ pub struct CodewhaleClient {
/// they must not reconstruct output caps with `None` and discard a custom
/// route's context window.
route_limits: Option<RouteLimits>,
route_facts: Option<Arc<codewhale_config::catalog::CatalogOffering>>,
/// Verified ChatGPT subject captured with Codewhale's own selected grant.
pub(super) codex_account_id: Option<String>,
/// Opaque reasoning is bound to this verified grant identity, stable across
@@ -668,6 +669,7 @@ impl Clone for CodewhaleClient {
billing_mode: self.billing_mode,
configured_models: Arc::clone(&self.configured_models),
route_limits: self.route_limits,
route_facts: self.route_facts.clone(),
codex_account_id: self.codex_account_id.clone(),
chatgpt_reasoning_api: self.chatgpt_reasoning_api.clone(),
wire_format: self.wire_format,
@@ -1889,6 +1891,14 @@ impl CodewhaleClient {
}
.build()?;
let route_facts = crate::provider_lake::configured_catalog_offering_for_route(
config,
api_provider,
admitted_identity.key.as_str(),
&base_url,
&default_model,
)
.map(Arc::new);
let catalog_error_secret_values = Arc::new(catalog_error_secret_values(
&api_key,
&http_headers,
@@ -1916,6 +1926,7 @@ impl CodewhaleClient {
billing_mode,
configured_models: Arc::new(config.custom_models.clone().unwrap_or_default()),
route_limits,
route_facts,
codex_account_id,
chatgpt_reasoning_api,
wire_format,
@@ -2032,6 +2043,7 @@ impl CodewhaleClient {
status: u16,
body: &str,
retry_after: Option<Duration>,
body_retry_after: Option<Duration>,
) -> LlmError {
let route = self.admitted_identity.key.as_str();
let error = match status {
@@ -2049,7 +2061,12 @@ impl CodewhaleClient {
.with_fix(auth_fix_hint(route, &self.api_key_source)),
),
),
_ => LlmError::from_http_response_with_retry_after(status, body, retry_after),
_ => LlmError::from_http_response_with_retry_hints(
status,
body,
retry_after,
body_retry_after,
),
};
match error {
LlmError::ModelError(message) => LlmError::ModelError(format!(
@@ -2209,6 +2226,24 @@ impl CodewhaleClient {
})
}
fn catalog_facts_for_model(
&self,
model: &str,
) -> Option<Arc<codewhale_config::catalog::CatalogOffering>> {
let mut config = Config::default();
let _ = config
.custom_models
.replace(self.configured_models.as_ref().clone());
crate::provider_lake::configured_catalog_offering_for_route(
&config,
self.api_provider,
self.admitted_identity.key.as_str(),
&self.base_url,
model,
)
.map(Arc::new)
}
/// Resolve `model` through the central route resolver and rebuild this
/// client whenever its exact wire identity, limits, or protocol differs
/// from the route bound at construction (#5042). `Ok(None)` means the
@@ -2242,6 +2277,7 @@ impl CodewhaleClient {
let mut rebound = self.clone();
rebound.default_model = candidate.wire_model_id().as_str().to_string();
rebound.route_limits = candidate_limits;
rebound.route_facts = rebound.catalog_facts_for_model(&rebound.default_model);
return Ok(Some(rebound));
}
let config = config.ok_or_else(|| {
@@ -2255,6 +2291,7 @@ impl CodewhaleClient {
})?;
let mut rebound = Self::from_candidate(config, &candidate)?;
rebound.configured_models = Arc::clone(&self.configured_models);
rebound.route_facts = rebound.catalog_facts_for_model(&rebound.default_model);
Ok(Some(rebound))
}
@@ -2907,6 +2944,93 @@ impl CodewhaleClient {
let (request, request_route_limits) =
self.bind_request_to_protocol(self.prepare_model_bound_request(request))?;
let mut request = clamp_output_cap(request, request_route_limits);
let facts = if request.model == self.default_model {
self.route_facts.clone()
} else {
self.catalog_facts_for_model(&request.model)
}
.filter(|facts| facts.wire_model_id == request.model);
if let Some(facts) = facts.as_deref() {
if facts.temperature == Some(false) {
request.temperature = None;
request.top_p = None;
}
if facts.tool_call == Some(false)
&& request
.tools
.as_ref()
.is_some_and(|tools| !tools.is_empty())
{
anyhow::bail!("The selected provider offering does not support tool calls");
}
if codewhale_config::models_dev::image_input_support(facts.modalities.as_ref())
== codewhale_config::route::CapabilityState::Unsupported
&& request
.messages
.iter()
.flat_map(|message| &message.content)
.any(|block| matches!(block, ContentBlock::ImageUrl { .. }))
{
anyhow::bail!("The selected provider offering does not support image input");
}
if facts.reasoning == Some(false) {
request.reasoning_effort = None;
request.thinking = None;
} else {
for option in &facts.reasoning_options {
if option.get("type").and_then(Value::as_str) != Some("effort") {
continue;
}
if request
.reasoning_effort
.as_deref()
.is_some_and(|effort| matches!(effort, "auto" | "default"))
{
request.reasoning_effort = option
.get("default")
.and_then(Value::as_str)
.map(str::to_string);
}
let wire_effort = if matches!(self.wire_format, WireFormat::ChatCompletions) {
request.reasoning_effort.as_deref().and_then(|effort| {
let provider = self.chat_shape_provider(&request.model);
let model = crate::config::wire_model_for_provider_route(
provider,
&self.base_url,
&request.model,
);
let mut body = json!({ "model": model });
chat::apply_route_reasoning_controls(
&mut body,
provider,
&self.base_url,
&model,
Some(effort),
);
body.get("reasoning_effort")
.or_else(|| body.pointer("/reasoning/effort"))
.and_then(Value::as_str)
.map(str::to_string)
})
} else {
request.reasoning_effort.clone()
};
if let Some(effort) = wire_effort.as_deref()
&& let Some(values) = option.get("values").and_then(Value::as_array)
&& !values.iter().any(|value| value.as_str() == Some(effort))
&& !(matches!(effort, "off" | "disabled" | "false")
&& (values.iter().any(|value| value.as_str() == Some("none"))
|| facts.reasoning_options.iter().any(|option| {
option.get("type").and_then(Value::as_str) == Some("toggle")
})))
{
anyhow::bail!(
"The selected provider offering does not support the configured reasoning effort; inspect model details and choose a published value"
);
}
}
}
}
let declared_wire_model = self.declared_wire_model(&request.model).map(str::to_string);
if self.is_local_ds4_model(&request.model)
&& let Some(tools) = request.tools.as_mut()
@@ -4046,10 +4170,14 @@ impl CodewhaleClient {
}
let header_retry_after = extract_retry_after(response.headers());
let raw = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
let retry_after =
header_retry_after.or_else(|| retry_delay_from_error_body(&raw));
let body_retry_after = retry_delay_from_error_body(&raw);
let body = self.disclosed_http_error_body(disclosure, status.as_u16(), &raw);
Err(self.http_error_with_route_context(status.as_u16(), &body, retry_after))
Err(self.http_error_with_route_context(
status.as_u16(),
&body,
header_retry_after,
body_retry_after,
))
}
},
Some(Box::new(move |err, attempt, delay| {
@@ -4165,11 +4293,15 @@ impl CodewhaleClient {
}
let header_retry_after = extract_retry_after(response.headers());
let raw = bounded_error_text(response, ERROR_BODY_MAX_BYTES).await;
let retry_after =
header_retry_after.or_else(|| retry_delay_from_error_body(&raw));
let body_retry_after = retry_delay_from_error_body(&raw);
let body =
self.disclosed_http_error_body(disclosure, status.as_u16(), &raw);
Err(self.http_error_with_route_context(status.as_u16(), &body, retry_after))
Err(self.http_error_with_route_context(
status.as_u16(),
&body,
header_retry_after,
body_retry_after,
))
}
},
Some(Box::new(|err, attempt, delay| {
@@ -6243,6 +6375,11 @@ mod configured_model_client_tests {
fn alternate_declared_model_freezes_wire_limits_and_price() {
let _env = crate::test_support::lock_test_env();
let mut config = config();
let mut initial = config.custom_models.as_ref().unwrap()[0].clone();
initial.id = "initial-model".into();
initial.tool_call = Some(true);
config.custom_models.as_mut().unwrap()[0].tool_call = Some(false);
config.custom_models.as_mut().unwrap().push(initial);
let client = CodewhaleClient::from_parts(
"https://api.deepseek.com".into(),
"initial-model".into(),
@@ -6269,6 +6406,7 @@ mod configured_model_client_tests {
.as_mut()
.unwrap()
.output = Some(99.0);
config.custom_models.as_mut().unwrap()[0].tool_call = Some(true);
assert_eq!(client.effective_max_output_tokens(model), 32);
let prepared = client
@@ -6280,12 +6418,41 @@ mod configured_model_client_tests {
assert_eq!(prepared.wire_model, model);
assert_eq!(prepared.body["model"], model);
assert_eq!(prepared.body["max_tokens"], 32);
let mut tool_request = translation_message_request("hello", model.into(), "English", 4096);
tool_request.tools = Some(vec![codewhale_models::Tool {
tool_type: None,
name: "fixture".into(),
description: "read-only fixture".into(),
input_schema: json!({ "type": "object", "properties": {} }),
allowed_callers: None,
defer_loading: Some(false),
input_examples: None,
strict: Some(true),
cache_control: None,
}]);
assert!(
client
.prepare_outbound_request(tool_request.clone(), false)
.is_err()
);
let mut initial_request = tool_request.clone();
initial_request.model = "initial-model".into();
assert!(
client
.prepare_outbound_request(initial_request, false)
.is_ok()
);
let rebound = client
.rebound_for_model_protocol(Some(&config), model)
.unwrap()
.unwrap();
assert_eq!(rebound.default_model, model);
assert_eq!(rebound.route_limits.unwrap().output_tokens, Some(32));
assert!(
rebound
.prepare_outbound_request(tool_request, false)
.is_err()
);
let envelope = rebound.effective_route_envelope(model, chrono::Utc::now());
assert_eq!(envelope.model, model);
let quote = serde_json::to_value(envelope.provider_live_pricing.unwrap()).unwrap();
+39 -3
View File
@@ -130,7 +130,12 @@ impl CodewhaleClient {
// gateways (#4978, e.g. Sensenova) only accept the documented
// enabled/disabled/auto thinking types, so non-native routes get the
// portable `{"type":"enabled","budget_tokens":N}` shape instead.
let thinking_capable = codewhale_models::model_supports_reasoning(&model);
let thinking_capable = self
.route_facts
.as_ref()
.filter(|facts| facts.wire_model_id == request.model)
.and_then(|facts| facts.reasoning)
.unwrap_or_else(|| codewhale_models::model_supports_reasoning(&model));
let is_minimax_provider = self.api_provider == ProviderKind::MinimaxAnthropic;
let is_minimax = crate::config::is_exact_minimax_anthropic_m3_route(
self.api_provider,
@@ -190,9 +195,35 @@ impl CodewhaleClient {
body["thinking"] = json!({ "type": "adaptive" });
}
_ if thinking_capable => {
if let Some(budget) = compat_thinking_budget(effort.as_deref(), request.max_tokens)
if let Some(mut budget) =
compat_thinking_budget(effort.as_deref(), request.max_tokens)
{
body["thinking"] = json!({ "type": "enabled", "budget_tokens": budget });
let mut minimum = u64::from(MIN_THINKING_BUDGET_TOKENS);
if let Some(facts) = self
.route_facts
.as_ref()
.filter(|facts| facts.wire_model_id == request.model)
&& let Some(option) = facts.reasoning_options.iter().find(|option| {
matches!(
option.get("type").and_then(Value::as_str),
Some("budget" | "budget_tokens")
)
})
{
minimum = option.get("min").and_then(Value::as_u64).unwrap_or(minimum);
let maximum = option
.get("max")
.and_then(Value::as_u64)
.unwrap_or(u64::from(request.max_tokens.saturating_sub(1)));
budget = budget.min(u32::try_from(maximum).unwrap_or(u32::MAX));
}
if u64::from(budget) >= minimum {
body["thinking"] = json!({ "type": "enabled", "budget_tokens": budget });
} else {
tracing::warn!(
"The output allowance cannot represent the offering's minimum reasoning budget; thinking was omitted"
);
}
}
}
_ => {}
@@ -1007,6 +1038,11 @@ fn parse_anthropic_usage(usage: &Value) -> Usage {
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
}
}
+11
View File
@@ -1198,6 +1198,17 @@ fn parse_responses_usage(val: &Value) -> Usage {
reasoning_tokens,
reasoning_replay_tokens: None,
server_tool_use: None,
input_audio_tokens: val
.pointer("/input_tokens_details/audio_tokens")
.and_then(Value::as_u64)
.map(super::saturating_u32),
output_audio_tokens: val
.pointer("/output_tokens_details/audio_tokens")
.and_then(Value::as_u64)
.map(super::saturating_u32),
request_usages: Vec::new(),
}
}
+7 -6
View File
@@ -865,7 +865,7 @@
};
let client = CodewhaleClient::new(&config).expect("openrouter client");
let auth = client
.http_error_with_route_context(401, "Invalid API key", None)
.http_error_with_route_context(401, "Invalid API key", None, None)
.to_string();
assert!(auth.contains("provider: openrouter"), "{auth}");
assert!(auth.contains("openrouter.ai"), "{auth}");
@@ -877,7 +877,7 @@
assert!(!auth.contains("or-rejected-key-1234567890"), "{auth}");
let model = client
.http_error_with_route_context(404, "model not found: nope", None)
.http_error_with_route_context(404, "model not found: nope", None, None)
.to_string();
assert!(model.contains("provider route: openrouter"), "{model}");
assert!(model.contains("host: openrouter.ai"), "{model}");
@@ -903,7 +903,7 @@
assert_eq!(client.subscription_limit_guidance, None);
let body =
r#"{"error":{"type":"usage_limit_reached","message":"The usage limit has been reached"}}"#;
let plain = client.http_error_with_route_context(429, body, None);
let plain = client.http_error_with_route_context(429, body, None, None);
assert!(matches!(plain, LlmError::QuotaExhausted(_)), "{plain:?}");
assert!(!plain.to_string().contains("codewhale auth"), "{plain}");
@@ -912,7 +912,7 @@
Some("[email protected] (plus)"),
));
let guided = client
.http_error_with_route_context(429, body, None)
.http_error_with_route_context(429, body, None, None)
.to_string();
assert!(
guided.contains("The usage limit has been reached"),
@@ -928,7 +928,7 @@
);
assert!(!guided.contains("or-quota-key-1234567890"), "{guided}");
// Ordinary rate limits stay retryable and unannotated.
let rate = client.http_error_with_route_context(429, "Too Many Requests", None);
let rate = client.http_error_with_route_context(429, "Too Many Requests", None, None);
assert!(rate.is_retryable());
assert!(!rate.to_string().contains("codewhale auth"), "{rate}");
}
@@ -962,7 +962,7 @@
let body =
r#"{"error":{"code":"credit_balance_exhausted","message":"credit balance exhausted"}}"#;
let error = client
.http_error_with_route_context(402, body, None)
.http_error_with_route_context(402, body, None, None)
.to_string();
assert!(!error.contains("codewhale auth"), "{error}");
}
@@ -1046,6 +1046,7 @@
429,
r#"{"error":{"code":"subscription_sharing_usage_limit_exceeded"}}"#,
None,
None,
);
assert!(matches!(error, LlmError::QuotaExhausted(_)));
assert!(!error.is_retryable());
+19
View File
@@ -111,6 +111,25 @@ pub(crate) fn parse_usage(usage: Option<&Value>) -> Usage {
reasoning_tokens,
reasoning_replay_tokens: None,
server_tool_use,
input_audio_tokens: usage
.and_then(|usage| {
usage
.pointer("/prompt_tokens_details/audio_tokens")
.or_else(|| usage.pointer("/input_tokens_details/audio_tokens"))
})
.and_then(Value::as_u64)
.map(saturating_u32),
output_audio_tokens: usage
.and_then(|usage| {
usage
.pointer("/completion_tokens_details/audio_tokens")
.or_else(|| usage.pointer("/output_tokens_details/audio_tokens"))
})
.and_then(Value::as_u64)
.map(saturating_u32),
request_usages: Vec::new(),
}
}
@@ -54,6 +54,14 @@ fn normalize(message: &str, temp: &TempDir) -> String {
if let Some(home) = crate::config::effective_home_dir() {
text = text.replace(home.to_str().unwrap(), "<TEST_HOME>");
}
// Catalog freshness depends on the bundled snapshot's age against the
// wall clock, so it cannot be part of a stable baseline.
while let Some(start) = text.find(" · Catalog: ") {
let Some(end) = text[start..].find(" · Cloud facts:") else {
break;
};
text.replace_range(start..start + end, "");
}
text
}
@@ -105,6 +105,11 @@ impl CommandDebugDiagnosticsContext for DebugDiagnosticsAdapter<'_> {
reasoning_tokens: rec.reasoning_tokens,
reasoning_replay_tokens: rec.reasoning_replay_tokens,
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
});
DebugCacheTurn {
provider: rec.provider.map(|provider| provider.as_str().to_string()),
@@ -1,5 +1,5 @@
{
"message": "codewhale 0.10.2\n\n Route: deepseek · baseline-{provider}-model · reasoning max\n Directory: <WORKSPACE>\n Project docs: AGENTS.md\n Mode: Work · approvals ask · shell off · workspace\n Safety: no OS sandbox on this platform (workspace-write requested, not enforced), network requested off, not enforced\n MCP: 0 configured\n\n Context window: 0.0% used (48 / 1000000 tokens)\n Window source: catalog · Catalog: models.dev stale · Cloud facts: off\n Window override: [providers.deepseek] context_window in config.toml\n Session: baseline-{model}-session · 0 cells · 0 API messages\n Session tokens: 0 in · 0 out · 1234 total · cache not reported\n Session cost: $0.0000\n Session metrics: 4 turns\n Tool outputs: no large outputs tracked\n\n Per-turn tokens: /tokens · Footer items: /statusline\n",
"message": "codewhale 0.10.2\n\n Route: deepseek · baseline-{provider}-model · reasoning max\n Directory: <WORKSPACE>\n Project docs: AGENTS.md\n Mode: Work · approvals ask · shell off · workspace\n Safety: no OS sandbox on this platform (workspace-write requested, not enforced), network requested off, not enforced\n MCP: 0 configured\n\n Context window: 0.0% used (48 / 1000000 tokens)\n Window source: catalog · Cloud facts: off\n Window override: [providers.deepseek] context_window in config.toml\n Session: baseline-{model}-session · 0 cells · 0 API messages\n Session tokens: 0 in · 0 out · 1234 total · cache not reported\n Session cost: $0.0000\n Session metrics: 4 turns\n Tool outputs: no large outputs tracked\n\n Per-turn tokens: /tokens · Footer items: /statusline\n",
"error": false,
"action": "None"
}
@@ -1506,6 +1506,8 @@ mod cost_breakdown_tests {
live_pricing_defect: None,
usd_priced: true,
cny_priced: estimate.cny > 0.0,
exact_usd: None,
exact_cny: None,
}
}
+6 -1
View File
@@ -4470,7 +4470,11 @@ fn save_deepseek_key_uses_isolated_file_store_without_plaintext_config() -> Resu
.lines()
.any(|line| line.trim_start().starts_with("api_key ="))
);
assert!(config.contains("auth_mode = \"api_key\""));
let parsed: toml::Value = toml::from_str(&config)?;
assert_eq!(
parsed["providers"]["deepseek"]["auth_mode"].as_str(),
Some("api_key")
);
// Saving a key leaves the model to the provider default.
assert!(!config.contains("default_text_model"), "{config}");
assert_eq!(
@@ -16920,6 +16924,7 @@ mod released_provider_serde {
currency: Currency::Usd,
provenance: PricingProvenance::UserOverride,
cloud_facts: None,
pricing_schedule: None,
input_per_million: input,
output_per_million: output,
cache_read_per_million: None,
+42 -2
View File
@@ -400,7 +400,7 @@ impl SessionRuntimeRisk {
"unbounded process termination can kill this or another Codewhale npm session's Node launcher; stop the owned server by PID or port instead"
}
Self::UnclassifiedWindowsInvocation => {
"Windows command input cannot be classified safely enough to exclude termination of Codewhale npm launchers; use a direct PID- or port-specific command"
"Windows command input could not be classified; review the command before execution"
}
}
}
@@ -651,7 +651,14 @@ fn deterministic_fallback(
if ctx.approval_mode != ApprovalMode::Bypass
&& let Some(risk) = ctx.session_runtime_risk
{
return AutoReviewDecision::safety_gate(risk.reason());
return match risk {
SessionRuntimeRisk::WindowsNodeImageKill => {
AutoReviewDecision::safety_gate(risk.reason())
}
SessionRuntimeRisk::UnclassifiedWindowsInvocation => {
AutoReviewDecision::new(AutoReviewAction::AskUser, risk.reason())
}
};
}
// Gate on the action, not the broad modal-styling risk bucket. Full Access
@@ -2029,6 +2036,39 @@ mod tests {
windows_session_runtime_risk(&nested),
Some(SessionRuntimeRisk::UnclassifiedWindowsInvocation)
);
for mode in [
ApprovalMode::Suggest,
ApprovalMode::Auto,
ApprovalMode::Never,
] {
let mut unclassified = ctx_for(
"bash",
json!({"command": nested}),
RunOrigin::Interactive,
mode,
);
unclassified.session_runtime_risk =
Some(SessionRuntimeRisk::UnclassifiedWindowsInvocation);
let policy = AutoReviewPolicy::default();
let decision = policy.evaluate(&unclassified);
assert_eq!(decision.action, AutoReviewAction::AskUser);
assert!(!decision.built_in_safety_gate);
assert!(decision.reason.contains("could not be classified"));
let (plan, audit) =
crate::core::engine::auto_review_plan_decision_for_context(&policy, &unclassified);
assert_eq!(audit["decision"], "ask_user");
if mode == ApprovalMode::Auto {
assert!(matches!(
plan,
crate::core::engine::AutoReviewPlanDecision::ConsultReviewer(_)
));
} else {
assert!(matches!(
plan,
crate::core::engine::AutoReviewPlanDecision::NoChange
));
}
}
let ctx = ctx_for(
"bash",
json!({"command": "taskkill /F /IM node.exe"}),
@@ -316,8 +316,10 @@ async fn terminal_diagnostics_merge_cumulative_usage_within_one_request() {
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
assert_eq!(mock.captured_requests().len(), 1);
let mut expected_usage = usage.clone();
expected_usage.request_usages = vec![usage.clone()];
assert_eq!(
turn.usage, usage,
turn.usage, expected_usage,
"repeated cumulative receipts are counted once"
);
let mut events = handle.rx_event.write().await;
@@ -1012,7 +1014,9 @@ async fn initial_routed_usage_is_total_only_emitted_once_and_keeps_parent_route_
drop(rx);
assert_eq!(routed_events, vec![classifier_usage.clone()]);
assert_eq!(terminal_parent_usage, parent_usage);
let mut expected_parent_usage = parent_usage.clone();
expected_parent_usage.request_usages = vec![parent_usage.clone()];
assert_eq!(terminal_parent_usage, expected_parent_usage);
assert_eq!(total_usage.input_tokens, 18);
assert_eq!(total_usage.output_tokens, 8);
assert_eq!(dropped_records, 2);
+15
View File
@@ -413,6 +413,11 @@ mod tests {
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
};
usage.add(&api_usage);
assert!(usage.cache_creation_input_tokens.is_none());
@@ -431,6 +436,11 @@ mod tests {
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
};
usage.add(&api_usage);
assert_eq!(usage.cache_read_input_tokens, Some(30));
@@ -452,6 +462,11 @@ mod tests {
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
};
usage.add(&api_usage);
// 0 is a valid observed value, must NOT be converted to None
+17
View File
@@ -244,6 +244,19 @@ impl TurnContext {
}
pub(crate) fn add_usage_to(total: &mut Usage, delta: &Usage) {
if total.request_usages.is_empty() && (total.input_tokens > 0 || total.output_tokens > 0) {
total.request_usages.push(total.clone());
}
if total.request_usages.len() <= 1024 {
if delta.request_usages.is_empty() {
total.request_usages.push(delta.clone());
} else {
let remaining = 1025 - total.request_usages.len();
total
.request_usages
.extend(delta.request_usages.iter().take(remaining).cloned());
}
}
total.input_tokens = total.input_tokens.saturating_add(delta.input_tokens);
total.output_tokens = total.output_tokens.saturating_add(delta.output_tokens);
total.prompt_cache_hit_tokens =
@@ -257,6 +270,10 @@ pub(crate) fn add_usage_to(total: &mut Usage, delta: &Usage) {
delta.prompt_cache_write_tokens,
);
total.reasoning_tokens = add_optional_usage(total.reasoning_tokens, delta.reasoning_tokens);
total.input_audio_tokens =
add_optional_usage(total.input_audio_tokens, delta.input_audio_tokens);
total.output_audio_tokens =
add_optional_usage(total.output_audio_tokens, delta.output_audio_tokens);
total.reasoning_replay_tokens =
add_optional_usage(total.reasoning_replay_tokens, delta.reasoning_replay_tokens);
if let Some(delta) = delta.server_tool_use.as_ref() {
+143 -2
View File
@@ -588,6 +588,25 @@ pub fn child_usage_metadata_fields(
"child_server_tool_use".into(),
serde_json::json!(usage.server_tool_use),
);
fields.insert(
"child_input_audio_tokens".into(),
serde_json::json!(usage.input_audio_tokens),
);
fields.insert(
"child_output_audio_tokens".into(),
serde_json::json!(usage.output_audio_tokens),
);
let requests = if usage.request_usages.len() > 1024
|| usage
.request_usages
.iter()
.any(|request| !request.request_usages.is_empty())
{
vec![Usage::default(); 1025]
} else {
usage.request_usages.clone()
};
fields.insert("child_request_usages".into(), serde_json::json!(requests));
fields
}
@@ -885,6 +904,9 @@ pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option<Usage>
"child_prompt_cache_write_tokens",
"child_reasoning_tokens",
"child_reasoning_replay_tokens",
"child_input_audio_tokens",
"child_output_audio_tokens",
"child_request_usages",
];
if !TOKEN_FIELDS
.iter()
@@ -900,6 +922,55 @@ pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option<Usage>
.map(|value| u32::try_from(value).unwrap_or(u32::MAX))
}
let invalid_requests = || vec![Usage::default(); 1025];
let token_value = |value: &serde_json::Value| {
value.is_null()
|| value
.as_u64()
.is_some_and(|value| u32::try_from(value).is_ok())
};
let flat_request = |request: &serde_json::Value| {
request.as_object().is_some_and(|fields| {
["input_tokens", "output_tokens"].iter().all(|field| {
fields
.get(*field)
.is_some_and(|value| value.as_u64().is_some())
}) && fields.iter().all(|(key, value)| match key.as_str() {
"input_tokens"
| "output_tokens"
| "prompt_cache_hit_tokens"
| "prompt_cache_miss_tokens"
| "prompt_cache_write_tokens"
| "reasoning_tokens"
| "reasoning_replay_tokens"
| "input_audio_tokens"
| "output_audio_tokens" => token_value(value),
"request_usages" => value.as_array().is_some_and(|nested| nested.is_empty()),
"server_tool_use" => {
value.is_null()
|| value.as_object().is_some_and(|tools| {
tools.iter().all(|(key, value)| {
matches!(
key.as_str(),
"code_execution_requests" | "tool_search_requests"
) && token_value(value)
})
})
}
_ => false,
})
})
};
let request_usages = match metadata.get("child_request_usages") {
None => Vec::new(),
Some(serde_json::Value::Array(requests))
if requests.len() <= 1024 && requests.iter().all(flat_request) =>
{
serde_json::from_value(serde_json::Value::Array(requests.clone()))
.unwrap_or_else(|_| invalid_requests())
}
Some(_) => invalid_requests(),
};
Some(Usage {
input_tokens: u32_field(metadata, "child_input_tokens").unwrap_or(0),
output_tokens: u32_field(metadata, "child_output_tokens").unwrap_or(0),
@@ -912,6 +983,10 @@ pub fn child_usage_from_metadata(metadata: &serde_json::Value) -> Option<Usage>
.get("child_server_tool_use")
.cloned()
.and_then(|value| serde_json::from_value(value).ok()),
input_audio_tokens: u32_field(metadata, "child_input_audio_tokens"),
output_audio_tokens: u32_field(metadata, "child_output_audio_tokens"),
request_usages,
})
}
@@ -4330,18 +4405,84 @@ mod tests {
#[test]
fn child_metadata_round_trip_preserves_zero_and_reasoning_usage() {
let route = deepseek_envelope();
let usage = Usage {
let mut usage = Usage {
input_tokens: 0,
output_tokens: 9,
reasoning_tokens: Some(7),
reasoning_replay_tokens: Some(3),
input_audio_tokens: Some(0),
output_audio_tokens: Some(1),
..Usage::default()
};
usage.request_usages.push(usage.clone());
let mut metadata = serde_json::json!({"tool": "rlm_eval"});
attach_child_usage_metadata(&mut metadata, &route, &usage);
assert_eq!(child_route_envelope_from_metadata(&metadata), Some(route));
assert_eq!(child_usage_from_metadata(&metadata), Some(usage));
assert_eq!(child_usage_from_metadata(&metadata), Some(usage.clone()));
metadata
.as_object_mut()
.unwrap()
.remove("child_request_usages");
let mut legacy = usage.clone();
legacy.request_usages.clear();
assert_eq!(child_usage_from_metadata(&metadata), Some(legacy));
for malformed in [
serde_json::json!(null),
serde_json::json!({}),
serde_json::json!([{}]),
serde_json::json!([{ "input_tokens": -1, "output_tokens": 9 }]),
serde_json::json!([{ "input_tokens": 0, "output_tokens": 9, "request_usages": [{}] }]),
serde_json::json!(vec![Usage::default(); 1025]),
] {
metadata["child_request_usages"] = malformed;
let decoded = child_usage_from_metadata(&metadata).unwrap();
assert!(matches!(
codewhale_config::pricing::OfferingPricing::default()
.quote_cost(&crate::pricing::token_usage_for_pricing(&decoded)),
Err(codewhale_config::pricing::PricingQuoteError::InvalidUsage)
));
}
let pricing = codewhale_config::pricing::OfferingPricing {
input_per_million: Some(1.0),
output_per_million: Some(0.0),
context_tiers: vec![
serde_json::from_value(serde_json::json!({
"tier": { "type": "context", "size": 200_000 },
"input": 2.0,
"output": 0.0,
}))
.unwrap(),
],
..Default::default()
};
let mut aggregate = Usage::default();
for _ in 0..3 {
crate::core::turn::add_usage_to(
&mut aggregate,
&Usage {
input_tokens: 100_000,
..Default::default()
},
);
}
let fields = child_usage_metadata_fields(&deepseek_envelope(), &aggregate);
let mut decoded = child_usage_from_metadata(&serde_json::Value::Object(fields)).unwrap();
assert_eq!(
pricing
.quote_cost(&crate::pricing::token_usage_for_pricing(&decoded))
.unwrap()
.amount,
"0.3"
);
decoded.request_usages.clear();
assert_eq!(
pricing
.quote_cost(&crate::pricing::token_usage_for_pricing(&decoded))
.unwrap()
.amount,
"0.6"
);
let mut zero_metadata = serde_json::json!({});
let zero = Usage::default();
+10
View File
@@ -3847,6 +3847,11 @@ NOEQUAL line dropped
reasoning_tokens: Some(4),
reasoning_replay_tokens: Some(2),
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
};
let payload = super::turn_end_payload(TurnEndPayloadInput {
@@ -4300,6 +4305,11 @@ printf '%s\n' '{{"text":"stdout is not a mutation contract"}}'
reasoning_tokens: None,
reasoning_replay_tokens: None,
server_tool_use: None,
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
};
let context = submit_context(&dir).with_tokens(15);
let payload = super::turn_end_payload(TurnEndPayloadInput {
+24 -32
View File
@@ -468,15 +468,33 @@ impl LlmError {
/// - Status code (429 = rate limit, 401/403 = auth, 499/5xx = transient upstream error)
/// - Response body keywords (`context_length`, `content_policy`, safety, etc.)
pub fn from_http_response(status: u16, body: &str) -> Self {
if let Some(error) = explicit_quota_code(body)
.or_else(|| explicit_quota_code_marker(body))
Self::from_http_response_with_retry_hints(
status,
body,
None,
retry_delay_from_error_body(body),
)
}
pub(crate) fn from_http_response_with_retry_hints(
status: u16,
body: &str,
retry_after: Option<Duration>,
body_retry_after: Option<Duration>,
) -> Self {
let body_retry_after = body_retry_after.filter(|delay| *delay <= PROVIDER_RETRY_HINT_MAX);
let quota_code = explicit_quota_code(body).or_else(|| explicit_quota_code_marker(body));
if let Some(error) = quota_code
.as_deref()
.and_then(Self::from_subscription_sharing_error_code)
{
return error;
}
// xAI refuses an exhausted account with a 403, not a 402/429.
if matches!(status, 400 | 402 | 403 | 429) && has_explicit_quota_evidence(body) {
if matches!(status, 400 | 402 | 403 | 429)
&& (quota_code.is_some()
|| has_explicit_quota_phrase(body, status == 429 && body_retry_after.is_some()))
{
return LlmError::QuotaExhausted(QuotaExhaustionError::from_http_message(
body.to_string(),
));
@@ -485,7 +503,7 @@ impl LlmError {
match status {
429 => LlmError::RateLimited {
message: body.to_string(),
retry_after: None,
retry_after: retry_after.or(body_retry_after),
},
401 => Self::authentication_error(body),
403 => {
@@ -631,23 +649,6 @@ impl LlmError {
}
}
/// Constructs an `LlmError` from HTTP status code, body, and optional Retry-After header.
pub fn from_http_response_with_retry_after(
status: u16,
body: &str,
retry_after: Option<Duration>,
) -> Self {
let mut error = Self::from_http_response(status, body);
if let LlmError::RateLimited {
retry_after: ref mut ra,
..
} = error
{
*ra = retry_after;
}
error
}
/// Constructs an `LlmError` from a reqwest error.
pub fn from_reqwest(err: &reqwest::Error) -> Self {
if err.is_timeout() {
@@ -786,15 +787,6 @@ pub(crate) fn is_context_length_message(lower: &str) -> bool {
.any(|phrase| lower.contains(phrase))
}
/// Quota exhaustion is a durable account state, not a generic rate-limit
/// synonym. Accept only explicit provider evidence at the HTTP/parser boundary;
/// callers holding a stringified error must never promote it to this type.
fn has_explicit_quota_evidence(body: &str) -> bool {
explicit_quota_code(body).is_some()
|| explicit_quota_code_marker(body).is_some()
|| has_explicit_quota_phrase(body)
}
fn explicit_quota_code(body: &str) -> Option<String> {
let value: Value = serde_json::from_str(body).ok()?;
[
@@ -848,7 +840,7 @@ fn explicit_quota_code_marker(body: &str) -> Option<String> {
is_explicit_quota_code(code).then(|| code.to_string())
}
fn has_explicit_quota_phrase(body: &str) -> bool {
fn has_explicit_quota_phrase(body: &str, bounded_rate_limit_hint: bool) -> bool {
let lower = body.to_ascii_lowercase();
let current_quota_exhausted = lower.contains("exceeded your current quota")
|| lower.contains("current quota has been exceeded");
@@ -889,7 +881,7 @@ fn has_explicit_quota_phrase(body: &str) -> bool {
|| lower.contains("credit balance exhausted")
|| lower.contains("credit balance is exhausted")
|| durable_scope_exhausted
|| (current_quota_exhausted && plan_and_billing_guidance)
|| (current_quota_exhausted && plan_and_billing_guidance && !bounded_rate_limit_hint)
}
fn extract_json_error_message(body: &str) -> Option<String> {
+15 -10
View File
@@ -236,13 +236,15 @@ pub fn maybe_load_persisted_cache() {
let Some(cache) = load_cache_file(&path) else {
return;
};
// Live only when the cache is recent by a trustworthy clock *and* came
// from the source this process would fetch now. A cache written for
// another mirror or override file still publishes (offline startups keep
// rows) but as stale, so the next refresh replaces it.
let freshness = if within_ttl(cache.fetched_at, now_unix())
&& cache.source_fingerprint == current_source_fingerprint()
{
// The cache belongs to the selected source. Another mirror or override
// cannot supply fallback facts for this selection.
if cache.source_fingerprint != current_source_fingerprint() {
mark_failed(ModelsDevRefreshError::InvalidResponse(
"cached catalog belongs to a different source".into(),
));
return;
}
let freshness = if within_ttl(cache.fetched_at, now_unix()) {
ModelsDevFreshness::Live
} else {
ModelsDevFreshness::Stale
@@ -344,7 +346,10 @@ pub fn spawn_background_refresh() {
if env_truthy(ENV_DISABLE_FETCH) && std::env::var(ENV_MODELS_DEV_PATH).is_err() {
return;
}
tokio::spawn(async {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return;
};
handle.spawn(async {
match refresh(false).await {
Ok(count) => {
tracing::debug!(
@@ -815,7 +820,7 @@ mod tests {
let stale = PersistedModelsDevCache {
schema_version: CACHE_SCHEMA_VERSION,
fetched_at: 1, // far in the past → stale
source_fingerprint: "stale-fp".into(),
source_fingerprint: current_source_fingerprint(),
source_label: "https://models.dev/catalog.json".into(),
body: FIXTURE.into(),
};
@@ -863,7 +868,7 @@ mod tests {
};
save_cache_file(&cache, &foreign).expect("save");
maybe_load_persisted_cache();
assert_eq!(status().freshness, ModelsDevFreshness::Stale);
assert_eq!(status().freshness, ModelsDevFreshness::Failed);
// From the current source, but stamped far in the future.
let future = PersistedModelsDevCache {
+3 -2
View File
@@ -1428,13 +1428,14 @@ async fn claude_login() -> Result<PendingOAuthLogin> {
tokio::task::spawn_blocking(move || {
let params = &CLAUDE_OAUTH_PARAMS;
let pkce = generate_pkce();
let expected_state = generate_state();
let redirect = "https://console.anthropic.com/oauth/code/callback";
let mut url = reqwest::Url::parse("https://claude.ai/oauth/authorize")?;
url.query_pairs_mut().extend_pairs([
("code", "true"), ("client_id", params.default_client_id),
("response_type", "code"), ("redirect_uri", redirect),
("scope", params.default_scopes), ("code_challenge", pkce.challenge.as_str()),
("code_challenge_method", "S256"), ("state", pkce.verifier.as_str()),
("code_challenge_method", "S256"), ("state", expected_state.as_str()),
]);
writeln!(output, "Open this URL to sign in with Claude:\n{url}\nPaste the authorization code (code#state):")?;
output.flush()?;
@@ -1446,7 +1447,7 @@ async fn claude_login() -> Result<PendingOAuthLogin> {
anyhow::ensure!(input.len() <= 8192, "Claude authorization code is too long");
let (code, state) = input.trim().split_once('#').context("Expected Claude authorization code#state")?;
anyhow::ensure!(!code.is_empty() && !code.chars().any(char::is_control)
&& codewhale_core::secret_eq::constant_time_eq(state.as_bytes(), pkce.verifier.as_bytes()),
&& codewhale_core::secret_eq::constant_time_eq(state.as_bytes(), expected_state.as_bytes()),
"Claude authorization state did not match the pending login");
let (status, body) = ReqwestOAuthFormClient.post_form(
&form_token_url(params, params.default_issuer), &[
+357 -229
View File
@@ -8,7 +8,7 @@
use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Timelike, Utc, Weekday};
use codewhale_config::pricing::{
Currency, LIVE_PRICING_MAX_AGE_SECS, LivePricingDefect, OfferingPricing, PricingProvenance,
TokenClass, TokenUsage,
PricingQuoteError, TokenClass, TokenUsage,
};
#[cfg(test)]
@@ -875,6 +875,7 @@ fn is_grok_tiered(model: &str) -> bool {
)
}
#[cfg(test)]
fn pricing_for_model_and_usage(model: &str, usage: &Usage) -> Option<ModelPricing> {
if is_minimax_m3(model) {
return Some(minimax_m3_standard_pricing(
@@ -1093,27 +1094,27 @@ pub fn calculate_turn_cost_estimate_from_usage(model: &str, usage: &Usage) -> Op
/// Only cache-write can fail here: input, cache-read, and output rates are
/// mandatory on every hand row, while a cache-creation rate exists only where a
/// provider publishes one or documents that writes cost nothing extra.
#[cfg(test)]
fn cost_estimate_with_pricing_checked(
pricing: ModelPricing,
usage: &Usage,
) -> Result<CostEstimate, Vec<TokenClass>> {
let classes = token_usage_for_pricing(usage);
if classes.cache_write > 0
&& pricing
.usd
.cache_write
.rate(pricing.usd.input_cache_miss_per_million)
.is_none()
{
return Err(vec![TokenClass::CacheWrite]);
let usd = currency_offering_pricing(pricing.usd, Currency::Usd);
let missing = usd.unpriced_used_classes(&classes);
if !missing.is_empty() {
return Err(missing);
}
Ok(CostEstimate {
usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage),
cny: pricing
.cny
.map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage))
.unwrap_or(0.0),
})
let Some(usd) = usd.estimate_cost(&classes) else {
return Err(Vec::new());
};
let cny = match pricing.cny {
Some(pricing) => currency_offering_pricing(pricing, Currency::Cny)
.estimate_cost(&classes)
.ok_or_else(Vec::new)?,
None => 0.0,
};
Ok(CostEstimate { usd, cny })
}
/// Unchecked projection for the legacy model-only test helpers, which construct
@@ -1124,13 +1125,8 @@ fn cost_estimate_with_pricing_checked(
/// input rate.
#[cfg(test)]
fn cost_estimate_with_pricing(pricing: ModelPricing, usage: &Usage) -> CostEstimate {
CostEstimate {
usd: calculate_turn_cost_from_usage_with_pricing(pricing.usd, usage),
cny: pricing
.cny
.map(|pricing| calculate_turn_cost_from_usage_with_pricing(pricing, usage))
.unwrap_or(0.0),
}
cost_estimate_with_pricing_checked(pricing, usage)
.expect("validated hand row and usage classes")
}
/// Calculate cost from provider/model usage when that pair identifies a single
@@ -1254,6 +1250,7 @@ pub enum UnpricedReason {
/// reported input total. Pricing that contradictory partition would
/// over-count input, so the call is retained but fails closed.
InconsistentUsage,
MissingUsageBreakdown,
}
impl UnpricedReason {
@@ -1276,6 +1273,7 @@ impl UnpricedReason {
"invalid_pricing_row" => Self::InvalidPricingRow,
"unsupported_currency" | "currency_not_published" => Self::UnsupportedCurrency,
"inconsistent_usage" => Self::InconsistentUsage,
"missing_usage_breakdown" => Self::MissingUsageBreakdown,
"late_usage_ledger_unavailable" => Self::LateUsageUnavailable,
"late_usage_ledger_overflow" => Self::LateUsageOverflow,
_ => Self::UnrecordedCoverage,
@@ -1303,6 +1301,7 @@ impl UnpricedReason {
Self::InvalidPricingRow => MessageId::CostReasonInvalidRate,
Self::UnsupportedCurrency => MessageId::CostReasonCurrencyMissing,
Self::InconsistentUsage => MessageId::CostReasonUsageConflict,
Self::MissingUsageBreakdown => MessageId::CostReasonUsageBreakdown,
}
}
@@ -1327,6 +1326,7 @@ impl UnpricedReason {
Self::InvalidPricingRow => "invalid_pricing_row",
Self::UnsupportedCurrency => "unsupported_currency",
Self::InconsistentUsage => "inconsistent_usage",
Self::MissingUsageBreakdown => "missing_usage_breakdown",
}
}
@@ -1370,6 +1370,8 @@ pub struct TurnCostAudit {
/// be inferred from `estimate > 0`.
pub usd_priced: bool,
pub cny_priced: bool,
pub exact_usd: Option<String>,
pub exact_cny: Option<String>,
}
impl TurnCostAudit {
@@ -1387,6 +1389,8 @@ impl TurnCostAudit {
live_pricing_defect: None,
usd_priced,
cny_priced,
exact_usd: None,
exact_cny: None,
}
}
@@ -1399,6 +1403,8 @@ impl TurnCostAudit {
live_pricing_defect: None,
usd_priced: false,
cny_priced: false,
exact_usd: None,
exact_cny: None,
}
}
@@ -1411,6 +1417,8 @@ impl TurnCostAudit {
live_pricing_defect: None,
usd_priced: false,
cny_priced: false,
exact_usd: None,
exact_cny: None,
}
}
@@ -1425,6 +1433,8 @@ impl TurnCostAudit {
live_pricing_defect: Some(defect),
usd_priced: false,
cny_priced: false,
exact_usd: None,
exact_cny: None,
}
}
@@ -1565,41 +1575,27 @@ fn audit_turn_cost_for_provider_on_endpoint_for_identity_at(
return TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier);
}
// MiniMax-M3 doubles its published rates above 512K total input. The
// catalog row is necessarily static, so retain the usage-aware first-party
// table for both direct wire protocols after provider/model provenance has
// been canonicalized.
// Direct DeepSeek pricing carries an authoritative CNY row and recorded-time
// peak/off-peak tiers that a static catalog row cannot represent. These
// exact first-party routes intentionally override the catalog; no other
// provider/model text match is allowed to do so.
if matches!(
provider,
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
) && catalog_model.eq_ignore_ascii_case("minimax-m3")
{
return hand_priced_audit(pricing_for_model_and_usage(&catalog_model, usage), usage);
return hand_provider_audit(provider, &catalog_model, usage, recorded_at);
}
// xAI doubles Grok 4.6 / 4.5 / 4.3 input, cached-input, and output rates
// once the prompt reaches 200K tokens. Keep this provider-owned and
// usage-aware so a third-party route reusing the model slug never
// inherits xAI billing.
if provider == ProviderKind::Xai && is_grok_tiered(&catalog_model) {
return hand_priced_audit(pricing_for_model_and_usage(&catalog_model, usage), usage);
return hand_provider_audit(provider, &catalog_model, usage, recorded_at);
}
// Direct DeepSeek pricing carries an authoritative CNY row and recorded-time
// peak/off-peak tiers that a static catalog row cannot represent; Sonnet 5
// keeps riding the same recorded-time hand row (its rate is flat again
// since Anthropic cancelled the 2026-09-01 increase, but the contract that
// first-party Anthropic prices Sonnet 5 from its own row stays). These
// exact first-party routes intentionally override the catalog; no other
// provider/model text match is allowed to do so.
if direct_deepseek
|| (provider == ProviderKind::Anthropic
&& catalog_model.eq_ignore_ascii_case("claude-sonnet-5"))
{
return hand_priced_audit(
provider_owned_hand_pricing_at(provider, &catalog_model, recorded_at),
usage,
);
return hand_provider_audit(provider, &catalog_model, usage, recorded_at);
}
if let Some(pricing) = frozen_cloud_pricing {
@@ -1645,31 +1641,7 @@ fn audit_turn_cost_for_provider_on_endpoint_for_identity_at(
&& let Some(pricing) =
effective_offering_pricing(provider, &catalog_model, offering, &classes)
{
if let Some(estimate) =
catalog_cost_estimate_for_route(provider, &catalog_model, offering, usage)
{
let (usd_priced, cny_priced) = match pricing.currency {
Currency::Usd => (true, false),
Currency::Cny => (false, true),
Currency::Other(_) => (false, false),
};
return TurnCostAudit::priced(
estimate,
pricing.provenance.clone(),
usd_priced,
cny_priced,
)
.with_live_defect(live_defect);
}
let classes = pricing.unpriced_used_classes(&classes);
if classes.is_empty() {
// Every used class is priced, so the only way the estimate failed
// is a currency CodeWhale does not carry. Never convert.
return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency)
.with_live_defect(live_defect);
}
return TurnCostAudit::missing_classes(pricing.provenance, classes)
.with_live_defect(live_defect);
return audit_offering_pricing(pricing, usage).with_live_defect(live_defect);
}
// A few first-party rows predate or intentionally omit a Models.dev entry
@@ -1707,10 +1679,12 @@ fn refused_catalog_pricing_audit(
if !codewhale_config::pricing::catalog_cost_is_valid(cost) {
return Some(TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow));
}
cost.context_tiers()
.iter()
.any(|tier| tier.tier.kind != "context" || u64::from(usage.input_tokens) >= tier.tier.size)
.then(|| TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier))
let pricing = OfferingPricing::from_catalog_offering(offering)?;
matches!(
pricing.quote_cost(&token_usage_for_pricing(usage)),
Err(PricingQuoteError::UnsupportedTier)
)
.then(|| TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier))
}
/// Outcome of checking a catalog row's pricing provenance against the route.
@@ -1815,11 +1789,14 @@ fn verified_catalog_offering(
// `recorded_at` is the turn's own clock, which is the right reference for
// "was this price current when the turn happened".
let now_unix = u64::try_from(recorded_at.timestamp()).ok();
if pricing.provenance == PricingProvenance::ProviderLive
&& pricing
.effective_at
.zip(now_unix)
.is_some_and(|(effective_at, dispatched_at)| effective_at > dispatched_at)
if matches!(
pricing.provenance,
PricingProvenance::ProviderLive | PricingProvenance::ModelsDevLive
) && pricing
.fetched_at
.or(pricing.effective_at)
.zip(now_unix)
.is_some_and(|(effective_at, dispatched_at)| effective_at > dispatched_at)
{
return VerifiedOffering::FutureEffective;
}
@@ -1849,13 +1826,123 @@ fn hand_priced_audit(pricing: Option<ModelPricing>, usage: &Usage) -> TurnCostAu
let Some(pricing) = pricing else {
return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow);
};
let has_cny = pricing.cny.is_some();
match cost_estimate_with_pricing_checked(pricing, usage) {
Ok(estimate) => {
TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, has_cny)
}
Err(classes) => TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes),
audit_pricing_pair(
currency_offering_pricing(pricing.usd, Currency::Usd),
pricing
.cny
.map(|row| currency_offering_pricing(row, Currency::Cny)),
usage,
)
}
fn audit_pricing_pair(
primary: OfferingPricing,
secondary: Option<OfferingPricing>,
usage: &Usage,
) -> TurnCostAudit {
let mut audit = audit_offering_pricing(primary, usage);
if audit.estimate.is_none() {
return audit;
}
if let Some(secondary) = secondary {
let secondary = audit_offering_pricing(secondary, usage);
let Some(cost) = secondary.estimate else {
return secondary;
};
audit.estimate.as_mut().expect("priced primary").cny = cost.cny;
audit.cny_priced = secondary.cny_priced;
audit.exact_cny = secondary.exact_cny;
}
audit
}
pub(crate) fn provider_owned_hand_quote_rows(
provider: ProviderKind,
model: &str,
recorded_at: DateTime<Utc>,
) -> Option<(OfferingPricing, Option<OfferingPricing>)> {
let pricing = provider_owned_hand_pricing_at(provider, model, recorded_at)?;
let mut primary = currency_offering_pricing(pricing.usd, Currency::Usd);
let mut secondary = pricing
.cny
.map(|row| currency_offering_pricing(row, Currency::Cny));
let tier = if provider == ProviderKind::Xai && is_grok_tiered(model) {
Some((
u64::from(GROK_4_6_LONG_CONTEXT_THRESHOLD),
true,
grok_tiered_pricing(&model.to_ascii_lowercase(), true)?,
))
} else if matches!(
provider,
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic
) && is_minimax_m3(model)
{
Some((
u64::from(MINIMAX_M3_LONG_CONTEXT_THRESHOLD),
false,
minimax_m3_standard_pricing(true),
))
} else {
None
};
if let Some((size, inclusive, higher)) = tier {
primary
.context_tiers
.push(codewhale_config::models_dev::ModelsDevCostTier {
tier: codewhale_config::models_dev::ModelsDevContextTier {
kind: "context".into(),
size,
},
cost: currency_offering_pricing(higher.usd, Currency::Usd).cost_structure(),
});
primary.context_tiers_inclusive = inclusive;
if let Some(row) = secondary.as_mut()
&& let Some(higher) = higher.cny
{
row.context_tiers
.push(codewhale_config::models_dev::ModelsDevCostTier {
tier: codewhale_config::models_dev::ModelsDevContextTier {
kind: "context".into(),
size,
},
cost: currency_offering_pricing(higher, Currency::Cny).cost_structure(),
});
row.context_tiers_inclusive = inclusive;
}
}
for row in std::iter::once(&mut primary).chain(secondary.iter_mut()) {
row.provider = provider.as_str().into();
row.wire_model_id = model.into();
row.source_ref = Some(
match provider {
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic => {
"https://api-docs.deepseek.com/quick_start/pricing"
}
ProviderKind::Xai => "https://docs.x.ai/docs/models",
ProviderKind::Minimax | ProviderKind::MinimaxAnthropic => {
"https://platform.minimax.io/docs/guides/pricing-paygo"
}
ProviderKind::Anthropic => {
"https://platform.claude.com/docs/en/about-claude/pricing"
}
_ => "codewhale:reviewed-provider-price",
}
.into(),
);
}
Some((primary, secondary))
}
fn hand_provider_audit(
provider: ProviderKind,
model: &str,
usage: &Usage,
at: DateTime<Utc>,
) -> TurnCostAudit {
provider_owned_hand_quote_rows(provider, model, at).map_or_else(
|| TurnCostAudit::unpriced(UnpricedReason::NoPricingRow),
|(primary, secondary)| audit_pricing_pair(primary, secondary, usage),
)
}
/// Recorded-time variant with explicit billing-surface provenance.
@@ -1925,21 +2012,34 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
usage: &Usage,
recorded_at: DateTime<Utc>,
) -> TurnCostAudit {
let declared_pricing = match provider_live_pricing {
Some(quote) if quote.provenance == PricingProvenance::UserOverride => {
let pricing = provider_identity
if matches!(
endpoint_metering_for_billing_surface(billing_surface),
EndpointMetering::ExactSubscription | EndpointMetering::LocalNoBill
) {
return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered);
}
let frozen_pricing = match provider_live_pricing {
Some(quote) => {
let binding = provider_identity
.zip(endpoint_fingerprint)
.zip(u64::try_from(recorded_at.timestamp()).ok())
.and_then(|((identity, fingerprint), at)| {
quote.pricing_for_route(provider, identity, model, fingerprint, at)
});
let Some(pricing) = pricing else {
.zip(u64::try_from(recorded_at.timestamp()).ok());
let Some(((identity, fingerprint), at)) = binding else {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
};
Some(pricing)
let Some(pricing) = quote.pricing_for_route(provider, identity, model, fingerprint, at)
else {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
};
let secondary =
quote.secondary_pricing_for_route(provider, identity, model, fingerprint, at);
Some((pricing, secondary))
}
_ => None,
None => None,
};
let declared = frozen_pricing
.as_ref()
.is_some_and(|(pricing, _)| pricing.provenance == PricingProvenance::UserOverride);
let reviewed_custom_metered = reviewed_custom_route_is_metered(provider, endpoint_fingerprint);
let reviewed_provider_live =
reviewed_provider_live_route_is_metered(provider, provider_identity, endpoint_fingerprint);
@@ -1951,9 +2051,7 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
return TurnCostAudit::unpriced(UnpricedReason::NotMoneyMetered);
}
EndpointMetering::Unknown
if billing_surface.is_some()
&& !reviewed_custom_metered
&& declared_pricing.is_none() =>
if billing_surface.is_some() && !reviewed_custom_metered && !declared =>
{
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
}
@@ -1962,21 +2060,14 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
if !usage_cache_partition_is_consistent(usage) {
return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage);
}
if let Some(pricing) = declared_pricing {
return audit_offering_pricing(pricing, usage);
if declared && let Some((pricing, secondary)) = frozen_pricing.clone() {
return audit_pricing_pair(pricing, secondary, usage);
}
if provider == ProviderKind::Stepfun {
return match pricing_for_billing_surface(provider, model, billing_surface) {
// Each model keeps its documented cache-write policy; unpublished
// write rates fail closed instead of borrowing another model's rate.
Some(pricing) => match cost_estimate_with_pricing_checked(pricing, usage) {
Ok(estimate) => {
TurnCostAudit::priced(estimate, PricingProvenance::ProviderDocs, true, false)
}
Err(classes) => {
TurnCostAudit::missing_classes(PricingProvenance::ProviderDocs, classes)
}
},
Some(pricing) => hand_priced_audit(Some(pricing), usage),
// The surface classified as per-token but no rates exist for it, or
// no surface was established at all.
None => TurnCostAudit::unpriced(match billing_surface {
@@ -2001,45 +2092,17 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
if billing_surface.is_none() {
return TurnCostAudit::unpriced(UnpricedReason::UnestablishedEndpoint);
}
if reviewed_provider_live {
let Some(provider_identity) = provider_identity.map(str::trim).filter(|id| !id.is_empty())
else {
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
};
let Some(endpoint_fingerprint) = endpoint_fingerprint else {
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
};
let Some(dispatched_at_unix) = u64::try_from(recorded_at.timestamp()).ok() else {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
};
let pricing = match provider_live_pricing {
Some(quote) => {
let Some(pricing) = quote.pricing_for_route(
provider,
provider_identity,
model,
endpoint_fingerprint,
dispatched_at_unix,
) else {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
};
pricing
}
None if provider == ProviderKind::Openrouter => {
// An offline/startup OpenRouter dispatch has no mutable live
// quote to freeze. Audit it only against the immutable bundled
// snapshot (and provider-owned hand rows, if one is added), so
// a refresh that lands after dispatch cannot retro-price it.
return audit_openrouter_immutable_pricing(model, usage, recorded_at);
}
None => {
// Baseten has no reviewed immutable price card. Its compatible
// custom route therefore requires the exact frozen live quote.
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
}
};
return audit_offering_pricing(pricing, usage);
if let Some((pricing, secondary)) = frozen_pricing {
return audit_pricing_pair(pricing, secondary, usage);
}
if reviewed_provider_live {
return if provider == ProviderKind::Openrouter {
audit_openrouter_immutable_pricing(model, usage, recorded_at)
} else {
TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing)
};
}
if provider == ProviderKind::Openrouter && provider_identity.is_some() {
// A persisted built-in OpenRouter receipt that is missing the exact
// official identity/endpoint binding (or its frozen quote) must not
@@ -2049,21 +2112,6 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
if provider == ProviderKind::Custom {
return TurnCostAudit::unpriced(UnpricedReason::UnknownBillingBasis);
}
let frozen_cloud_pricing = match provider_live_pricing {
Some(quote) if quote.provenance == PricingProvenance::CloudFacts => {
let pricing = provider_identity
.zip(endpoint_fingerprint)
.zip(u64::try_from(recorded_at.timestamp()).ok())
.and_then(|((identity, fingerprint), dispatched)| {
quote.pricing_for_route(provider, identity, model, fingerprint, dispatched)
});
let Some(pricing) = pricing else {
return TurnCostAudit::unpriced(UnpricedReason::UnverifiedLivePricing);
};
Some(pricing)
}
_ => None,
};
audit_turn_cost_for_provider_on_endpoint_for_identity_at(
provider,
provider_identity,
@@ -2072,20 +2120,41 @@ pub(crate) fn audit_turn_cost_for_route_on_endpoint_for_identity_at(
usage,
recorded_at,
false,
frozen_cloud_pricing,
None,
)
}
fn audit_offering_pricing(pricing: OfferingPricing, usage: &Usage) -> TurnCostAudit {
let classes = token_usage_for_pricing(usage);
let unpriced = pricing.unpriced_used_classes(&classes);
if !unpriced.is_empty() {
return TurnCostAudit::missing_classes(pricing.provenance, unpriced);
if pricing.rate_withheld {
return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow);
}
let Some(amount) = pricing.estimate_cost(&classes) else {
let classes = token_usage_for_pricing(usage);
let quote = match pricing.quote_cost(&classes) {
Ok(quote) => quote,
Err(PricingQuoteError::MissingRate(class)) => {
let mut missing = pricing.unpriced_used_classes(&classes);
if !missing.contains(&class) {
missing.push(class);
}
return TurnCostAudit::missing_classes(pricing.provenance, missing);
}
Err(PricingQuoteError::MissingUsage(_)) => {
return TurnCostAudit::unpriced(UnpricedReason::MissingUsageBreakdown);
}
Err(PricingQuoteError::UnsupportedTier) => {
return TurnCostAudit::unpriced(UnpricedReason::UnrepresentedTier);
}
Err(PricingQuoteError::InvalidUsage) => {
return TurnCostAudit::unpriced(UnpricedReason::InconsistentUsage);
}
Err(PricingQuoteError::InvalidRate) => {
return TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow);
}
};
let Ok(amount) = quote.amount.parse::<f64>() else {
return TurnCostAudit::unpriced(UnpricedReason::InvalidPricingRow);
};
let (estimate, usd, cny) = match pricing.currency {
let (estimate, usd, cny) = match quote.currency {
Currency::Usd => (CostEstimate::usd_only(amount), true, false),
Currency::Cny => (
CostEstimate {
@@ -2097,7 +2166,13 @@ fn audit_offering_pricing(pricing: OfferingPricing, usage: &Usage) -> TurnCostAu
),
Currency::Other(_) => return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency),
};
TurnCostAudit::priced(estimate, pricing.provenance, usd, cny)
let mut audit = TurnCostAudit::priced(estimate, pricing.provenance, usd, cny);
if usd {
audit.exact_usd = Some(quote.amount);
} else {
audit.exact_cny = Some(quote.amount);
}
audit
}
/// Price an exact official OpenRouter route without consulting mutable live
@@ -2112,7 +2187,6 @@ fn audit_openrouter_immutable_pricing(
else {
return TurnCostAudit::unpriced(UnpricedReason::NoPricingRow);
};
let classes = token_usage_for_pricing(usage);
if let Some(offering) = crate::provider_lake::bundled_catalog_offering_for_model(
ProviderKind::Openrouter,
&canonical_model,
@@ -2120,32 +2194,8 @@ fn audit_openrouter_immutable_pricing(
if let Some(audit) = refused_catalog_pricing_audit(&offering, usage) {
return audit;
}
if let Some(pricing) = effective_offering_pricing(
ProviderKind::Openrouter,
&canonical_model,
&offering,
&classes,
) {
let unpriced_classes = pricing.unpriced_used_classes(&classes);
if !unpriced_classes.is_empty() {
return TurnCostAudit::missing_classes(pricing.provenance, unpriced_classes);
}
let Some(estimate) = catalog_cost_estimate_for_route(
ProviderKind::Openrouter,
&canonical_model,
&offering,
usage,
) else {
return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency);
};
let (usd_priced, cny_priced) = match pricing.currency {
Currency::Usd => (true, false),
Currency::Cny => (false, true),
Currency::Other(_) => {
return TurnCostAudit::unpriced(UnpricedReason::UnsupportedCurrency);
}
};
return TurnCostAudit::priced(estimate, pricing.provenance, usd_priced, cny_priced);
if let Some(pricing) = OfferingPricing::from_catalog_offering(&offering) {
return audit_offering_pricing(pricing, usage);
}
}
@@ -2478,6 +2528,14 @@ fn effective_offering_pricing(
if classes.cache_write > 0 && pricing.cache_write_per_million.is_none() {
pricing.cache_write_per_million = pricing.input_per_million;
}
for tier in &mut pricing.context_tiers {
if classes.cache_read > 0 && tier.cost.cache_read.is_none() {
tier.cost.cache_read = tier.cost.input;
}
if classes.cache_write > 0 && tier.cost.cache_write.is_none() {
tier.cost.cache_write = tier.cost.input;
}
}
}
Some(pricing)
}
@@ -2485,6 +2543,7 @@ fn effective_offering_pricing(
/// Estimate usage only from the exact provider offering. Missing prices for a
/// used token class fail closed, except on the two documented first-party
/// routes where cache tokens are explicitly billed at the input rate.
#[cfg(test)]
fn catalog_cost_estimate_for_route(
provider: ProviderKind,
model: &str,
@@ -2519,10 +2578,29 @@ fn catalog_cost_estimate_for_route(
/// `reasoning_tokens` under `output_tokens_details` while `output_tokens` is
/// the total, and Chat Completions nests it under `completion_tokens_details`
/// while `completion_tokens` is the total. Adding it charged reasoning turns
/// twice for the same tokens (up to 2x on reasoning-heavy turns). It stays on
/// `Usage` as informational telemetry (`/usage`, hooks, sub-agent metadata).
/// twice for the same tokens (up to 2x on reasoning-heavy turns). It uses
/// its own class when a rate is published, replacing that subset of output.
#[must_use]
pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage {
let mut classes = request_token_usage_for_pricing(usage);
classes.request_usages = usage
.request_usages
.iter()
.take(1025)
.map(|request| {
let mut classes = request_token_usage_for_pricing(request);
if !request.request_usages.is_empty() {
classes
.request_usages
.push(request_token_usage_for_pricing(request));
}
classes
})
.collect();
classes
}
fn request_token_usage_for_pricing(usage: &Usage) -> TokenUsage {
// `input_tokens` is the authoritative total. Even malformed provider
// telemetry must never produce token classes whose sum exceeds it. The
// audit path rejects contradictory partitions; this bounded projection
@@ -2541,12 +2619,31 @@ pub fn token_usage_for_pricing(usage: &Usage) -> TokenUsage {
let input = non_cached_reported.saturating_add(uncategorized_input);
// Reasoning tokens are already inside `output_tokens`; see the doc comment.
let output = usage.output_tokens;
// Subset telemetry is clamped like the cache partition above: malformed
// provider counts that exceed their parent total must never fail the
// whole estimate closed or double-count.
let reasoning = usage
.reasoning_tokens
.map(|tokens| u64::from(tokens.min(output)));
let output_audio = usage.output_audio_tokens.map(|tokens| {
let remaining = u64::from(output).saturating_sub(reasoning.unwrap_or(0));
u64::from(tokens).min(remaining)
});
let input_audio = usage
.input_audio_tokens
.map(|tokens| u64::from(tokens).min(u64::from(input)));
TokenUsage {
input: u64::from(input),
output: u64::from(output),
cache_read: u64::from(cache_read),
cache_write: u64::from(cache_write),
reasoning,
input_audio,
output_audio,
request_usages: Vec::new(),
}
}
@@ -2557,20 +2654,18 @@ fn usage_cache_partition_is_consistent(usage: &Usage) -> bool {
reported <= u64::from(usage.input_tokens)
}
fn calculate_turn_cost_from_usage_with_pricing(pricing: CurrencyPricing, usage: &Usage) -> f64 {
let usage = token_usage_for_pricing(usage);
let hit_cost = (usage.cache_read as f64 / 1_000_000.0) * pricing.input_cache_hit_per_million;
let miss_cost = (usage.input as f64 / 1_000_000.0) * pricing.input_cache_miss_per_million;
// An unpublished write policy is only reachable here for usage with zero
// cache-write tokens; `cost_estimate_with_pricing_checked` rejects the rest
// before any money is computed.
let write_rate = pricing
.cache_write
.rate(pricing.input_cache_miss_per_million)
.unwrap_or(0.0);
let write_cost = (usage.cache_write as f64 / 1_000_000.0) * write_rate;
let output_cost = (usage.output as f64 / 1_000_000.0) * pricing.output_per_million;
hit_cost + miss_cost + write_cost + output_cost
fn currency_offering_pricing(pricing: CurrencyPricing, currency: Currency) -> OfferingPricing {
OfferingPricing {
currency,
input_per_million: Some(pricing.input_cache_miss_per_million),
output_per_million: Some(pricing.output_per_million),
cache_read_per_million: Some(pricing.input_cache_hit_per_million),
cache_write_per_million: pricing
.cache_write
.rate(pricing.input_cache_miss_per_million),
provenance: PricingProvenance::ProviderDocs,
..Default::default()
}
}
/// Estimate how much money was saved by serving `cache_hit_tokens` from the
@@ -4118,12 +4213,21 @@ mod tests {
#[test]
fn recorded_time_provider_cost_keeps_catalog_cache_write_tier() {
let request = Usage {
input_tokens: 100_000,
output_tokens: 0,
prompt_cache_hit_tokens: Some(0),
prompt_cache_miss_tokens: Some(0),
prompt_cache_write_tokens: Some(100_000),
..Default::default()
};
let usage = Usage {
input_tokens: 1_000_000,
output_tokens: 0,
prompt_cache_hit_tokens: Some(0),
prompt_cache_miss_tokens: Some(0),
prompt_cache_write_tokens: Some(1_000_000),
request_usages: vec![request; 10],
..Default::default()
};
@@ -4391,6 +4495,12 @@ mod tests {
output: 100,
cache_read: 250,
cache_write: 50,
reasoning: Some(50),
input_audio: None,
output_audio: None,
request_usages: Vec::new(),
}
);
@@ -4526,6 +4636,12 @@ mod tests {
output: 100,
cache_read: 250,
cache_write: 0,
reasoning: None,
input_audio: None,
output_audio: None,
request_usages: Vec::new(),
}
);
}
@@ -5112,12 +5228,25 @@ mod tests {
}
fn official_route_audit(provider: ProviderKind, model: &str, usage: &Usage) -> TurnCostAudit {
audit_turn_cost_for_route_at(
let base = provider.provider().default_base_url();
let at = Utc::now();
let fingerprint = codewhale_config::catalog::base_url_fingerprint(base);
let quote = crate::provider_catalog_live::fresh_dispatch_pricing_quote_at(
provider,
provider.as_str(),
model,
billing_surface_for_route(provider, Some(provider.provider().default_base_url())),
base,
at.timestamp() as u64,
);
audit_turn_cost_for_route_on_endpoint_for_identity_at(
provider,
Some(provider.as_str()),
model,
billing_surface_for_route(provider, Some(base)),
Some(&fingerprint),
quote.as_ref(),
usage,
Utc::now(),
at,
)
}
@@ -5253,7 +5382,7 @@ mod tests {
audit.unpriced_reason,
Some(UnpricedReason::UnverifiedLivePricing)
);
assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevBundled));
assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevLive));
assert_eq!(audit.live_pricing_defect, None);
let estimate = audit.estimate.expect("priced");
assert!(
@@ -5293,7 +5422,16 @@ mod tests {
);
let usage = million_input_usage();
let audit = official_route_audit(ProviderKind::OpencodeZen, "deepseek-v4-flash", &usage);
let fingerprint = codewhale_config::catalog::base_url_fingerprint(
ProviderKind::OpencodeZen.provider().default_base_url(),
);
let audit = audit_turn_cost_for_provider_on_endpoint_at(
ProviderKind::OpencodeZen,
"deepseek-v4-flash",
Some(&fingerprint),
&usage,
now,
);
crate::provider_lake::clear_live_snapshot();
assert!(audit.is_priced(), "{audit:?}");
@@ -5339,16 +5477,10 @@ mod tests {
);
let usage = million_input_usage();
let audit = audit_turn_cost_for_route_on_endpoint_at(
let audit = official_route_audit(
ProviderKind::Fireworks,
"accounts/fireworks/models/kimi-k3",
billing_surface_for_route(
ProviderKind::Fireworks,
Some(crate::config::DEFAULT_FIREWORKS_BASE_URL),
),
Some(&fingerprint),
&usage,
now,
);
crate::provider_lake::clear_live_snapshot();
@@ -5397,13 +5529,9 @@ mod tests {
crate::provider_lake::LiveSource::PerProvider,
);
let audit = audit_turn_cost_for_route_on_endpoint_at(
let audit = audit_turn_cost_for_provider_on_endpoint_at(
ProviderKind::Fireworks,
"accounts/fireworks/models/future-price-only",
billing_surface_for_route(
ProviderKind::Fireworks,
Some(crate::config::DEFAULT_FIREWORKS_BASE_URL),
),
Some(&fingerprint),
&million_input_usage(),
dispatched_at,
@@ -5466,7 +5594,7 @@ mod tests {
crate::provider_lake::clear_live_snapshot();
assert!(audit.is_priced(), "{audit:?}");
assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevBundled));
assert_eq!(audit.provenance, Some(PricingProvenance::ModelsDevLive));
assert_eq!(audit.live_pricing_defect, None);
let estimate = audit.estimate.expect("priced");
assert!(
@@ -1,7 +1,7 @@
[
{
"reason": "no_pricing_row",
"review": "Source reviewed at 36f11b82; fixture recorded time is 2026-09-08T12:00Z with 1000 input/100 output and no cache usage or live/signed/override price state. Exact default tuples below have no usable provider-owned bundled/hand row (pricing.rs:1639,1674,2282; provider_lake.rs:993). OpenRouter official identity with no frozen quote uses immutable bundled fallback (pricing.rs:2011,2089); its DeepSeek Pro row is costless. Zen resolves gpt-5.6 to wire gpt-5.6-sol (route/offering.rs:312), while its hand fallback prices only DeepSeek Flash (pricing.rs:2426). Wanjie is not the direct-DeepSeek retired-alias/hand-price owner. No numeric rates or model-owner rates are inferred. ZenMux review: metered OpenAI-compatible aggregator; its deepseek/deepseek-v4.1-flash default has no bundled/hand pricing row and resolves costless until the live catalog prices it. ModelScope review (ba62522a20 omitted this row): same shape — metered default with no bundled/hand row in pricing.rs.",
"review": "Source reviewed at 36f11b82; fixture recorded time is 2026-09-08T12:00Z with 1000 input/100 output and no cache usage or live/signed/override price state. Exact default tuples below have no usable provider-owned bundled/hand row (pricing.rs:1639,1674,2282; provider_lake.rs:993). OpenRouter and Together defaults retired from this group: the refreshed bundled seed now prices deepseek/deepseek-v4-pro and deepseek-ai/DeepSeek-V4-Pro. Zen resolves gpt-5.6 to wire gpt-5.6-sol (route/offering.rs:312), while its hand fallback prices only DeepSeek Flash (pricing.rs:2426). Wanjie is not the direct-DeepSeek retired-alias/hand-price owner. No numeric rates or model-owner rates are inferred. ZenMux review: metered OpenAI-compatible aggregator; its deepseek/deepseek-v4.1-flash default has no bundled/hand pricing row and resolves costless until the live catalog prices it. ModelScope review (ba62522a20 omitted this row): same shape — metered default with no bundled/hand row in pricing.rs.",
"routes": [
["atlascloud", "deepseek-ai/deepseek-v4-flash", "https://api.atlascloud.ai/v1", "chat_completions", "metered"],
["codewhale", "deepseek/deepseek-v4-pro", "https://api.codewhale.net/v1", "chat_completions", "metered"],
@@ -15,14 +15,12 @@
["nvidia-nim", "deepseek-ai/deepseek-v4-pro", "https://integrate.api.nvidia.com/v1", "chat_completions", "metered"],
["opencode-zen", "gpt-5.6-sol", "https://opencode.ai/zen/v1", "responses", "metered"],
["openmodel", "deepseek-v4-flash", "https://api.openmodel.ai", "anthropic_messages", "metered"],
["openrouter", "deepseek/deepseek-v4-pro", "https://openrouter.ai/api/v1", "chat_completions", "metered"],
["orcarouter", "deepseek/deepseek-v4-pro", "https://api.orcarouter.ai/v1", "chat_completions", "metered"],
["qianfan", "ernie-4.0-turbo-8k", "https://api.baiduqianfan.ai/v1", "chat_completions", "metered"],
["sakana", "fugu", "https://api.sakana.ai/v1", "chat_completions", "metered"],
["siliconflow", "deepseek-ai/DeepSeek-V4-Pro", "https://api.siliconflow.com/v1", "chat_completions", "metered"],
["siliconflow-CN", "deepseek-ai/DeepSeek-V4-Pro", "https://api.siliconflow.cn/v1", "chat_completions", "metered"],
["telecomjs", "deepseek-v4-pro", "https://aigw.telecomjs.com/v1", "chat_completions", "metered"],
["together", "deepseek-ai/DeepSeek-V4-Pro", "https://api.together.xyz/v1", "chat_completions", "metered"],
["wanjie-ark", "deepseek-reasoner", "https://maas-openapi.wanjiedata.com/api/v1", "chat_completions", "metered"],
["zenmux", "deepseek/deepseek-v4.1-flash", "https://zenmux.ai/api/v1", "chat_completions", "metered"]
]
+286 -5
View File
@@ -90,6 +90,7 @@ pub struct ProviderLivePricingQuote {
pub(crate) currency: Currency,
pub(crate) provenance: PricingProvenance,
pub(crate) cloud_facts: Option<CloudFactsPricingSource>,
pub(crate) pricing_schedule: Option<String>,
pub(crate) input_per_million: Option<String>,
pub(crate) output_per_million: Option<String>,
pub(crate) cache_read_per_million: Option<String>,
@@ -120,6 +121,8 @@ struct ProviderLivePricingQuoteWire {
#[serde(default, skip_serializing_if = "Option::is_none")]
cloud_facts: Option<CloudFactsPricingSource>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pricing_schedule: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
input_per_million: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
output_per_million: Option<String>,
@@ -147,6 +150,7 @@ impl TryFrom<&ProviderLivePricingQuote> for ProviderLivePricingQuoteWire {
currency: quote.currency.clone(),
provenance: quote.provenance.clone(),
cloud_facts: quote.cloud_facts.clone(),
pricing_schedule: quote.pricing_schedule.clone(),
input_per_million: quote.input_per_million.clone(),
output_per_million: quote.output_per_million.clone(),
cache_read_per_million: quote.cache_read_per_million.clone(),
@@ -190,6 +194,7 @@ impl<'de> Deserialize<'de> for ProviderLivePricingQuote {
currency: wire.currency,
provenance: wire.provenance,
cloud_facts: wire.cloud_facts,
pricing_schedule: wire.pricing_schedule,
input_per_million: wire.input_per_million,
output_per_million: wire.output_per_million,
cache_read_per_million: wire.cache_read_per_million,
@@ -227,6 +232,35 @@ impl ProviderLivePricingQuote {
.any(Option::is_some)
}
pub(crate) fn carries_pricing_decision(&self) -> bool {
self.carries_rates()
|| self
.pricing_schedule
.as_deref()
.filter(|schedule| schedule.len() <= 32_768)
.and_then(|schedule| {
serde_json::from_str::<(OfferingPricing, Option<OfferingPricing>)>(schedule)
.ok()
})
.is_some_and(|(pricing, _)| pricing.has_any_price() || pricing.rate_withheld)
}
pub(crate) fn secondary_pricing_for_route(
&self,
provider: ProviderKind,
identity: &str,
model: &str,
fingerprint: &str,
at: u64,
) -> Option<OfferingPricing> {
self.pricing_for_route(provider, identity, model, fingerprint, at)?;
serde_json::from_str::<(OfferingPricing, Option<OfferingPricing>)>(
self.pricing_schedule.as_deref()?,
)
.ok()?
.1
}
fn is_structurally_valid(&self) -> bool {
self.pricing_for_route(
self.provider,
@@ -254,6 +288,7 @@ impl ProviderLivePricingQuote {
cache_read_per_million: &Option<String>,
cache_write_per_million: &Option<String>,
cloud_facts: Option<&CloudFactsPricingSource>,
pricing_schedule: Option<&str>,
) -> Option<String> {
let payload = serde_json::to_vec(&(
"codewhale-provider-live-pricing-quote-v1",
@@ -280,6 +315,12 @@ impl ProviderLivePricingQuote {
}
None => payload,
};
let payload = match pricing_schedule {
Some(schedule) => {
serde_json::to_vec(&("codewhale-pricing-schedule-v2", payload, schedule)).ok()?
}
None => payload,
};
Some(format!("sha256:{}", crate::hashing::sha256_hex(payload)))
}
@@ -303,6 +344,11 @@ impl ProviderLivePricingQuote {
let output_per_million = Self::canonical_rate(pricing.output_per_million);
let cache_read_per_million = Self::canonical_rate(pricing.cache_read_per_million);
let cache_write_per_million = Self::canonical_rate(pricing.cache_write_per_million);
let mut owned = pricing.clone();
owned.provider = provider_identity.into();
owned.wire_model_id = wire_model.into();
let pricing_schedule =
Some(serde_json::to_string(&(owned, Option::<OfferingPricing>::None)).ok()?);
let catalog_revision = Self::revision_for(
provider,
provider_identity,
@@ -316,6 +362,7 @@ impl ProviderLivePricingQuote {
&cache_read_per_million,
&cache_write_per_million,
None,
pricing_schedule.as_deref(),
)?;
Some(Self {
provider,
@@ -327,6 +374,7 @@ impl ProviderLivePricingQuote {
currency: pricing.currency.clone(),
provenance: pricing.provenance.clone(),
cloud_facts: None,
pricing_schedule,
input_per_million,
output_per_million,
cache_read_per_million,
@@ -375,14 +423,32 @@ impl ProviderLivePricingQuote {
}
match (&self.provenance, &self.cloud_facts) {
(PricingProvenance::UserOverride, None) => {}
(PricingProvenance::ProviderLive, None)
(
PricingProvenance::ModelsDevBundled
| PricingProvenance::ProviderDocs
| PricingProvenance::CodewhaleCatalog,
None,
) if published_pricing_scope(provider, provider_identity, endpoint_fingerprint) => {}
(PricingProvenance::ModelsDevLive, None)
if dispatched_at_unix.saturating_sub(self.catalog_fetched_at)
< DEFAULT_PROVIDER_CATALOG_TTL_SECS
&& reviewed_provider_live_scope(
&& published_pricing_scope(
provider,
provider_identity,
endpoint_fingerprint,
) => {}
(PricingProvenance::ProviderLive, None)
if dispatched_at_unix.saturating_sub(self.catalog_fetched_at)
< DEFAULT_PROVIDER_CATALOG_TTL_SECS
&& (reviewed_provider_live_scope(
provider,
provider_identity,
endpoint_fingerprint,
) || published_pricing_scope(
provider,
provider_identity,
endpoint_fingerprint,
)) => {}
(PricingProvenance::CloudFacts, Some(source))
if source.facts_version > 0
&& !source.key_id.is_empty()
@@ -424,6 +490,7 @@ impl ProviderLivePricingQuote {
&& cost.cache_read.is_none()
&& cost.cache_write.is_none()
&& self.provenance != PricingProvenance::UserOverride
&& self.pricing_schedule.is_none()
{
return None;
}
@@ -440,10 +507,51 @@ impl ProviderLivePricingQuote {
&self.cache_read_per_million,
&self.cache_write_per_million,
self.cloud_facts.as_ref(),
self.pricing_schedule.as_deref(),
)?;
if self.catalog_revision != expected_revision {
return None;
}
if let Some(schedule) = self.pricing_schedule.as_deref() {
if schedule.len() > 32_768 {
return None;
}
let (pricing, secondary): (OfferingPricing, Option<OfferingPricing>) =
serde_json::from_str(schedule).ok()?;
if pricing.provider != self.provider_identity
|| pricing.wire_model_id != self.wire_model
|| pricing.provenance != self.provenance
|| pricing.currency != self.currency
|| pricing.input_per_million != cost.input
|| pricing.output_per_million != cost.output
|| pricing.cache_read_per_million != cost.cache_read
|| pricing.cache_write_per_million != cost.cache_write
|| !codewhale_config::pricing::catalog_cost_is_valid(&pricing.cost_structure())
|| (matches!(
self.provenance,
PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive
) && pricing.fetched_at.or(pricing.effective_at)
!= Some(self.catalog_fetched_at))
|| (self.provenance == PricingProvenance::ProviderLive
&& pricing.endpoint_fingerprint.as_deref()
!= Some(self.endpoint_fingerprint.as_str()))
|| (self.provenance != PricingProvenance::ModelsDevBundled
&& pricing
.expires_at
.is_some_and(|expires| dispatched_at_unix >= expires))
|| secondary.as_ref().is_some_and(|row| {
row.provider != self.provider_identity
|| row.wire_model_id != self.wire_model
|| row.currency != Currency::Cny
|| row.provenance != self.provenance
|| self.provenance != PricingProvenance::ProviderDocs
|| !codewhale_config::pricing::catalog_cost_is_valid(&row.cost_structure())
})
{
return None;
}
return Some(pricing);
}
Some(OfferingPricing {
provider: self.provider_identity.clone(),
wire_model_id: self.wire_model.clone(),
@@ -456,6 +564,8 @@ impl ProviderLivePricingQuote {
provenance: self.provenance.clone(),
effective_at: Some(self.catalog_fetched_at),
endpoint_fingerprint: Some(self.endpoint_fingerprint.clone()),
..Default::default()
})
}
}
@@ -1321,6 +1431,14 @@ fn reviewed_provider_live_scope(
}
}
fn published_pricing_scope(provider: ProviderKind, identity: &str, fingerprint: &str) -> bool {
!matches!(
provider,
ProviderKind::Custom | ProviderKind::OpenaiCodex | ProviderKind::OpencodeGo
) && identity == provider.as_str()
&& fingerprint == base_url_fingerprint(provider.provider().default_base_url())
}
#[must_use]
pub(crate) fn fresh_provider_live_pricing_quote_at(
provider: ProviderKind,
@@ -1361,7 +1479,7 @@ pub(crate) fn fresh_provider_live_pricing_quote_at(
|| pricing.wire_model_id.trim() != wire_model
|| pricing.currency != Currency::Usd
|| pricing.provenance != PricingProvenance::ProviderLive
|| pricing.effective_at != Some(entry.fetched_at)
|| pricing.fetched_at.or(pricing.effective_at) != Some(entry.fetched_at)
|| pricing.endpoint_fingerprint.as_deref() != Some(endpoint_fingerprint)
|| pricing.input_per_million.is_none()
|| pricing.output_per_million.is_none()
@@ -1425,9 +1543,28 @@ pub(crate) fn configured_dispatch_pricing_quote_at(
output_per_million: cost.output,
cache_read_per_million: cost.cache_read,
cache_write_per_million: cost.cache_write,
reasoning_per_million: cost.reasoning,
input_audio_per_million: cost.input_audio,
output_audio_per_million: cost.output_audio,
context_tiers: cost.context_tiers(),
rate_withheld: declared.cost.is_some()
&& !OfferingPricing {
input_per_million: cost.input,
output_per_million: cost.output,
cache_read_per_million: cost.cache_read,
cache_write_per_million: cost.cache_write,
reasoning_per_million: cost.reasoning,
input_audio_per_million: cost.input_audio,
output_audio_per_million: cost.output_audio,
context_tiers: cost.context_tiers(),
..Default::default()
}
.has_any_price(),
provenance: PricingProvenance::UserOverride,
effective_at: None,
endpoint_fingerprint: Some(base_url_fingerprint(base_url)),
..Default::default()
};
// Freeze even an unpriced declaration: missing rates must not fall through
// to a same-named bundled or subsequently refreshed price.
@@ -1451,8 +1588,17 @@ pub(crate) fn declared_or_catalog_quote(
catalog: impl FnOnce() -> Option<ProviderLivePricingQuote>,
) -> Option<ProviderLivePricingQuote> {
match declared {
Some(quote) if quote.carries_rates() => Some(quote),
declared => catalog().or(declared),
Some(quote) if quote.carries_pricing_decision() => Some(quote),
declared => catalog()
.filter(|quote| {
matches!(
quote.provenance,
PricingProvenance::ProviderLive
| PricingProvenance::ModelsDevLive
| PricingProvenance::CloudFacts
)
})
.or(declared),
}
}
@@ -1473,6 +1619,140 @@ pub(crate) fn fresh_dispatch_pricing_quote_at(
) {
return Some(quote);
}
if crate::provider_lake::live_catalog_origin(provider, wire_model)
== Some(crate::provider_lake::LiveSource::ModelsDev)
&& crate::provider_lake::catalog_offering_for_model_identity(
provider,
Some(provider_identity),
wire_model,
)
.is_some_and(|row| {
matches!(
row.source,
codewhale_config::catalog::CatalogSource::Live { .. }
)
})
{
return None;
}
if !published_pricing_scope(provider, provider_identity, &endpoint_fingerprint) {
if let Some(quote) = fresh_cloud_dispatch_pricing_quote_at(
provider,
provider_identity,
wire_model,
base_url,
dispatched_at_unix,
) {
return Some(quote);
}
return None;
}
let at = chrono::DateTime::from_timestamp(i64::try_from(dispatched_at_unix).ok()?, 0)?;
let hand = crate::pricing::provider_owned_hand_quote_rows(provider, wire_model, at);
let needs_schedule = matches!(
provider,
ProviderKind::Deepseek | ProviderKind::DeepseekAnthropic
);
let catalog = crate::provider_lake::catalog_offering_for_route(
provider,
provider_identity,
base_url,
wire_model,
)
.filter(|offering| offering.wire_model_id == wire_model)
.map(|mut offering| {
if crate::provider_lake::live_catalog_origin(provider, wire_model)
== Some(crate::provider_lake::LiveSource::ModelsDev)
&& let codewhale_config::catalog::CatalogSource::Live { fetched_at, .. } =
offering.pricing_source()
{
offering.cost_source = Some(codewhale_config::catalog::CatalogSource::ModelsDevLive {
fetched_at: *fetched_at,
});
}
offering
})
.and_then(|offering| OfferingPricing::from_catalog_offering_at(&offering, dispatched_at_unix))
.filter(|pricing| {
pricing
.live_pricing_defect(
Some(&endpoint_fingerprint),
Some(dispatched_at_unix),
DEFAULT_PROVIDER_CATALOG_TTL_SECS,
)
.is_none()
});
let catalog_is_live = catalog.as_ref().is_some_and(|pricing| {
matches!(
pricing.provenance,
PricingProvenance::ModelsDevLive | PricingProvenance::ProviderLive
)
});
let (pricing, mut secondary) = if needs_schedule && hand.is_some() {
hand?
} else if catalog_is_live {
catalog.map(|pricing| (pricing, None)).or(hand)?
} else if hand.is_some() {
hand?
} else if let Some(quote) = fresh_cloud_dispatch_pricing_quote_at(
provider,
provider_identity,
wire_model,
base_url,
dispatched_at_unix,
) {
return Some(quote);
} else {
catalog.map(|pricing| (pricing, None))?
};
let mut quote = ProviderLivePricingQuote::from_pricing(
provider,
provider_identity,
wire_model,
&endpoint_fingerprint,
pricing.fetched_at.unwrap_or(dispatched_at_unix),
&pricing,
)?;
if let Some(row) = secondary.as_mut() {
row.provider = provider_identity.into();
row.wire_model_id = wire_model.into();
let (primary, _): (OfferingPricing, Option<OfferingPricing>) =
serde_json::from_str(quote.pricing_schedule.as_deref()?).ok()?;
quote.pricing_schedule = Some(serde_json::to_string(&(primary, secondary)).ok()?);
quote.catalog_revision = ProviderLivePricingQuote::revision_for(
quote.provider,
&quote.provider_identity,
&quote.wire_model,
&quote.endpoint_fingerprint,
quote.catalog_fetched_at,
&quote.currency,
&quote.provenance,
&quote.input_per_million,
&quote.output_per_million,
&quote.cache_read_per_million,
&quote.cache_write_per_million,
None,
quote.pricing_schedule.as_deref(),
)?;
}
quote.pricing_for_route(
provider,
provider_identity,
wire_model,
&endpoint_fingerprint,
dispatched_at_unix,
)?;
Some(quote)
}
fn fresh_cloud_dispatch_pricing_quote_at(
provider: ProviderKind,
provider_identity: &str,
wire_model: &str,
base_url: &str,
dispatched_at_unix: u64,
) -> Option<ProviderLivePricingQuote> {
let endpoint_fingerprint = base_url_fingerprint(base_url);
if !cloud_pricing_scope(provider, provider_identity, base_url) {
return None;
}
@@ -1535,6 +1815,7 @@ pub(crate) fn fresh_dispatch_pricing_quote_at(
&quote.cache_read_per_million,
&quote.cache_write_per_million,
quote.cloud_facts.as_ref(),
quote.pricing_schedule.as_deref(),
)?;
quote.pricing_for_route(
provider,
+9 -2
View File
@@ -1321,9 +1321,16 @@ pub(crate) fn configured_catalog_offering_for_route(
base_url: &str,
model: &str,
) -> Option<CatalogOffering> {
let lower = catalog_offering_for_route(provider, identity, base_url, model);
configured_model_for_route(config, provider, identity, base_url, model)
.map(|row| row.to_catalog_offering())
.or_else(|| catalog_offering_for_route(provider, identity, base_url, model))
.map(|row| {
let mut offering = row.to_catalog_offering();
if let Some(lower) = lower.as_ref() {
offering.complete_from(lower, Some(base_url));
}
offering
})
.or(lower)
}
pub(crate) fn configured_catalog_models_for_route(
+55 -15
View File
@@ -283,8 +283,20 @@ impl ReasoningEffort {
other => other,
};
}
if let Some(values) = Self::catalog_effort_values(provider, wire_model) {
return Self::clamp_to_catalog_efforts(normalized, provider, wire_model, &values);
if let Some(offering) = Self::offering_for_reasoning(provider, base_url, wire_model)
&& let Some(values) = Self::catalog_effort_values_for_offering(&offering)
{
if normalized == Self::Off
&& provider != ProviderKind::Custom
&& !codewhale_config::provider_base_url_is_official(provider, base_url)
{
return Self::Off;
}
return Self::clamp_to_catalog_efforts(
normalized,
Self::catalog_default_for_offering(&offering),
&values,
);
}
match normalized {
Self::Low | Self::Medium => Self::High,
@@ -292,8 +304,31 @@ impl ReasoningEffort {
}
}
pub fn catalog_default(provider: ProviderKind, wire_model: &str) -> Option<Self> {
let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
pub(crate) fn offering_for_reasoning(
provider: ProviderKind,
base_url: &str,
wire_model: &str,
) -> Option<codewhale_config::catalog::CatalogOffering> {
crate::provider_lake::catalog_offering_for_route(
provider,
provider.as_str(),
base_url,
wire_model,
)
}
pub fn catalog_default(
provider: ProviderKind,
base_url: &str,
wire_model: &str,
) -> Option<Self> {
let offering = Self::offering_for_reasoning(provider, base_url, wire_model)?;
Self::catalog_default_for_offering(&offering)
}
fn catalog_default_for_offering(
offering: &codewhale_config::catalog::CatalogOffering,
) -> Option<Self> {
offering.reasoning_options.iter().find_map(|option| {
option
.get("type")
@@ -306,8 +341,18 @@ impl ReasoningEffort {
})
}
pub fn catalog_effort_values(provider: ProviderKind, wire_model: &str) -> Option<Vec<Self>> {
let offering = crate::provider_lake::catalog_offering_for_model(provider, wire_model)?;
pub fn catalog_effort_values(
provider: ProviderKind,
base_url: &str,
wire_model: &str,
) -> Option<Vec<Self>> {
let offering = Self::offering_for_reasoning(provider, base_url, wire_model)?;
Self::catalog_effort_values_for_offering(&offering)
}
pub(crate) fn catalog_effort_values_for_offering(
offering: &codewhale_config::catalog::CatalogOffering,
) -> Option<Vec<Self>> {
let mut efforts = Vec::new();
let mut has_toggle = false;
for option in &offering.reasoning_options {
@@ -348,30 +393,25 @@ impl ReasoningEffort {
"xhigh" => Some(Self::XHigh),
"ultra" | "ultracode" => Some(Self::Ultra),
"max" | "maximum" => Some(Self::Max),
"auto" | "automatic" | "adaptive" => Some(Self::Auto),
"auto" | "automatic" | "adaptive" | "default" => Some(Self::Auto),
_ => None,
}
}
fn clamp_to_catalog_efforts(
normalized: Self,
provider: ProviderKind,
wire_model: &str,
values: &[Self],
) -> Self {
fn clamp_to_catalog_efforts(normalized: Self, default: Option<Self>, values: &[Self]) -> Self {
if matches!(normalized, Self::Auto) || values.contains(&normalized) {
return normalized;
}
let aliased = match normalized {
Self::Minimal if values.contains(&Self::Low) => Self::Low,
Self::Max | Self::Ultra if values.contains(&Self::XHigh) => Self::XHigh,
Self::Off => Self::catalog_default(provider, wire_model).unwrap_or(Self::High),
Self::Off => default.unwrap_or(Self::High),
other => other,
};
if values.contains(&aliased) {
aliased
} else {
Self::catalog_default(provider, wire_model).unwrap_or(Self::High)
default.unwrap_or(Self::High)
}
}
+9 -5
View File
@@ -135,14 +135,14 @@ impl RlmUsageAccumulator {
let completed = if let Some(Some(slot)) = state.records.get_mut(reservation.index)
&& !slot.completed
{
super::add_usage_with_prompt_cache(&mut slot.record.usage.usage, usage);
crate::core::turn::add_usage_to(&mut slot.record.usage.usage, usage);
slot.completed = true;
true
} else {
false
};
if completed {
super::add_usage_with_prompt_cache(&mut state.usage, usage);
crate::core::turn::add_usage_to(&mut state.usage, usage);
}
}
@@ -797,7 +797,9 @@ mod tests {
bridge.usage.cancel(missing_reservation, true).await;
let snapshot = bridge.usage_snapshot().await;
assert_eq!(snapshot.usage, reported);
let mut expected_reported = reported.clone();
expected_reported.request_usages = vec![reported.clone()];
assert_eq!(snapshot.usage, expected_reported);
assert_eq!(snapshot.records.len(), 1);
assert_eq!(snapshot.drop_records.len(), 1);
assert_eq!(snapshot.dropped_records, 1);
@@ -882,9 +884,11 @@ mod tests {
}
let snapshot = bridge.usage_snapshot().await;
assert_eq!(snapshot.usage, usage);
let mut expected_usage = usage.clone();
expected_usage.request_usages = vec![usage.clone()];
assert_eq!(snapshot.usage, expected_usage);
assert_eq!(snapshot.records.len(), 1);
assert_eq!(snapshot.records[0].usage.usage, usage);
assert_eq!(snapshot.records[0].usage.usage, expected_usage);
let rejected_fragments: Vec<_> = snapshot
.nested_events
.iter()
+2 -39
View File
@@ -11,6 +11,7 @@
//! or caller. The persistent `rlm` action surface is caller-session scoped;
//! `share_session=true` explicitly refuses. No HTTP sidecar is created.
#[cfg(test)]
use codewhale_models::Usage;
pub mod bridge;
@@ -22,44 +23,6 @@ pub(crate) use bridge::RlmBridge;
pub use prompt::rlm_system_prompt;
pub use turn::{RlmTermination, RlmTurnResult};
fn add_usage_with_prompt_cache(total: &mut Usage, delta: &Usage) {
total.input_tokens = total.input_tokens.saturating_add(delta.input_tokens);
total.output_tokens = total.output_tokens.saturating_add(delta.output_tokens);
total.prompt_cache_hit_tokens =
add_optional_usage(total.prompt_cache_hit_tokens, delta.prompt_cache_hit_tokens);
total.prompt_cache_miss_tokens = add_optional_usage(
total.prompt_cache_miss_tokens,
delta.prompt_cache_miss_tokens,
);
total.prompt_cache_write_tokens = add_optional_usage(
total.prompt_cache_write_tokens,
delta.prompt_cache_write_tokens,
);
total.reasoning_tokens = add_optional_usage(total.reasoning_tokens, delta.reasoning_tokens);
total.reasoning_replay_tokens =
add_optional_usage(total.reasoning_replay_tokens, delta.reasoning_replay_tokens);
if let Some(delta_server) = delta.server_tool_use.as_ref() {
let total_server = total.server_tool_use.get_or_insert_default();
total_server.code_execution_requests = add_optional_usage(
total_server.code_execution_requests,
delta_server.code_execution_requests,
);
total_server.tool_search_requests = add_optional_usage(
total_server.tool_search_requests,
delta_server.tool_search_requests,
);
}
}
fn add_optional_usage(total: Option<u32>, delta: Option<u32>) -> Option<u32> {
match (total, delta) {
(Some(total), Some(delta)) => Some(total.saturating_add(delta)),
(None, Some(delta)) => Some(delta),
(Some(total), None) => Some(total),
(None, None) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -87,7 +50,7 @@ mod tests {
..Usage::default()
};
add_usage_with_prompt_cache(&mut total, &delta);
crate::core::turn::add_usage_to(&mut total, &delta);
assert_eq!(total.input_tokens, 150);
assert_eq!(total.output_tokens, 15);
+10 -3
View File
@@ -467,9 +467,14 @@ mod tests {
let error = result.error.expect("truncation must fail the RLM turn");
assert!(error.contains("incomplete"), "{error}");
assert!(error.contains("max_tokens"), "{error}");
assert_eq!(result.usage, usage, "billed usage must still be charged");
let mut expected_usage = usage.clone();
expected_usage.request_usages = vec![usage.clone()];
assert_eq!(
result.usage, expected_usage,
"billed usage must still be charged"
);
assert_eq!(result.routed_usage.len(), 1);
assert_eq!(result.routed_usage[0].usage.usage, usage);
assert_eq!(result.routed_usage[0].usage.usage, expected_usage);
assert_eq!(result.routed_usage_dropped_records, 0);
assert_eq!(mock.call_count(), 1, "truncation must not retry");
assert!(
@@ -714,7 +719,9 @@ mod tests {
.unwrap()
.contains("wall-clock deadline")
);
assert_eq!(result.usage, text_response(partial).usage);
let mut expected_usage = text_response(partial).usage;
expected_usage.request_usages = vec![text_response(partial).usage];
assert_eq!(result.usage, expected_usage);
assert_eq!(client.0.call_count(), 1);
let snapshot = usage.snapshot().await;
assert_eq!(snapshot.records.len(), 1, "keep completed usage");
+5 -1
View File
@@ -1028,7 +1028,6 @@ mod tests {
] {
for model in [
"deepseek-v4.1-flash-expires-on-0910",
"deepseek-v4.1-flash",
"deepseek-v4-flash-vendor",
] {
assert_eq!(provider_capability(provider, model).max_output, None);
@@ -1043,6 +1042,11 @@ mod tests {
}
}
assert_eq!(
output_ceiling_source(ProviderKind::Deepseek, "deepseek-v4.1-flash"),
OutputCeilingSource::Documented(384_000)
);
// Exact route output facts supply a ceiling only for that request.
let limits = RouteLimits {
context_tokens: Some(128_000),
+53
View File
@@ -321,6 +321,59 @@ pub fn selected_route(path: &Path) -> Result<(String, String, codewhale_config::
Ok((provider, config.default_model(), source))
}
pub fn resolve_model(
path: &Path,
provider_identity: &str,
base_url: &str,
model: Option<&str>,
) -> Result<codewhale_config::route::ReadyRouteCandidate> {
crate::models_dev_live::maybe_load_persisted_cache();
let store = codewhale_config::ConfigStore::load(Some(path.to_path_buf()))?;
let mut config = saved_config(&store)?;
let identity = config
.resolve_provider_selection_identity(provider_identity)
.map_err(anyhow::Error::msg)?;
config.set_provider_base_url_override(&identity, Some(base_url.into()))?;
crate::provider_catalog_live::maybe_load_persisted_cache_for_config(&config);
let route = crate::route_runtime::resolve_runtime_route_for_identity(&config, &identity, model)
.map_err(anyhow::Error::msg);
if !route.as_ref().is_ok_and(|route| {
(route.identity.provider == ProviderKind::OpenaiCodex
&& crate::codex_model_cache::model_roster_for(&route.config)
.metadata_for(route.candidate.wire_model_id().as_str())
.is_some())
|| crate::provider_lake::configured_catalog_offering_for_route(
&route.config,
route.identity.provider,
route.identity.key.as_str(),
&route.candidate.endpoint().base_url,
route.candidate.wire_model_id().as_str(),
)
.is_some()
}) {
if crate::provider_catalog_live::cached_entry_for_route(
identity.provider,
identity.key.as_str(),
base_url,
)?
.is_some_and(|entry| entry.fetched_at > 0)
{
return Err(
codewhale_agent::ModelResolutionError::ModelNotAvailableForProvider {
provider: identity.provider,
requested: model.unwrap_or("auto").to_string(),
}
.into(),
);
}
codewhale_agent::ModelRegistry::default().resolve(
model.filter(|model| *model != "auto"),
Some(identity.provider),
)?;
}
Ok(route?.candidate)
}
/// Save one explicit route preference after atomically adopting legacy choices.
pub fn set(path: &Path, key: &str, value: &str) -> Result<()> {
persistence::mutate_config_document(path, |doc| set_document(path, doc, key, value))
+45 -2
View File
@@ -1727,7 +1727,9 @@ pub(crate) fn open_runtime_threads_for_host(
// Publish the same exact endpoint-scoped catalog as interactive startup
// before the server admits turns. A cached model list alone does not make
// its capabilities available to route resolution.
crate::models_dev_live::maybe_load_persisted_cache();
crate::provider_catalog_live::maybe_load_persisted_cache_for_config(config);
crate::models_dev_live::spawn_background_refresh();
Ok((manager, workshop_activation))
}
@@ -10553,6 +10555,8 @@ struct ProviderModelEntry {
reasoning_effort: codewhale_config::route::CapabilityState,
reasoning_effort_levels: Vec<String>,
reasoning_effort_source: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
facts: Option<Value>,
}
#[derive(Debug, Clone, Serialize)]
@@ -10836,7 +10840,43 @@ fn provider_model_entry_for_api(
reasoning_effort: CapabilityState::Unknown,
reasoning_effort_levels: Vec::new(),
reasoning_effort_source: None,
facts: None,
};
let base = config.base_url_for_route(identity);
let offering = crate::provider_lake::configured_catalog_offering_for_route(
config,
provider,
identity.key.as_str(),
&base,
&entry.id,
);
let resolved =
crate::route_runtime::resolve_runtime_route_for_identity(config, identity, Some(&entry.id))
.ok();
if let Some(offering) = offering.as_ref() {
let pricing = codewhale_config::pricing::OfferingPricing::from_catalog_offering(offering);
entry.facts = Some(serde_json::json!({
"wire_model_id": offering.wire_model_id,
"canonical_model_id": offering.canonical_model,
"name": offering.name,
"family": offering.family,
"status": offering.status,
"advertised_limits": offering.limit,
"effective_limits": resolved.as_ref().map(|route| route.candidate.limits()),
"effective_context": resolved.as_ref().map(|route| route.context_window),
"capabilities": resolved.as_ref().map(|route| route.candidate.capabilities()),
"reasoning": offering.reasoning,
"reasoning_options": offering.reasoning_options,
"modalities": offering.modalities,
"temperature": offering.temperature,
"source": offering.source,
"fact_sources": offering.fact_sources,
"pricing_status": pricing.as_ref().map(|pricing| pricing.status_label_at(codewhale_config::catalog::now_unix())),
"pricing": pricing,
"upstream_last_updated": offering.last_updated,
"account_access": "unverified",
}));
}
// A provider kind and a familiar model name do not establish the
// capabilities of a different endpoint or named compatible route.
if provider == ProviderKind::Custom || config.provider_uses_custom_endpoint(identity) {
@@ -10870,10 +10910,13 @@ fn provider_model_entry_for_api(
if metadata.reasoning == Some(false) {
entry.reasoning_effort = CapabilityState::Unsupported;
}
} else if let Some(efforts) = ReasoningEffort::catalog_effort_values(provider, &entry.id) {
} else if let Some(efforts) = offering
.as_ref()
.and_then(ReasoningEffort::catalog_effort_values_for_offering)
{
entry.reasoning_effort_levels = efforts
.into_iter()
.filter(|effort| *effort != ReasoningEffort::Off)
.filter(|effort| !matches!(effort, ReasoningEffort::Off | ReasoningEffort::Auto))
.map(|effort| effort.as_setting().to_string())
.collect();
entry.reasoning_effort_source = Some("catalog");
+29 -12
View File
@@ -10,9 +10,9 @@ use super::{ApiError, RuntimeApiState, map_thread_err};
/// is the last provider-counted prompt size when one exists.
///
/// All numeric fields are nullable: a route that cannot express a bounded
/// window (unknown model, no catalog or configured limits) reports `null`
/// rather than an invented number, and `live: false` marks responses where
/// the engine could not be loaded and only the static route window resolved.
/// window reports `null`. Static windows retain their resolution source,
/// including a named unverified fallback. `live: false` marks responses where
/// the engine could not be loaded and only the stored route resolved.
#[derive(Debug, Serialize)]
pub(super) struct ThreadContextResponse {
thread_id: String,
@@ -23,6 +23,7 @@ pub(super) struct ThreadContextResponse {
/// route limits); `false` when only the store-recorded route resolved.
live: bool,
window_tokens: Option<u64>,
window_source: Option<&'static str>,
input_tokens: Option<u64>,
billed_input_tokens: Option<u64>,
output_cap_tokens: Option<u64>,
@@ -53,6 +54,7 @@ pub(super) async fn get_thread_context(
model_provider_id: snapshot.model_provider_id,
live: true,
window_tokens: Some(snapshot.window_tokens),
window_source: Some("engine_active_route"),
input_tokens: Some(snapshot.input_tokens),
billed_input_tokens: snapshot.billed_input_tokens,
output_cap_tokens: Some(snapshot.output_cap_tokens),
@@ -66,17 +68,29 @@ pub(super) async fn get_thread_context(
// Engine unavailable or the route cannot bound a window: still answer
// with whatever the thread record and the static route catalog can prove.
let provider = thread
let config = state.config.read().clone();
let route = thread
.model_provider
.as_deref()
.and_then(crate::config::ProviderKind::parse);
let window_tokens = provider.map(|provider| {
u64::from(crate::route_budget::route_context_window_tokens(
provider,
&thread.model,
None,
))
});
.and_then(|provider| {
config
.resolve_persisted_provider_identity(
Some(provider),
thread.model_provider_id.as_deref(),
)
.ok()
})
.and_then(|identity| {
crate::route_runtime::resolve_runtime_route_for_identity(
&config,
&identity,
Some(&thread.model),
)
.ok()
});
let window_tokens = route
.as_ref()
.map(|route| u64::from(route.context_window.tokens));
Ok(Json(ThreadContextResponse {
thread_id,
model: thread.model,
@@ -84,6 +98,9 @@ pub(super) async fn get_thread_context(
model_provider_id: thread.model_provider_id,
live: false,
window_tokens,
window_source: route
.as_ref()
.map(|route| route.context_window.source.label()),
input_tokens: None,
billed_input_tokens: None,
output_cap_tokens: None,
+38 -17
View File
@@ -5563,24 +5563,39 @@ async fn steer_and_interrupt_endpoints_work_on_active_turn() -> Result<()> {
.json()
.await?;
assert_eq!(steer_resp["id"], turn_id);
let deadline = tokio::time::Instant::now() + ci_scaled(Duration::from_secs(10));
while !runtime_threads
.events_since(&thread_id, None)?
.iter()
.any(|ev| ev.event == "turn.steered")
{
if tokio::time::Instant::now() >= deadline {
bail!("timed out waiting for the steer on {turn_id} to settle");
assert!(matches!(steer_resp["steer_count"].as_u64(), Some(0 | 1)));
tokio::time::timeout(ci_scaled(Duration::from_secs(10)), async {
loop {
let events = runtime_threads.events_since(&thread_id, None)?;
let delivered = events.iter().any(|event| {
event.turn_id.as_deref() == Some(turn_id.as_str()) && event.event == "turn.steered"
});
let completed = events.iter().any(|event| {
event.turn_id.as_deref() == Some(turn_id.as_str())
&& event.event == "item.completed"
&& event.payload["item"]["detail"] == "please steer"
});
if delivered && completed {
return Ok::<_, anyhow::Error>(());
}
sleep(Duration::from_millis(25)).await;
}
sleep(Duration::from_millis(25)).await;
}
assert_eq!(
runtime_threads
.test_store()
.load_turn(&turn_id)?
.steer_count,
1
);
})
.await
.context("steer delivery did not settle before interrupt")??;
let detail: serde_json::Value = client
.get(format!("http://{addr}/v1/threads/{thread_id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
let settled_turn = detail["turns"]
.as_array()
.and_then(|turns| turns.iter().find(|turn| turn["id"] == turn_id))
.context("steered turn missing from thread detail")?;
assert_eq!(settled_turn["steer_count"], 1);
let interrupt_resp: serde_json::Value = client
.post(format!(
@@ -12898,6 +12913,7 @@ fn provider_model_catalog_paginates_all_six_hundred_rows_without_truncation() {
reasoning_effort: codewhale_config::route::CapabilityState::Unknown,
reasoning_effort_levels: Vec::new(),
reasoning_effort_source: None,
facts: None,
output_token_limit: codewhale_config::route::CapabilityState::Unknown,
id: format!("openrouter/model-{index:03}"),
image_input: codewhale_config::route::CapabilityState::Unknown,
@@ -12954,6 +12970,7 @@ fn provider_model_catalog_applies_filter_before_cursor_and_rejects_cross_scope_r
reasoning_effort: codewhale_config::route::CapabilityState::Unknown,
reasoning_effort_levels: Vec::new(),
reasoning_effort_source: None,
facts: None,
output_token_limit: codewhale_config::route::CapabilityState::Unknown,
id: id.to_string(),
image_input: codewhale_config::route::CapabilityState::Unknown,
@@ -13012,6 +13029,7 @@ fn provider_model_cursor_rejects_catalog_change_between_pages() {
reasoning_effort: codewhale_config::route::CapabilityState::Unknown,
reasoning_effort_levels: Vec::new(),
reasoning_effort_source: None,
facts: None,
output_token_limit: codewhale_config::route::CapabilityState::Unknown,
id: id.to_string(),
image_input: codewhale_config::route::CapabilityState::Unknown,
@@ -13035,6 +13053,7 @@ fn provider_model_cursor_rejects_catalog_change_between_pages() {
reasoning_effort: codewhale_config::route::CapabilityState::Unknown,
reasoning_effort_levels: Vec::new(),
reasoning_effort_source: None,
facts: None,
output_token_limit: codewhale_config::route::CapabilityState::Unknown,
id: "alpha".to_string(),
image_input: codewhale_config::route::CapabilityState::Unknown,
@@ -13082,6 +13101,7 @@ fn provider_model_cursor_round_trips_multibyte_filter_at_the_allowed_limit() {
reasoning_effort: codewhale_config::route::CapabilityState::Unknown,
reasoning_effort_levels: Vec::new(),
reasoning_effort_source: None,
facts: None,
output_token_limit: codewhale_config::route::CapabilityState::Unknown,
id: format!("{filter}{suffix}"),
image_input: codewhale_config::route::CapabilityState::Unknown,
@@ -19914,6 +19934,7 @@ fn runtime_image_named_catalog_cursor_binds_identity_endpoint_and_catalog() -> R
reasoning_effort: codewhale_config::route::CapabilityState::Unknown,
reasoning_effort_levels: Vec::new(),
reasoning_effort_source: None,
facts: None,
id: id.into(),
output_token_limit: codewhale_config::route::CapabilityState::Unknown,
image_input: codewhale_config::route::CapabilityState::Supported,
+36 -24
View File
@@ -13262,7 +13262,7 @@ async fn user_input_tool_timeout_and_cancel_clear_snapshot_before_turn_end() ->
result: Err(error),
})
.await?;
tokio::time::timeout(Duration::from_secs(2), async {
tokio::time::timeout(TURN_SETTLEMENT_DEADLOCK_TIMEOUT, async {
loop {
let detail = manager.get_thread_detail(&thread.id).await?;
let events = manager.events_since(&thread.id, None)?;
@@ -17543,7 +17543,7 @@ async fn steer_turn_on_active_turn_records_item_and_event() -> Result<()> {
.await?;
let steer_text = "add bullet list".to_string();
manager
let steered_turn = manager
.steer_turn(
&thread.id,
&turn.id,
@@ -17552,36 +17552,48 @@ async fn steer_turn_on_active_turn_records_item_and_event() -> Result<()> {
},
)
.await?;
assert!(matches!(steered_turn.steer_count, 0 | 1));
let observed_steer = steer_seen_rx
.await
.context("driver did not receive steer")?;
assert_eq!(observed_steer, steer_text);
tokio::time::timeout(TURN_SETTLEMENT_DEADLOCK_TIMEOUT, async {
loop {
let events = manager.events_since(&thread.id, None)?;
let delivered = events.iter().any(|event| {
event.turn_id.as_deref() == Some(turn.id.as_str()) && event.event == "turn.steered"
});
let completed = events.iter().any(|event| {
event.turn_id.as_deref() == Some(turn.id.as_str())
&& event.event == "item.completed"
&& event.payload["item"]["detail"] == steer_text
});
if delivered && completed {
return Ok::<_, anyhow::Error>(());
}
sleep(Duration::from_millis(25)).await;
}
})
.await
.context("steer delivery did not settle")??;
assert_eq!(manager.store.load_turn(&turn.id)?.steer_count, 1);
let final_turn = wait_for_terminal_turn(&manager, &turn.id).await?;
assert_eq!(final_turn.status, RuntimeTurnStatus::Completed);
assert_eq!(final_turn.steer_count, 1);
let deadline = Instant::now() + TURN_SETTLEMENT_DEADLOCK_TIMEOUT;
loop {
let events = manager.events_since(&thread.id, None)?;
let steered = events.iter().any(|ev| ev.event == "turn.steered");
let completed = events.iter().any(|ev| {
ev.event == "item.completed"
&& ev
.payload
.get("item")
.and_then(|item| item.get("detail"))
.and_then(Value::as_str)
== Some("add bullet list")
});
if steered && completed {
break;
}
if Instant::now() >= deadline {
bail!("steer receipts were not persisted: steered={steered}, completed={completed}");
}
sleep(Duration::from_millis(20)).await;
}
assert_eq!(manager.store.load_turn(&turn.id)?.steer_count, 1);
let events = manager.events_since(&thread.id, None)?;
assert!(events.iter().any(|ev| ev.event == "turn.steered"));
assert!(events.iter().any(|ev| {
ev.event == "item.completed"
&& ev
.payload
.get("item")
.and_then(|item| item.get("detail"))
.and_then(Value::as_str)
== Some("add bullet list")
}));
Ok(())
}
+4 -1
View File
@@ -1324,8 +1324,11 @@ mod tests {
let card = Scorecard::from_turns(&turns);
// 1M total input crosses the 272K context tier, so every input class
// (miss, cache read, cache write) bills at the tier input rate of
// $60/M on this documented no-cache-discount route: 1M * 60 = $60.
assert!(!card.per_turn[0].cost_unpriced);
assert!((card.per_turn[0].cost_usd - 30.0).abs() < f64::EPSILON);
assert!((card.per_turn[0].cost_usd - 60.0).abs() < f64::EPSILON);
assert!(card.per_turn[1].cost_unpriced);
assert!(!card.metrics.cost_complete);
}
+10 -3
View File
@@ -3426,14 +3426,21 @@ impl SessionManager {
let mut file = fs::File::open(&path)?;
if file.metadata()?.len() > limit as u64 {
let before = file.metadata()?.len();
let compacted = self.compact_oversized_session(id).is_ok();
self.compact_oversized_session(id).map_err(|error| {
io::Error::new(
error.kind(),
format!(
"session exceeds its full-history transport limit and compaction failed: {error}; source retained"
),
)
})?;
file = fs::File::open(&path)?;
let after = file.metadata()?.len();
if !compacted || after > limit as u64 {
if after > limit as u64 {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"session is {:.1} MiB and the full-history transport limit is {:.1} MiB; archiving its off-branch journal entries left it at {:.1} MiB, so its active conversation alone is over the limit; source retained",
"session is {:.1} MiB and the full-history transport limit is {:.1} MiB; off-branch compaction left it at {:.1} MiB; source retained",
before as f64 / 1_048_576.0,
limit as f64 / 1_048_576.0,
after as f64 / 1_048_576.0,
+13 -36
View File
@@ -1633,12 +1633,6 @@ fn review_usage_metadata(
metadata
}
fn add_optional_usage(total: &mut Option<u32>, next: Option<u32>) {
if let Some(next) = next {
*total = Some(total.unwrap_or(0).saturating_add(next));
}
}
/// Describe a failed review request with its whole error chain.
///
/// The client wraps the retry loop's `LlmError` in one outer context (the
@@ -1657,36 +1651,7 @@ pub(crate) fn request_failure_message(
}
pub(crate) fn add_review_usage(total: &mut Usage, next: &Usage) {
total.input_tokens = total.input_tokens.saturating_add(next.input_tokens);
total.output_tokens = total.output_tokens.saturating_add(next.output_tokens);
add_optional_usage(
&mut total.prompt_cache_hit_tokens,
next.prompt_cache_hit_tokens,
);
add_optional_usage(
&mut total.prompt_cache_miss_tokens,
next.prompt_cache_miss_tokens,
);
add_optional_usage(
&mut total.prompt_cache_write_tokens,
next.prompt_cache_write_tokens,
);
add_optional_usage(&mut total.reasoning_tokens, next.reasoning_tokens);
add_optional_usage(
&mut total.reasoning_replay_tokens,
next.reasoning_replay_tokens,
);
if let Some(next_tools) = &next.server_tool_use {
let tools = total.server_tool_use.get_or_insert_with(Default::default);
add_optional_usage(
&mut tools.code_execution_requests,
next_tools.code_execution_requests,
);
add_optional_usage(
&mut tools.tool_search_requests,
next_tools.tool_search_requests,
);
}
crate::core::turn::add_usage_to(total, next);
}
enum ReviewSource {
@@ -2681,6 +2646,8 @@ mod tests {
output_tokens: 3,
prompt_cache_hit_tokens: Some(2),
reasoning_tokens: Some(4),
input_audio_tokens: Some(3),
output_audio_tokens: Some(1),
..Default::default()
};
first.server_tool_use = Some(codewhale_models::ServerToolUsage {
@@ -2692,6 +2659,8 @@ mod tests {
output_tokens: 5,
prompt_cache_hit_tokens: Some(7),
reasoning_tokens: Some(6),
input_audio_tokens: Some(4),
output_audio_tokens: Some(1),
server_tool_use: Some(codewhale_models::ServerToolUsage {
code_execution_requests: Some(2),
tool_search_requests: Some(3),
@@ -2704,6 +2673,9 @@ mod tests {
assert_eq!(total.output_tokens, 8);
assert_eq!(total.prompt_cache_hit_tokens, Some(9));
assert_eq!(total.reasoning_tokens, Some(10));
assert_eq!(total.input_audio_tokens, Some(7));
assert_eq!(total.output_audio_tokens, Some(2));
assert_eq!(total.request_usages, vec![first, second]);
assert_eq!(
total.server_tool_use.unwrap().code_execution_requests,
Some(3)
@@ -2771,6 +2743,11 @@ mod tests {
let metadata = result.metadata.unwrap();
assert_eq!(metadata["input_tokens"], 24);
assert_eq!(metadata["output_tokens"], 4);
assert_eq!(
crate::cost_status::child_usage_from_metadata(&metadata),
Some(usage.clone())
);
assert_eq!(usage.request_usages.len(), 2);
assert!(metadata.get("review_coverage").is_none());
let mut drift = PrReviewAccumulator::new(&plan);
+4 -1
View File
@@ -1931,6 +1931,7 @@ pub struct App {
pub launch: LaunchState,
/// Mouse-selected launch action, consumed by the async UI loop.
pub pending_launch_action: Option<crate::tui::underwater::LaunchAction>,
pub(crate) pending_claude_login: Option<bool>,
/// Mouse click on the live composer's `[↵]` send target. The async UI loop
/// consumes it through the same submit dispatcher as Enter.
pub pending_composer_submit: Option<ComposerSubmitChord>,
@@ -7196,7 +7197,9 @@ impl App {
} else if let Some(effort) = inferred {
ReasoningEffort::from_setting(effort)
.normalize_for_route(provider, base_url, wire_model)
} else if let Some(default) = ReasoningEffort::catalog_default(provider, wire_model) {
} else if let Some(default) =
ReasoningEffort::catalog_default(provider, base_url, wire_model)
{
default
} else {
self.reasoning_effort
+7 -2
View File
@@ -500,10 +500,14 @@ impl App {
reasoning_effort =
reasoning_effort.normalize_for_route(provider, &active_route_base_url, &model);
} else if !auto_model && !reasoning_effort_explicit {
if let Some(default) = ReasoningEffort::catalog_default(provider, &model) {
if let Some(default) =
ReasoningEffort::catalog_default(provider, &active_route_base_url, &model)
{
reasoning_effort = default;
}
} else if !auto_model && ReasoningEffort::catalog_effort_values(provider, &model).is_some()
} else if !auto_model
&& ReasoningEffort::catalog_effort_values(provider, &active_route_base_url, &model)
.is_some()
{
reasoning_effort =
reasoning_effort.normalize_for_route(provider, &active_route_base_url, &model);
@@ -921,6 +925,7 @@ impl App {
focus_texture,
launch,
pending_launch_action: None,
pending_claude_login: None,
pending_composer_submit: None,
pending_hotbar_slot: None,
synchronized_output_enabled,
+15 -6
View File
@@ -1504,8 +1504,20 @@ fn reasoning_effort_preserves_grok_46_ladder_only_on_exact_xai_route() {
"https://gateway.example/v1",
model,
),
ReasoningEffort::Medium,
"catalog effort lists are model metadata; the Chat wire still omits them on a custom endpoint"
ReasoningEffort::High,
"a custom endpoint must not inherit the official catalog effort ladder"
);
let custom = "https://gateway.example/v1";
assert!(ReasoningEffort::offering_for_reasoning(ProviderKind::Xai, custom, model).is_none());
assert!(ReasoningEffort::catalog_effort_values(ProviderKind::Xai, custom, model).is_none());
assert_eq!(
crate::tui::model_picker::picker_efforts_for_route(ProviderKind::Xai, custom, model, false),
vec![
ReasoningEffort::Auto,
ReasoningEffort::Off,
ReasoningEffort::High,
ReasoningEffort::Max,
]
);
}
@@ -7881,10 +7893,7 @@ fn ctrl_t_skips_catalog_rungs_that_resolve_to_an_offered_tier() {
wire_model_id: model.to_string(),
endpoint_key: "chat".to_string(),
reasoning_options: vec![serde_json::json!({ "type": "effort", "values": values })],
source: codewhale_config::catalog::CatalogSource::Live {
base_url_fingerprint: "models-dev-capabilities".to_string(),
fetched_at,
},
source: codewhale_config::catalog::CatalogSource::ModelsDevLive { fetched_at },
..Default::default()
}
};
+5 -3
View File
@@ -326,9 +326,11 @@ fn suspend_tui_child_modes<W: Write>(
fn disable_mouse_capture_for_child<W: Write>(writer: &mut W) {
// Crossterm's mouse-capture command takes a WinAPI path on Windows and
// does not emit bytes into PTY-style terminals such as mintty. External
// editors inherit the PTY state, so send the xterm reset sequences
// directly here.
// does not emit bytes into PTY-style terminals such as mintty. Release
// the WinAPI state before resetting the inherited PTY state so resume
// can re-enable capture through a real console-mode transition.
#[cfg(windows)]
let _ = execute!(writer, crossterm::event::DisableMouseCapture);
const DISABLE_MOUSE_CAPTURE: &[u8] = b"\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l";
if let Err(err) = writer.write_all(DISABLE_MOUSE_CAPTURE) {
tracing::debug!(?err, "DisableMouseCapture direct reset ignored");
+24 -19
View File
@@ -33,9 +33,7 @@ use crate::model_profile::{
resolved_capability_profile_with_overrides,
};
use crate::models_dev_live::{self, ModelsDevFreshness};
use crate::provider_lake::{
catalog_offering_for_model, catalog_offering_for_model_identity, configured_providers,
};
use crate::provider_lake::{catalog_offering_for_model_identity, configured_providers};
use crate::reasoning_preference::ReasoningEffort;
use crate::settings::PinnedModel;
use crate::tui::app::{App, StatusToastLevel};
@@ -3548,7 +3546,10 @@ fn effective_picker_metadata_with_codex(
.as_ref()
.expect("provider metadata admission checked");
let identity = admitted.key.as_str();
let context_override = config.context_window_for_provider_config(admitted);
let context_override = config
.model_context_windows_for(admitted)
.and_then(|windows| windows.get(id).copied())
.or_else(|| config.context_window_for_provider_config(admitted));
let base_url = config.base_url_for_route(admitted);
if let Some(declared) =
crate::provider_lake::configured_model_for_route(config, provider, identity, &base_url, id)
@@ -3715,7 +3716,15 @@ fn effective_picker_metadata_with_codex(
vision,
pricing,
display_name: codex_metadata.and_then(|model| model.display_name.clone()),
reasoning_unknown: false,
reasoning_unknown: if provider == ProviderKind::OpenaiCodex {
codex_metadata.is_none_or(|metadata| metadata.reasoning.is_none())
} else {
offering
.as_ref()
.map_or(profile.reasoning == SupportState::Unknown, |offering| {
offering.reasoning.is_none()
})
},
declared_input_price: None,
source: card.map(|card| card.source),
}
@@ -4590,7 +4599,9 @@ fn route_picker_efforts(
// effort tiers. Keep the existing wire-compatible route ladder.
return CODEX_PICKER_EFFORTS.to_vec();
}
if let Some(catalog_efforts) = catalog_picker_efforts(provider, wire_model) {
if let Some(offering) = ReasoningEffort::offering_for_reasoning(provider, base_url, wire_model)
&& let Some(catalog_efforts) = catalog_picker_efforts(&offering)
{
return catalog_efforts;
}
if matches!(provider, crate::config::ProviderKind::Deepseek) {
@@ -4608,10 +4619,8 @@ fn route_picker_efforts(
/// skipped. Returns `None` when the catalog has no usable effort list so the
/// caller can keep the provider default rather than inventing tiers.
fn catalog_picker_efforts(
provider: ProviderKind,
wire_model: &str,
offering: &codewhale_config::catalog::CatalogOffering,
) -> Option<Vec<ReasoningEffort>> {
let offering = catalog_offering_for_model(provider, wire_model)?;
let mut efforts = Vec::new();
let mut saw_effort_list = false;
let mut has_toggle = false;
@@ -4625,8 +4634,6 @@ fn catalog_picker_efforts(
has_toggle = true;
continue;
}
// Prefer explicit effort lists; also accept thinking-mode lists whose
// values map onto our tiers (adaptive→auto, disabled→off, always_on→max).
if option_type != "effort" && option_type != "thinking" {
continue;
}
@@ -4648,14 +4655,9 @@ fn catalog_picker_efforts(
if !saw_effort_list || efforts.is_empty() {
return None;
}
// A Models.dev `toggle` beside the ladder means reasoning can also be
// switched off (#6396).
if has_toggle && !efforts.contains(&ReasoningEffort::Off) {
efforts.insert(0, ReasoningEffort::Off);
}
// Always offer Auto when the catalog published discrete tiers so the
// operator can still leave the choice to the route default. Do not invent
// Off unless the catalog said so — some models are always-on.
if !efforts.contains(&ReasoningEffort::Auto) {
efforts.insert(0, ReasoningEffort::Auto);
}
@@ -4665,7 +4667,7 @@ fn catalog_picker_efforts(
fn catalog_effort_value(raw: &str) -> Option<ReasoningEffort> {
match raw.trim().to_ascii_lowercase().as_str() {
"off" | "disabled" | "false" => Some(ReasoningEffort::Off),
"none" => Some(ReasoningEffort::Off), // Muse "none" maps to Off in our enum but display as "none"
"none" => Some(ReasoningEffort::Off),
"minimal" | "minimum" => Some(ReasoningEffort::Minimal),
"low" | "light" => Some(ReasoningEffort::Low),
"medium" | "mid" => Some(ReasoningEffort::Medium),
@@ -5754,8 +5756,11 @@ mod tests {
);
// A real catalog/readiness refresh replaces cached presentation facts.
let mut offering =
catalog_offering_for_model(ProviderKind::Deepseek, "deepseek-v4-pro").unwrap();
let mut offering = crate::provider_lake::catalog_offering_for_model(
ProviderKind::Deepseek,
"deepseek-v4-pro",
)
.unwrap();
offering.limit.as_mut().unwrap().context = Some(777_000);
crate::provider_lake::set_live_snapshot(
codewhale_config::catalog::CatalogSnapshot {
+1 -1
View File
@@ -2476,7 +2476,7 @@ async fn apply_command_result_inner(
run_xai_device_login_from_tui(terminal, app, engine_handle, config).await?;
}
AppAction::StartClaudeLogin => {
let _ = run_claude_login_from_tui(terminal, app, engine_handle, config).await?;
app.pending_claude_login = Some(false);
}
AppAction::StartClaudeRevoke => {
let path = app.config_path.clone();
+39 -5
View File
@@ -2319,6 +2319,27 @@ pub(crate) async fn run_event_loop(
// #1830/#2317: service any already-arrived terminal keys before a
// potentially long engine batch so composer/modal input stays live.
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
if !event_broker.is_paused()
&& let Some(from_picker) = app.pending_claude_login.take()
{
let switched = run_claude_login_from_tui(
terminal,
app,
&mut engine_handle,
config,
&terminal_input,
&mut pending_terminal_events,
)
.await?;
if from_picker {
complete_provider_picker_onboarding_if_switched(
app,
ProviderKind::Anthropic,
switched,
);
}
force_terminal_repaint = true;
}
app.maybe_poll_plugin_catalog_idle();
if drain_remote_control_events(app, config, &engine_handle).await? {
@@ -7508,21 +7529,34 @@ pub(crate) async fn run_claude_login_from_tui(
app: &mut App,
engine_handle: &mut EngineHandle,
config: &mut Config,
terminal_input: &TerminalInputPump,
pending_terminal_events: &mut VecDeque<ObservedTerminalEvent>,
) -> Result<bool> {
pause_terminal(
terminal_input.pause_for_child_terminal().await?;
let handoff = prepare_terminal_input_handoff(terminal_input, pending_terminal_events);
if !matches!(handoff, Ok(true)) {
terminal_input.resume_after_child_terminal();
return handoff.map(|_| false).map_err(Into::into);
}
let result = match pause_terminal(
terminal,
app.use_alt_screen(),
app.use_mouse_capture,
app.use_bracketed_paste,
)?;
let result = crate::oauth::login(crate::oauth::OAuthProvider::Claude).await;
resume_terminal(
) {
Ok(()) => crate::oauth::login(crate::oauth::OAuthProvider::Claude).await,
Err(error) => Err(error),
};
let restored = resume_terminal(
terminal,
app.use_alt_screen(),
app.use_mouse_capture,
app.use_bracketed_paste,
app.synchronized_output_enabled,
)?;
);
terminal_input.resume_after_child_terminal();
app.needs_redraw = true;
restored?;
let switched = match result {
Ok(pending) => {
apply_codewhale_owned_login(
+2 -8
View File
@@ -227,7 +227,7 @@ pub(crate) fn handle_bracketed_paste(app: &mut App, text: &str) {
// so fast typing / IME commits / autocomplete bursts don't get
// mis-classified as a paste.
app.bracketed_paste_seen = true;
if app.is_history_search_active() {
if app.is_history_search_active() && app.view_stack.is_empty() {
app.history_search_insert_str(text);
} else if paste_text_into_provider_picker(app, text) || app.view_stack.handle_paste(text) {
// Modal consumed the paste (e.g. provider picker key entry).
@@ -3094,13 +3094,7 @@ pub(crate) async fn handle_view_events(
refresh_config_view_if_open(app, "provider");
}
ViewEvent::ProviderPickerClaudeOAuthRequested => {
let switched =
run_claude_login_from_tui(terminal, app, engine_handle, config).await?;
complete_provider_picker_onboarding_if_switched(
app,
ProviderKind::Anthropic,
switched,
);
app.pending_claude_login = Some(true);
}
ViewEvent::ProviderPickerXaiOAuthRequested => {
let switched =
+16
View File
@@ -7339,6 +7339,17 @@ fn paste_burst_does_not_leak_into_composer_while_a_modal_owns_keys() {
"composer must not gain leaked burst text under a modal: {:?}",
app.input
);
app.start_history_search();
let query = app.history_search_query().unwrap().to_owned();
handle_bracketed_paste(&mut app, "中文输入");
assert_eq!(app.history_search_query(), Some(query.as_str()));
let before = app.input.clone();
route_key_to_view_stack(
&mut app,
KeyEvent::new(KeyCode::Char('中'), KeyModifiers::NONE),
Instant::now(),
);
assert_eq!(app.input, before);
}
#[test]
@@ -22856,6 +22867,11 @@ fn child_usage_metadata_carries_cache_write_and_reasoning_end_to_end() {
code_execution_requests: Some(2),
tool_search_requests: Some(1),
}),
input_audio_tokens: None,
output_audio_tokens: None,
request_usages: Vec::new(),
};
// The shared producer emits every class.
+22 -4
View File
@@ -16,7 +16,7 @@ Related docs: [`PROVIDERS.md`](./PROVIDERS.md), RFC
|---|---|
| Do users need a special model just to refresh models? | **No.** |
| Does Codewhale auto-update the public model catalog? | **Yes, at runtime**, from [Models.dev](https://models.dev/catalog.json), ~24 h TTL. |
| Is the offline bundled seed auto-committed in CI? | **No, but it is generated.** A maintainer runs `seed lock` and `seed render` and opens a PR; CI fails a hand edit (`seed render --check`). |
| Is the offline bundled seed auto-committed in CI? | **No, but it is generated.** A maintainer runs `seed lock` and `seed render` and lands the verified generated slice on main; CI fails a hand edit (`seed render --check`). |
| Should an LLM rewrite catalog JSON? | **No.** Ingest is deterministic public JSON. An LLM can *review* a PR, not own the source of truth. |
---
@@ -141,10 +141,10 @@ These stay hand-maintained or release-lane work until a scheduled PR lands:
| Surface | Why it drifts |
|---|---|
| `models_dev.bundled.json` | Offline seed, generated from a reviewed spec and a pinned lock (see below); refreshed by PR, not at runtime |
| `models_dev.bundled.json` | Offline seed, generated from a reviewed spec and a pinned lock (see below); refreshed by a verified maintainer slice, not at runtime |
| `provider_descriptors.json` default model IDs | Product choice, not pure catalog dump; constant projections are generated |
| `catalog_corrections.json` `reviewed` | Intrinsic/selector/transport compatibility facts with exact source receipts; generated into the same seed |
| Rust pricing policy | Vendor billing windows, CNY conversion, withheld/tiered behavior; pure reference observations live in the reviewed supplement |
| Rust pricing policy | Vendor billing windows, independent USD/CNY rates, withheld/tiered behavior; pure reference observations live in the reviewed supplement |
| New `ProviderKind` / wire dialect | Needs code, not only JSON |
Runtime live refresh **does not** rewrite those files. Users on a recent
@@ -153,6 +153,24 @@ hermetic runs, and first-boot without cache still depend on the seed.
---
The current quote is frozen at dispatch with its exact provider identity, wire
ID, endpoint fingerprint, source and rates. Refreshing the catalog does not
reprice old turns. Live rows carry fetch and expiry clocks separately from an
upstream update or a declared effective date. After 24 hours a live price is
stale; a bundled row is a dated estimate, never proof of account access.
The shared decimal calculator selects each request's context tier from its
entire input, including cache reads and writes. It prices seven published
classes and subtracts separately charged reasoning/audio subsets from the
ordinary classes. Missing rates or a required usage breakdown stay unpriced.
The result is an exact decimal string; display rounding does not change it.
CNY prices remain separate from USD. These are estimates, not reconciled bills.
Two default wire IDs missing from the 2026-10-12 upstream snapshot remain as
explicitly dated curated compatibility rows in the seed spec, with their prior
snapshot receipts. They do not establish present account availability or a
renamed wire ID. Other removed non-default rows are no longer seeded.
## Maintainer tooling (no LLM)
### Validate / dry-run fetch
@@ -206,7 +224,7 @@ first live refresh.
3. `python3 scripts/catalog_models_dev.py seed lock` writes the lock.
4. `python3 scripts/catalog_models_dev.py seed render` writes the seed.
5. Check that default wire IDs still match `DEFAULT_*_MODEL`, run the
catalog tests, and open a PR with the report in its body.
catalog checks, and land the verified slice on main with its report in the commit.
Optional: use a cheap model to summarize “new / removed / default-risk” in
the PR body — never as the author of the JSON.
+7 -2
View File
@@ -4,7 +4,7 @@ A step-by-step checklist for IT admins deploying Codewhale on lab or classroom
machines running Windows.
> **Audience**: IT staff, teaching assistants, lab managers.
> **Prereq**: Each target machine runs Windows 10 (1809+) or Windows 11.
> **Prereq**: For current Windows Terminal, use Windows 10 2004 (build 19041+) or Windows 11. Older x64 deployments follow the [Windows compatibility target and qualification record](ENVIRONMENTS.md#windows-support-and-release-qualification); the CLI target is 1607, with actual final-release startup/install acceptance still required.
---
@@ -12,7 +12,7 @@ machines running Windows.
| # | Task | Done? |
|---|------|-------|
| 1 | Confirm Windows version: `winver` → 10 build 17763+ or 11 | ☐ |
| 1 | Confirm OS, architecture and terminal against the [Windows support table](ENVIRONMENTS.md#windows-support-and-release-qualification). Current Windows Terminal requires build 19041+ or Windows 11. | ☐ |
| 2 | Ensure the user account is a **standard user** (not a local admin). The installer does not require elevation. | ☐ |
| 3 | Verify outbound HTTPS (port 443) is open to `api.openai.com` (or whichever LLM provider the course uses). | ☐ |
| 4 | Obtain the installer: download `CodeWhaleSetup.exe` from a v0.8.50+ [release](https://github.com/codewhale-hq/CodeWhale/releases/latest) or from your department mirror. | ☐ |
@@ -23,6 +23,11 @@ machines running Windows.
## Installation
The NSIS installer contains x64 runtime binaries. For native ARM64 machines, use
the matching archive/npm route in [INSTALL.md](INSTALL.md#windows-nsis-installer).
Import qualification and ARM64 candidate checks do not replace a clean lab
installation or the final 1607 x64 release receipt.
### Option A — Silent install (recommended for imaging / SCCM / Intune)
```powershell
+3 -10
View File
@@ -27,22 +27,15 @@ changelog or release notes, and this contributor record.
## Contributors by time
<details open>
<summary><strong>Unreleased</strong></summary>
**Merged or adapted contributions**
- **[printf033](https://github.com/printf033)** — confined-file I/O on Android/Termux: SELinux-denied `open("/", O_RDONLY|O_DIRECTORY)` anchor replaced with an `O_PATH` walk (Linux-cfg-gated) plus a re-materialized final parent, restoring journal, compaction and artifact writes in search-only SELinux domains ([#6983](https://github.com/codewhale-hq/Codewhale/pull/6983)), with the diagnosis and on-device reproducer in [#6980](https://github.com/codewhale-hq/Codewhale/issues/6980).
</details>
<details open>
<summary><strong>v0.10.2 — terminal, recovery and reliability</strong></summary>
**Contributions**
- **[Henrik Bakke Dukefoss / henrikdukefoss](https://github.com/henrikdukefoss)** — added the Standard Compute compatible-host descriptor, with live model discovery and its billing basis kept Unknown ([#6985](https://github.com/codewhale-hq/Codewhale/pull/6985)).
- **[shenjackyuanjie](https://github.com/shenjackyuanjie)** — diagnosed and fixed the Windows OpenHarmony CMake generator and CLI hashing-module build failures ([#6988](https://github.com/codewhale-hq/Codewhale/issues/6988), [#6989](https://github.com/codewhale-hq/Codewhale/pull/6989)).
- **[printf033](https://github.com/printf033)** — confined-file I/O on Android/Termux: SELinux-denied `open("/", O_RDONLY|O_DIRECTORY)` anchor replaced with an `O_PATH` walk (Linux-cfg-gated) plus a re-materialized final parent, restoring journal, compaction and artifact writes in search-only SELinux domains ([#6983](https://github.com/codewhale-hq/Codewhale/pull/6983)), with the diagnosis and on-device reproducer in [#6980](https://github.com/codewhale-hq/Codewhale/issues/6980). Source change; compiled Android and device acceptance remain unqualified.
- **[Paulo Aboim Pinto / aboimpinto](https://github.com/aboimpinto)** — moved config-command parsing and replies onto portable contracts while the host retains effects ([#6973](https://github.com/codewhale-hq/Codewhale/pull/6973)).
- **[Henrik Bakke Dukefoss / henrikdukefoss](https://github.com/henrikdukefoss)** — added the Standard Compute compatible-host descriptor, with live model discovery and its billing basis kept Unknown ([#6985](https://github.com/codewhale-hq/Codewhale/pull/6985)).
- **[Sh1Zuku / SparkofSpike](https://github.com/SparkofSpike)** — live network-policy updates and goal milestone hand-back ([#6928](https://github.com/codewhale-hq/Codewhale/pull/6928), [#6930](https://github.com/codewhale-hq/Codewhale/pull/6930)), and a Codewhale home reached through a symlink or junction ([#6947](https://github.com/codewhale-hq/Codewhale/issues/6947)).
- **[dajiaohuang](https://github.com/dajiaohuang)** — cache-write token accounting, with missing and explicit-zero usage preserved ([#6913](https://github.com/codewhale-hq/Codewhale/pull/6913)).
- **[gaord](https://github.com/gaord)** — embedder-declared telemetry surface support without changing collection policy ([#6916](https://github.com/codewhale-hq/Codewhale/pull/6916)).
+60
View File
@@ -62,6 +62,66 @@ diverging from monotonic elapsed by more than the suspend threshold — reports
`System sleep detected; connection lost — retrying request`, and re-issues the
request instead of failing the turn (#2990).
## Windows support and release qualification
Windows compatibility depends on the runtime architecture and terminal. The
native x64 CLI/TUI compatibility target is Windows 10 1607 (build 14393) or
later in the legacy Windows console. A release's actual executable and installer
must still pass startup and installation on that target; an import-table pass
alone does not prove it. Required Visual C++ runtime DLLs must also be present.
| Surface | Compatibility target or platform requirement | Qualification boundary |
| --- | --- | --- |
| Native x64 CLI/TUI | Windows 10 x64 1607, build 14393 | Final release bytes need an actual oldest-OS startup receipt |
| ConPTY API | Windows 10 1809, build 17763; Windows Server 2019 | This is Microsoft's API minimum; Codewhale's terminal-session routes remain Unix-only and return 501 on Windows |
| Current Windows Terminal | Windows 10 2004, build 19041, or later | This terminal has a newer prerequisite than the ConPTY API; use the legacy console for the x64 compatibility target |
| Native ARM64 CLI/TUI | Windows 11 ARM64 is the target | Candidate evidence exists on build 26300; no minimum Windows 11 build or Windows 10 ARM64 support is inferred from that run |
| NSIS installer | x86 setup/plugins under WOW64, installing x64 runtime bytes | Native ARM64 installation uses its archive/npm assets; generated Uninstall.exe and actual install behavior need separate receipts |
[Microsoft's ConPTY requirements](https://learn.microsoft.com/en-us/windows/console/createpseudoconsole)
and [current Terminal requirements](https://github.com/microsoft/terminal/blob/main/README.md)
are separate. Windows 11 supports x64 emulation on ARM64; an emulated x64 run
does not qualify native x64 execution or Windows 10 1607.
The release import guard uses separate per-machine profiles in
[`windows-import-floor.json`](../scripts/release/windows-import-floor.json).
Unknown import pairs, unlisted ordinals and delay-loaded dependencies stop the
gate. Its PASS covers the reviewed static import contract; it does not certify
all runtime features, the installed dependency closure or a minimum OS build.
The Native extension host still requires its runtime capability APIs and
isolation probes; where DeriveCapabilitySidsFromName is absent it reports
Unsupported rather than weakening its sandbox. This feature limit is distinct
from CLI startup compatibility.
### Recorded 0.10.2 candidate evidence
The 2026-10-13 qualification snapshot is source
[`cf7199b52e`](https://github.com/codewhale-hq/Codewhale/commit/cf7199b52ecb9147e4670ae7874924ffc30a6a67).
A default-feature debug ARM64 build on Windows 11 build 26300 produced executable
SHA256 `ca51f91562a0569ee43855715856e1cfee74e8a834bd6206183a3175373fdb2d`.
All 453 imported symbols resolved in a native ARM64 process using system-only
DLL lookup. The ordinary-user candidate checks passed 89 classifier tests and
one owner-crash recovery test, plus scoped editor wheel, Pinyin and undo checks.
This is neither a dist-profile release artifact nor installer acceptance, and
it establishes no oldest supported Windows 11 build.
The x64 profile comes from @LmeSzinc's
[#6953 export investigation](https://github.com/codewhale-hq/Codewhale/issues/6953)
on build 14393.447: all 411 v0.10.0 imports and 453 of 454 v0.10.1 imports resolved.
The missing DeriveCapabilitySidsFromName pair is excluded; the conservative
allowed union has 456 pairs. The x86 NSIS profile has 189 named Microsoft API
contracts predating 1607 and one explicitly identified ordinal: COMCTL32 #17 is
InitCommonControls in the x86 Windows SDK 10.0.26100.0 import library and stock
SysWOW64 DLL. Its [documented minimum is Vista](https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-initcommoncontrols).
The manifest retains official source URLs, image hashes, counts and the ordinal
identity; these qualify static contracts without claiming a 1607 installer run.
At this snapshot, startup/installation of the final release bytes on an actual
1607 machine and the candidate's generated uninstaller were still unqualified.
The final frozen source, release import dumps, archive/native/npm installation,
packaged/native-app owner recovery and oldest-OS receipts belong in the release
acceptance packet. See [the release checklist](RELEASE_CHECKLIST.md).
## Windows PowerShell execution policy
The shell tool runs PowerShell with `-ExecutionPolicy Bypass`. That sets only
+3
View File
@@ -1301,6 +1301,9 @@ Known limits of the winget route:
#### Windows NSIS Installer
For the OS/terminal compatibility targets and current evidence, see
[Windows support and release qualification](ENVIRONMENTS.md#windows-support-and-release-qualification).
A standalone NSIS-based installer is available starting with v0.8.50 for
Windows users who prefer a traditional double-click setup (no npm, no Scoop, no
Cargo required).
+1 -1
View File
@@ -35,7 +35,7 @@ Run `/mode` to open the mode picker, or switch directly with `/mode work`,
`/mode plan`, or `/mode operate`.
- **Plan**: design-first prompting. The stable primitive names remain familiar, but the runtime centrally refuses file mutation and shell execution. Read-only inspection and policy-allowed research, including deferred Web search/fetch, remain available. When a Plan turn successfully finishes a nonempty response, including a prose-only plan, the TUI asks how to continue: Work (Ask), Work (Auto-Review), or keep planning. Work sends the exact approved response as a normal follow-up and preserves that turn's existing To-dos, or adds one pending plan item without replacing unrelated work.
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `bash`, `agent`, `workflow`, and `todo_write`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.
- **Work** (internally `agent`): ordinary multi-step execution. The first-turn toolbox includes `read`, `write`, `edit`, `file_search`, `bash`, `agent`, `workflow`, `todo_write`, and `load_skill`, plus `create_goal`, `get_goal`, and `update_goal` so goal controls are available without discovery. The agent calls `create_goal` when a request is a durable objective; the host never infers a goal from wording, and the contract must not tell the model otherwise. Approval, sandbox, repository law, and managed policy decide what may execute.
- **Operate**: Work at full strength, the "ultra" mode. Same tools and authority as Work; the difference is how they are used. Each substantive request becomes a goal (`create_goal`), the plan stays visible (`todo_write`), multi-part work runs through `workflow` and parallel `agent` workers, non-trivial changes get an independent reviewer before they are called done, long commands keep running in the background, and recurring or watch-type work is offered as an `automation` (created after approval). It keeps going until the goal is verified, blocked on you, or paused.
`Act` and `/mode act` remain compatibility aliases for Work. Saved settings
+41 -17
View File
@@ -94,10 +94,11 @@ Sources to keep in sync:
- `crates/config/assets/catalog_corrections.json` `reviewed` - intrinsic facts,
scoped selector aliases, completion references and pure transport metadata.
The existing seed renderer embeds this reviewed supplement in Models.dev.
- `crates/agent/src/lib.rs` - compatibility projection of those shared selector
rows, used by `codewhale model resolve` (and the `[model]` config surface).
`codewhale model list` reads the resolved provider lake instead, so a single
list shows bundled, live and configured rows together.
- `crates/tui/src/provider_lake.rs` and `route_runtime.rs` - resolve current
model facts for both CLI model commands, the picker and Runtime API. Public
catalog facts, provider/account rosters and explicit configuration retain
their separate ownership. `crates/agent/src/lib.rs` keeps the legacy selector
compatibility projection; it does not decide the CLI's current route.
- `config.example.toml` and `docs/CONFIGURATION.md` - user-facing config
examples and environment variable reference.
- `scripts/check-provider-registry.py` - drift check for canonical provider
@@ -1129,16 +1130,26 @@ price. Flash ships the published $0.15/$0.50 list. A live call can still
## Static Model Registry
This table describes the static registry, which `codewhale model resolve` (and
the `[model]` config surface) still use to resolve selector aliases.
This table records legacy selector compatibility. Current `codewhale model
resolve`, `codewhale model list`, the picker and Runtime API use the shared
provider lake and route resolver. `models --update` refreshes the existing
catalog cache; later routes see new metadata, while dispatched requests retain
their admitted limits and price receipt.
`codewhale model resolve` projects the reviewed `selections` in
`crates/config/assets/catalog_corrections.json` through
`crates/agent/src/lib.rs`. There is no independent Rust model roster. These
ordered aliases and flags are compatibility metadata, not account availability
or executable route permission. This differs from live `/models` discovery.
Use `/models` or `codewhale models` to fetch model IDs from the active API
endpoint when the endpoint supports model listing.
The reviewed `selections` in `crates/config/assets/catalog_corrections.json`
remain compatibility metadata for legacy callers. They do not establish account
availability or executable route permission. Public discovery can contain
metadata-only models; an authenticated provider roster establishes a different
fact. A same-named custom endpoint keeps its own declared limits and prices.
`codewhale model resolve MODEL --provider PROVIDER` reports the exact wire ID,
canonical ID when declared, limits and implemented capabilities. Runtime
`GET /v1/providers/PROVIDER/models` adds source, fetch/expiry metadata, published
price structure, reasoning options, configured effective limits and the price
status. `account_access: unverified` means these facts have not established the
selected account's access. Context responses distinguish an active engine
window from the static route's resolution source, including any fallback.
See [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.
| Provider | Static registry entries | Tool calls | Registry reasoning flag |
| --- | --- | --- | --- |
@@ -1226,7 +1237,7 @@ while bare `k3` can use an entitled 1M override.
| Provider/model class | Context window | Max output metadata | Thinking support | Cache telemetry | FIM endpoint |
| --- | --- | --- | --- | --- | --- |
| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |
| DeepSeek V4 (`deepseek-v4-pro`, `deepseek-v4-flash`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only, on Chat Completions routes; not on the default Flash route, which speaks Responses |
| DeepSeek V4 Flash Vision experimental (`deepseek-v4-flash-vision-exp`) | 1,000,000 inherited from Flash | 384,000 inherited from Flash | yes, inherited | yes, inherited | not claimed; Chat Completions route only |
| DeepSeek compatibility aliases (`deepseek-chat`, `deepseek-reasoner`) | 1,000,000 | 384,000 | yes | yes | DeepSeek beta only |
| NVIDIA NIM V4 registry models | 1,000,000 | 384,000 | yes | yes | not documented in code |
@@ -1445,6 +1456,9 @@ provider docs work, but they are not native shipped behavior in this checkout:
## Sign in with Claude
This is an experimental 0.10.2 candidate flow; live sign-in, inference and
provider authorization are not release-qualified.
Run `codewhale auth claude` (or `/auth claude` in a running session), open the
printed Claude sign-in URL, and paste the complete `code#state` callback.
`/provider setup anthropic` also offers API-key and Claude Pro / Max choices.
@@ -1455,7 +1469,9 @@ Codewhale stores the grant in its private credentials directory, commits the
provider's credential pointer atomically, and refreshes it before requests on a
worker. Subscription tokens go only to `https://api.anthropic.com`; a custom
endpoint is rejected. Each request keeps the existing Messages adapter, tool
protocol, cache prefix, and Codewhale client identity. No Claude Code credential
protocol, cache prefix, and Codewhale inference-client identity. The login
registration is a separate fact: this candidate uses Claude Code’s OAuth
client ID, not a Codewhale-issued registration. No Claude Code credential
file or keychain entry is read or modified.
`codewhale auth claude-revoke` (or `/auth claude-revoke`) removes the owned local
@@ -1465,5 +1481,13 @@ to use an API key. Login and inference follow Anthropic's provider-side
availability; local checks do not
establish that an account is eligible or that live subscription inference works.
The callback and JSON token exchange follow the interoperable flow used by
[pi's Anthropic OAuth adapter](https://github.com/fivewillow/badlogic-pi-mono/blob/main/packages/ai/src/utils/oauth/anthropic.ts).
Reference refreshed on 2026-10-13: upstream Pi is now `earendil-works/pi`.
Its [v1.2.0 OAuth adapter](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/auth/oauth/anthropic.ts)
supports loopback and copy-code flows and uses `platform.claude.com` token and
copy-code endpoints; this candidate still uses `console.anthropic.com`.
Pi also uses [Claude Code inference identity](https://github.com/earendil-works/pi/blob/06a466935536f8c9bcacb2d5efc0fc021be1bdff/packages/ai/src/api/anthropic-messages.ts),
which Codewhale does not adopt. Pi's implementation is a compatibility
reference, not proof of account eligibility, billing behavior or permission
to offer the same flow. See Anthropic's current
[authentication guidance](https://code.claude.com/docs/en/legal-and-compliance#authentication-and-credential-use)
and [Agent SDK plan guidance](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan).
+12 -2
View File
@@ -154,6 +154,15 @@ known limitations and publication approval. These proofs are separate.
against those assets and runs its delegated entrypoints. Keep run URLs and
required evidence before the Actions artifacts expire; these are not a
public release.
- [ ] Windows static import receipts cover x64/ARM64 CLI, shim and compatibility
assets, any optional host, and the NSIS setup plus its actual embedded PE
plugins. Verify the selected architecture profile and its conservative
limits in [ENVIRONMENTS.md](ENVIRONMENTS.md#windows-support-and-release-qualification).
Preserve the generated candidate Uninstall.exe import/SHA receipt. Static
import/profile PASS does not replace final release startup/install on the
promised oldest x64 OS, native ARM64 package acceptance, or packaged/native
app owner-crash recovery. Record or explicitly resolve these remaining
runtime gates before tagging.
- [ ] Complete the version-specific acceptance packet. Cross-building Android
does not prove a real Termux/device session. Local install, actual provider
calls and customer acceptance need separate receipts; provider spend still
@@ -247,8 +256,9 @@ known limitations and publication approval. These proofs are separate.
workflows can overwrite notes and accidentally remove contributor credit.
- [ ] Note any deferred items in the next release's tracking issue.
- [ ] Close any issues that this release fixed.
- [ ] Update existing tracking with evidence; agents do not post GitHub
issue/PR comments. Handle the existing `sync-release-record` workflow's
- [ ] Update existing tracking with evidence and follow the current contributor
communication rules in [AGENTS.md](../AGENTS.md). Handle the existing
`sync-release-record` workflow's
metadata proposal after publication: it currently opens a bot PR, not a
required preparation branch. A source record does not prove deployment.
- [ ] Report canonical path, branch, SHA, upstream state and dirty/stranded
+10
View File
@@ -215,6 +215,16 @@ short-lived GitHub Actions artifact only.
Retain required asset/evidence copies before artifact expiry; record cross-build,
install and real-device/provider results separately in the existing packet.
Windows import evidence uses independent profiles for the native x64
compatibility target, observed ARM64 environment and x86 NSIS/WOW64 contracts;
see [ENVIRONMENTS.md](ENVIRONMENTS.md#windows-support-and-release-qualification).
Keep every staged Windows executable and the actual embedded NSIS PE plugin
dumps, hashes and failures. The generated Uninstall.exe needs its own scoped
installation receipt. Static import qualification does not prove final release
startup on Windows 10 1607, installed runtime dependencies or native ARM64
package/desktop behavior. Preserve these runtime receipts separately before
claiming the release is ready.
This candidate workflow does not create a tag or GitHub Release, publish a
crate or npm package, push a container, update Homebrew, deploy anything, or
write repository contents. Its green result is evidence, not publication
+13 -6
View File
@@ -1371,12 +1371,16 @@ ID that has already been settled all return `404` and reach no engine. A `404`
means the capability is not pending — it is not evidence about how the approval
was resolved; read `approval.decided` for that.
The raw provider call ID travels separately as `tool_call_id` on
The Engine tool-execution ID travels separately as `tool_call_id` on
`pending_approvals[]` and on the approval events. It is a correlator for
attaching a prompt to the tool row it gates, and never accepted as a decision.
Each thread-detail `pending_approvals[]` entry is
`{ "id", "turn_id", "tool_name", "description", "intent_summary"?, "tool_call_id"?, "summary"? }`,
where `id` is the capability above. `summary` (also on `approval.required`) is
`{ "id", "turn_id", "tool_name", "input"?, "description", "intent_summary"?, "tool_call_id"?, "summary"? }`,
where `id` is the capability above. `input` holds the captured tool input. An
omitted `input` means no captured value is available; an explicit JSON `null`
remains a captured value. On the authenticated canonical thread surface,
`approval.required` carries the same captured input.
`summary` (also on `approval.required`) is
a one-line description of the gated call built from the tool name and its
arguments only, never from model text ("Search the web for 'espresso'",
"Write notes/espresso.md"); paths inside the workspace are workspace-relative.
@@ -1403,8 +1407,11 @@ emits `approval.grant_added` with `{ "grant": { "grant_id", "tool_name",
revokes one (emitting `approval.grant_revoked`); the next matching call
prompts again. Archiving or deleting the thread ends all of its grants
(archiving emits `approval.grant_revoked` for each; unarchiving does not
restore them). Grants live in memory for the Runtime process: a restart
forgets them, and a forced (non-bypassable) prompt is never answered by one.
restore them). Grants are stored in the Runtime data directory
(`approval_grants.json`). After a Runtime restart a thread's grants stay
suspended, and matching calls prompt again, until an authenticated client
resumes the thread. A grant store that cannot be read holds no grants, so every
call prompts. A forced (non-bypassable) prompt is never answered by a grant.
**User input**
- `POST /v1/user-input/{thread_id}/{input_id}` with body
@@ -2651,7 +2658,7 @@ clients and does not grant or persist permissions.
distinct identifiers. `approval_id` is the Runtime-minted, single-use capability
described under **Approvals** — the only value `POST /v1/approvals/{id}` accepts
— and `approval.required` also repeats it in the legacy `id` field for older
clients. `tool_call_id` is the provider's raw tool-call ID, present for
clients. `tool_call_id` is the Engine tool-execution ID, present for
correlation only. Automatically resolved prompts (thread `auto_approve`, and the
Auto-Review posture, which never opens a modal) mint an `approval_id` as well, so
the field has one meaning on every path; those IDs register no waiter and are
+2
View File
@@ -48,6 +48,7 @@ The direct schemas deliberately stay small:
| `read` | `path`, optional `offset`, optional `limit` | Read a bounded file window with explicit continuation or truncation notices. |
| `write` | `path`, `content` | Create or replace a file. |
| `edit` | `path`, `edits` | Apply one or more unambiguous text replacements against one original snapshot. |
| `file_search` | `query`, optional `path`, `limit`, `extensions`, `exclude` | Find files by name, path fragment or glob and return bounded, ranked matches. Respects ignore files; `exclude` replaces default exclusions. |
| `bash` | `command`, optional `timeout`, optional `description` | Run one cancellable foreground shell command and return a bounded tail. The optional description is display-only. |
| `agent` | delegated task and optional scope/context controls | Start or inspect focused child work. |
| `workflow` | plan/script/source_path plus run controls | Coordinate multi-agent phases with dependencies and completion checks. |
@@ -55,6 +56,7 @@ The direct schemas deliberately stay small:
| `create_goal` | objective plus optional budget | Start the session goal the turn works toward. |
| `get_goal` | none | Read the active goal and its progress. |
| `update_goal` | terminal status | Mark the goal complete or blocked. |
| `load_skill` | optional `name`, `query` | Load a named skill's `SKILL.md` body and companion-file list; omit `name` or use `name="list"` to list eligible skills, or use `query` to search names and descriptions. |
| `tool_search` | `query`, optional matching controls | Discover policy-allowed deferred tools and add selected schemas to this conversation's toolbox. |
Mode is an authority decision, not a synonym system. Plan, Work, and Operate
+8 -8
View File
@@ -286,7 +286,7 @@ owner = "crates/tui/src/sandbox"
[[feature]]
id = "providers"
name = "Providers"
summary = "First-class provider routes plus bundled OpenAI-compatible host descriptors."
summary = "Provider routes with endpoint-scoped model facts, explicit limits and sourced price estimates."
status = "stable"
surfaces = { tui = "stable", api = "stable" }
docs = "docs/PROVIDERS.md"
@@ -579,7 +579,7 @@ name = "Account computer files"
summary = "Save computer files as account versions, restore with revision checks, and download verified bytes."
status = "experimental"
surfaces = { tui = "preview" }
since = "unreleased"
since = "0.10.2"
owner = "crates/cli/src/cloud.rs"
[[feature]]
@@ -665,7 +665,7 @@ name = "Sign in with ChatGPT"
summary = "Uses official OAuth and the selected account catalog for eligible ChatGPT plan requests in the local OSS app."
status = "experimental"
surfaces = { tui = "preview", api = "partial", web = "none" }
since = "unreleased"
since = "0.10.1"
docs = "docs/PROVIDERS.md#sign-in-with-chatgpt"
owner = "crates/tui/src/oauth.rs"
@@ -675,7 +675,7 @@ name = "Sign in with Claude"
summary = "Adds Claude subscription sign-in and native Messages routing. Live provider acceptance remains unverified."
status = "experimental"
surfaces = { tui = "preview", api = "partial", web = "none" }
since = "unreleased"
since = "0.10.2"
docs = "docs/PROVIDERS.md#sign-in-with-claude"
owner = "crates/tui/src/oauth.rs"
@@ -685,7 +685,7 @@ name = "Reviewed plugin OAuth providers"
summary = "Reviewed plugins sign in with /connect <id> and out with /disconnect; the host owns PKCE, models and approvals."
status = "experimental"
surfaces = { tui = "preview", api = "partial", web = "none" }
since = "unreleased"
since = "0.10.1"
docs = "docs/PLUGIN_PROVIDERS.md"
owner = "crates/tui/src/plugins/providers.rs"
@@ -695,7 +695,7 @@ name = "Sign in with OrcaRouter"
summary = "OrcaRouter provider: sk-orca- API key or OAuth 2.0 + PKCE sign-in, plus a live chat model catalog."
status = "experimental"
surfaces = { tui = "preview", api = "none", web = "none" }
since = "unreleased"
since = "0.10.1"
docs = "docs/PROVIDERS.md"
owner = "crates/tui/src/oauth.rs"
@@ -716,7 +716,7 @@ name = "Profile constitution"
summary = "Account preferences are captured per turn and previewed by the Engine."
status = "experimental"
surfaces = { tui = "preview", api = "preview" }
since = "unreleased"
since = "0.10.1"
docs = "docs/RUNTIME_API.md#profile-constitution"
owner = "crates/tui/src/profile_constitution.rs"
@@ -726,6 +726,6 @@ name = "Plugin shell installation"
summary = "codewhale install uses the reviewed bundle installer with local, GitHub and version-pinned npm sources."
status = "experimental"
surfaces = { tui = "preview" }
since = "unreleased"
since = "0.10.2"
docs = "docs/PLUGINS.md"
owner = "crates/tui/src/plugins/mutation.rs"
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more