mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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 (head704d435db1, a clean merge of mainb5e270b690intoba01538bc9with no manual conflict resolutions): - Hosted CI on704d435db1: 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 fromb5e270b690until 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:
1 parent
b5e270b690
commit
be980da403
117 files changed
+8869
-1495
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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("e.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 }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -2413,6 +2413,7 @@
|
||||
"CostReasonInvalidRate": "अमान्य दर",
|
||||
"CostReasonCurrencyMissing": "इस मुद्रा की दर उपलब्ध नहीं",
|
||||
"CostReasonUsageConflict": "टोकन गणनाओं में विरोध",
|
||||
"CostReasonUsageBreakdown": "टोकन उपयोग का विवरण उपलब्ध नहीं",
|
||||
"CostChipUnknown": "लागत: अज्ञात ({reasons})",
|
||||
"CostChipSubtotal": "उप-योग {amount} + अज्ञात ({reasons})",
|
||||
"CostChipSavedSubtotal": "सहेजा गया उप-योग {amount} + अज्ञात ({reasons})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -2413,6 +2413,7 @@
|
||||
"CostReasonInvalidRate": "無効な料金",
|
||||
"CostReasonCurrencyMissing": "通貨の料金情報なし",
|
||||
"CostReasonUsageConflict": "トークン数が不整合",
|
||||
"CostReasonUsageBreakdown": "トークン使用量の内訳が不明",
|
||||
"CostChipUnknown": "費用: 不明({reasons})",
|
||||
"CostChipSubtotal": "小計 {amount} + 不明({reasons})",
|
||||
"CostChipSavedSubtotal": "保存済み小計 {amount} + 不明({reasons})",
|
||||
|
||||
@@ -2413,6 +2413,7 @@
|
||||
"CostReasonInvalidRate": "잘못된 요금",
|
||||
"CostReasonCurrencyMissing": "해당 통화 요금 없음",
|
||||
"CostReasonUsageConflict": "토큰 수 불일치",
|
||||
"CostReasonUsageBreakdown": "토큰 사용량 내역을 알 수 없음",
|
||||
"CostChipUnknown": "비용: 알 수 없음 ({reasons})",
|
||||
"CostChipSubtotal": "소계 {amount} + 알 수 없음 ({reasons})",
|
||||
"CostChipSavedSubtotal": "저장된 소계 {amount} + 알 수 없음 ({reasons})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -2413,6 +2413,7 @@
|
||||
"CostReasonInvalidRate": "некорректный тариф",
|
||||
"CostReasonCurrencyMissing": "тариф в этой валюте недоступен",
|
||||
"CostReasonUsageConflict": "противоречивые счётчики токенов",
|
||||
"CostReasonUsageBreakdown": "разбивка токенов недоступна",
|
||||
"CostChipUnknown": "стоимость: неизвестна ({reasons})",
|
||||
"CostChipSubtotal": "подытог {amount} + неизвестно ({reasons})",
|
||||
"CostChipSavedSubtotal": "сохранённый подытог {amount} + неизвестно ({reasons})",
|
||||
|
||||
@@ -2413,6 +2413,7 @@
|
||||
"CostReasonInvalidRate": "некоректний тариф",
|
||||
"CostReasonCurrencyMissing": "тариф у цій валюті недоступний",
|
||||
"CostReasonUsageConflict": "суперечливі лічильники токенів",
|
||||
"CostReasonUsageBreakdown": "деталізація токенів недоступна",
|
||||
"CostChipUnknown": "вартість: невідома ({reasons})",
|
||||
"CostChipSubtotal": "проміжний підсумок {amount} + невідомо ({reasons})",
|
||||
"CostChipSavedSubtotal": "збережений проміжний підсумок {amount} + невідомо ({reasons})",
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -2413,6 +2413,7 @@
|
||||
"CostReasonInvalidRate": "费率无效",
|
||||
"CostReasonCurrencyMissing": "缺少该币种费率",
|
||||
"CostReasonUsageConflict": "token 计数冲突",
|
||||
"CostReasonUsageBreakdown": "token 用量明细不可用",
|
||||
"CostChipUnknown": "费用:未知({reasons})",
|
||||
"CostChipSubtotal": "小计 {amount} + 未知({reasons})",
|
||||
"CostChipSavedSubtotal": "已保存小计 {amount} + 未知({reasons})",
|
||||
|
||||
@@ -2411,6 +2411,7 @@
|
||||
"CostReasonInvalidRate": "費率無效",
|
||||
"CostReasonCurrencyMissing": "缺少該幣別費率",
|
||||
"CostReasonUsageConflict": "token 計數衝突",
|
||||
"CostReasonUsageBreakdown": "token 用量明細不可用",
|
||||
"CostChipUnknown": "費用:未知({reasons})",
|
||||
"CostChipSubtotal": "小計 {amount} + 未知({reasons})",
|
||||
"CostChipSavedSubtotal": "已儲存小計 {amount} + 未知({reasons})",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)).
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
"e.provider_identity,
|
||||
"e.wire_model,
|
||||
"e.endpoint_fingerprint,
|
||||
quote.catalog_fetched_at,
|
||||
"e.currency,
|
||||
"e.provenance,
|
||||
"e.input_per_million,
|
||||
"e.output_per_million,
|
||||
"e.cache_read_per_million,
|
||||
"e.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(
|
||||
"e.cache_read_per_million,
|
||||
"e.cache_write_per_million,
|
||||
quote.cloud_facts.as_ref(),
|
||||
quote.pricing_schedule.as_deref(),
|
||||
)?;
|
||||
quote.pricing_for_route(
|
||||
provider,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user