chore: import upstream snapshot with attribution
CI / Change detection (push) Has been cancelled
CI / Version drift (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Workflow RLM cache (push) Has been cancelled
CI / Test (macos-latest) (push) Has been cancelled
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / npm wrapper smoke (push) Has been cancelled
CI / Mobile runtime smoke (push) Has been cancelled
CI / Workflow lint (push) Has been cancelled
CI / Documentation (push) Has been cancelled
Web Frontend / Lint & Type Check (push) Failing after 1s
Auto-close harvested PRs / close (push) Failing after 1s
cargo-deny / cargo-deny (bans licenses sources) (push) Failing after 1s
Security audit / cargo-audit (push) Failing after 1s
Sync to CNB / sync (push) Failing after 1s
Web Frontend / Deploy to Cloudflare (push) Waiting to run
cargo-deny / cargo-deny (advisories) (push) Failing after 3s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:23 +08:00
commit d68f003000
1008 changed files with 594824 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
# cargo-audit configuration (read by `cargo audit`).
#
# The advisories below are all "unmaintained" warnings — NOT security
# vulnerabilities. Each crate is pulled in transitively only by the `starlark`
# 0.13.0 family (starlark / starlark_syntax / starlark_map), which crates/tui
# depends on directly for Starlark execpolicy files.
# `cargo tree -i <crate>` confirms starlark is the sole path for each.
#
# There is no fix available without an upstream `starlark` release that drops
# these deps, and none is exploitable here. They are accepted for now and
# tracked in this file so `cargo audit` stays clean for genuinely new advisories.
# Remove an entry once a starlark upgrade/removal drops the transitive dep
# (re-check with `cargo tree -i derivative` and `cargo audit`).
#
# Audit #11, scratchpad/bug-audit-2026-06-24.md.
[advisories]
ignore = [
"RUSTSEC-2024-0388", # derivative 2.2.0 unmaintained — transitive via starlark 0.13.0
"RUSTSEC-2025-0057", # fxhash 0.2.1 unmaintained — transitive via starlark_map 0.13.0
"RUSTSEC-2024-0436", # paste 1.0.15 unmaintained — transitive via starlark 0.13.0
]
+186
View File
@@ -0,0 +1,186 @@
# CNB is a one-way mirror from GitHub. Keep this file source-controlled here;
# CNB-side edits will be overwritten by the GitHub -> CNB sync workflow.
.feishu_bridge_tests: &feishu_bridge_tests
name: feishu bridge tests
runner:
tags: cnb:arch:amd64
cpus: 8
docker:
image: node:22-bookworm
stages:
- name: feishu bridge tests
script: |
set -eu
cd integrations/feishu-bridge
npm ci
npm run check
npm test
.rust_workspace_gates_stage: &rust_workspace_gates_stage
name: rust workspace gates
script: |
set -eu
./scripts/release/check-versions.sh
./scripts/release/check-ohos-deps.sh
cargo fmt --all -- --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked
# Parity gates as first-class steps so drift surfaces as a named failure,
# not a buried workspace-test entry. Mirrors release.yml's parity job.
cargo test -p codewhale-protocol --test parity_protocol --locked
cargo test -p codewhale-state --test parity_state --locked
.linux_rust_gates: &linux_rust_gates
name: linux rust gates
runner:
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.88-bookworm
stages:
- name: install linux dependencies
script: |
set -eu
apt-get update
apt-get install -y git libdbus-1-dev nodejs npm pkg-config
if command -v rustup >/dev/null 2>&1; then
rustup component add rustfmt clippy
fi
- *rust_workspace_gates_stage
- name: linux npm wrapper smoke
script: |
set -eu
cargo build --release --locked -p codewhale-cli -p codewhale-tui
export PATH="$PWD/target/release:$PATH"
node scripts/release/npm-wrapper-smoke.js
./target/release/codewhale --version
./target/release/codewhale-tui --version
.linux_release_preflight: &linux_release_preflight
name: linux release preflight
runner:
tags: cnb:arch:amd64
cpus: 16
docker:
image: rust:1.88-bookworm
stages:
- name: install release dependencies
script: |
set -eu
apt-get update
apt-get install -y curl git libdbus-1-dev nodejs npm pkg-config
if command -v rustup >/dev/null 2>&1; then
rustup component add rustfmt clippy
fi
- *rust_workspace_gates_stage
- name: crate publish dry-run
script: |
set -eu
./scripts/release/publish-crates.sh dry-run
- name: release binary smoke
script: |
set -eu
cargo build --release --locked -p codewhale-cli -p codewhale-tui
export PATH="$PWD/target/release:$PATH"
node scripts/release/npm-wrapper-smoke.js
./target/release/codewhale --version
./target/release/codewhale-tui --version
main:
push:
- *feishu_bridge_tests
- *linux_rust_gates
"(fix/*|rebrand/*)":
push:
- *linux_rust_gates
"work/v*":
push:
- *feishu_bridge_tests
- *linux_release_preflight
$:
tag_push:
- docker:
image: rust:1.88-bookworm
stages:
- name: build linux x64 release assets (static)
script: |
set -eu
apt-get update
apt-get install -y git musl-tools nodejs pkg-config
rustup target add x86_64-unknown-linux-musl
./scripts/release/check-versions.sh
./scripts/release/check-ohos-deps.sh
cargo build --release --locked \
--target x86_64-unknown-linux-musl \
-p codewhale-cli -p codewhale-tui
mkdir -p target/cnb-release
BIN_DIR="target/x86_64-unknown-linux-musl/release"
cp "$BIN_DIR/codewhale" target/cnb-release/codewhale-linux-x64
cp "$BIN_DIR/codewhale-tui" target/cnb-release/codewhale-tui-linux-x64
strip \
target/cnb-release/codewhale-linux-x64 \
target/cnb-release/codewhale-tui-linux-x64 \
|| true
(
cd target/cnb-release
sha256sum \
codewhale-linux-x64 \
codewhale-tui-linux-x64 \
> codewhale-artifacts-sha256.txt
)
tag_name="${CNB_BRANCH:-}"
if [ -z "$tag_name" ]; then
tag_name="$(git describe --tags --exact-match 2>/dev/null || true)"
fi
version="${tag_name#v}"
cargo_version="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
if [ -n "$tag_name" ] && [ "$version" != "$cargo_version" ]; then
echo "ERROR: tag ${tag_name} does not match Cargo.toml version ${cargo_version}" >&2
exit 1
fi
commit_sha="${CNB_COMMIT:-$(git rev-parse HEAD)}"
{
echo "# ${tag_name:-CNB release}"
echo
awk -v version="${version}" '
index($0, "## [" version "]") == 1 { in_section = 1; next }
in_section && /^## \[/ { exit }
in_section { print }
' CHANGELOG.md
echo
echo "Built by CNB from ${commit_sha}."
echo
echo "Assets:"
echo "- codewhale-linux-x64"
echo "- codewhale-tui-linux-x64"
echo "- codewhale-artifacts-sha256.txt"
} > target/cnb-release/CNB_RELEASE.md
- name: create cnb release
type: git:release
options:
descriptionFromFile: target/cnb-release/CNB_RELEASE.md
latest: true
- name: upload linux x64 release assets
image: cnbcool/attachments:latest
settings:
attachments:
- target/cnb-release/codewhale-linux-x64
- target/cnb-release/codewhale-tui-linux-x64
- target/cnb-release/codewhale-artifacts-sha256.txt
+30
View File
@@ -0,0 +1,30 @@
{
"schema_version": 1,
"authority": [
"current user request",
"live code and tests",
"GitHub issue/PR details",
"AGENTS.md and project CLAUDE.md",
"memory",
"previous-session handoffs"
],
"protected_invariants": [
"Keep the active first-turn tool-catalog head byte-stable (DeepSeek KV prefix-cache invariant); changes to it must be one-time and deterministic.",
"Preserve old-session transcript replay: never remove a tool's registration just because it is deprecated/hidden.",
"Stable Rust only (edition 2024); no nightly features.",
"Keep the codewhale CLI dispatcher and the codewhale-tui binary in sync when crates/tui changes."
],
"branch_policy": "Start from live branch and handoff truth. Never commit directly to main; use the active integration branch or a fresh codex/... branch/worktree for isolated work, and open reviewable PRs into main. One PR per logical workstream; do not mix unrelated fixes.",
"verification_policy": {
"before_claiming_done": [
"run the focused tests for the changed crate (cargo test -p <crate>), then cargo check/clippy as appropriate",
"read changed files back to confirm the edit landed as intended",
"never claim verification you did not perform"
]
},
"escalate_when": [
"an action is destructive or hard to reverse and was not explicitly authorized",
"changing provider/auth/config or anything that sends data to an external service",
"deleting or overwriting files you did not create, or that contradict how they were described"
]
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "CodeWhale",
"dockerFile": "../Dockerfile",
"build": {
"args": {
"RUST_VERSION": "1.88"
}
},
"customizations": {
"vscode": {
"extensions": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb"
],
"settings": {
"rust-analyzer.cargo.features": "all",
"editor.formatOnSave": true
}
}
},
"remoteEnv": {
"DEEPSEEK_API_KEY": "${localEnv:DEEPSEEK_API_KEY}"
},
"mounts": [
"source=${localEnv:HOME}/.codewhale,target=/home/codewhale/.codewhale,type=bind,consistency=cached"
],
"features": {
"ghcr.io/devcontainers/features/rust:1": {},
"ghcr.io/devcontainers/features/git:1": {}
},
"postCreateCommand": "cargo build",
"remoteUser": "codewhale"
}
+66
View File
@@ -0,0 +1,66 @@
# Build artifacts
/target/
*.pdb
*.dll
*.so
*.dylib
*.rlib
# Sensitive environment files
.env
.env.*
# Development
/node_modules/
/.vscode/
/.idea/
*.swp
*.swo
*~
.DS_Store
# Git
/.git/
/.gitignore
/.gitattributes
# CI/CD
/.github/
# Python
__pycache__/
*.py[cod]
.pytest_cache/
venv/
.venv/
# Logs
*.log
# Generated
/outputs/
/tmp/
# Local runtime state
/.deepseek/
# Claude Code artifacts
/.claude/
/.ace-tool/
# Documentation (not needed at runtime)
/docs/
/website/
/*.md
!/README.md
!/CHANGELOG.md
# Assets (screenshots, etc.)
/assets/
# Scripts
/scripts/
# Development configs
/.devcontainer/
/config.example.toml
+55
View File
@@ -0,0 +1,55 @@
# DeepSeek TUI environment
# Shell-exported variables override values in this file.
# Copy this file to `.env`, then uncomment only the values you want to use.
# DeepSeek API (default provider)
# Get an API key from DeepSeek, then keep it local in `.env`.
# DEEPSEEK_API_KEY=
# Official DeepSeek Platform host (see api-docs.deepseek.com); `deepseek-cn` uses the same host.
# DEEPSEEK_BASE_URL=https://api.deepseek.com
# DEEPSEEK_PROVIDER=deepseek-cn
# V4 model selection. Compatibility aliases such as `deepseek-chat` normalize
# to the current V4 flash model in the TUI.
# DEEPSEEK_MODEL=deepseek-v4-pro
# DEEPSEEK_MODEL=deepseek-v4-flash
# NVIDIA NIM-hosted DeepSeek V4
# Use this provider when routing through NVIDIA's OpenAI-compatible endpoint.
# DEEPSEEK_PROVIDER=nvidia-nim
# NVIDIA_API_KEY=
# NVIDIA_NIM_API_KEY=
# NVIDIA_NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NIM_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_BASE_URL=https://integrate.api.nvidia.com/v1
# NVIDIA_NIM_MODEL=deepseek-ai/deepseek-v4-pro
# AtlasCloud OpenAI-compatible endpoint
# Atlas Cloud exposes curated model IDs through a single OpenAI-compatible API.
# See https://www.atlascloud.ai/docs and the Coding Plan:
# https://www.atlascloud.ai/console/coding-plan
# Reasoning models such as deepseek-ai/deepseek-v4-pro need max_tokens >= 512
# if you override output caps.
# DEEPSEEK_PROVIDER=atlascloud
# ATLASCLOUD_API_KEY=<your-atlascloud-key>
# ATLASCLOUD_BASE_URL=https://api.atlascloud.ai/v1
# ATLASCLOUD_MODEL=deepseek-ai/deepseek-v4-pro
# Logging
# `DEEPSEEK_LOG_LEVEL` is forwarded by the facade; `RUST_LOG` enables the
# TUI's lightweight verbose logs for info/debug/trace directives.
# DEEPSEEK_LOG_LEVEL=debug
# RUST_LOG=deepseek_tui=debug
# Agent safety defaults
# `on-request` asks before higher-risk work; `workspace-write` keeps writes
# inside the workspace unless a sandbox elevation path is explicitly used.
# DEEPSEEK_APPROVAL_POLICY=on-request
# DEEPSEEK_SANDBOX_MODE=workspace-write
# DEEPSEEK_ALLOW_SHELL=true
# DEEPSEEK_YOLO=true
# Optional extension paths
# DEEPSEEK_SKILLS_DIR=~/.deepseek/skills
# DEEPSEEK_MCP_CONFIG=~/.deepseek/mcp.json
+21
View File
@@ -0,0 +1,21 @@
# Ensure LF line endings for files consumed by include_str!() on all platforms.
# include_str!() preserves raw bytes; CRLF breaks substring assertions and
# produces different compiled binaries on Windows vs Linux/macOS.
# Cover nested mode/approval/personality prompts — a top-level *.md glob alone
# left modes/*.md on CRLF under Windows autocrlf and blew the agent token budget.
crates/tui/src/prompts/**/*.md text eol=lf
crates/tui/src/prompts/*.md text eol=lf
crates/tui/src/prompts/*.txt text eol=lf
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
*.rs text eol=lf
# Release shell scripts are invoked directly by bash on Windows
# checkouts; CRLF turns `set -euo pipefail` into an invalid option.
scripts/release/*.sh text eol=lf
# Keep repository attributes themselves stable on every platform.
.gitattributes text eol=lf
# Everything else auto-detects (default).
* text=auto
+64
View File
@@ -0,0 +1,64 @@
# Scoped contribution-gate allowlist.
#
# Maintainers and collaborators bypass the gate automatically. Use this file
# for external contributors who are allowed through the automated front door.
# Seed active contributors here before switching the gate workflows to enforce mode.
#
# Supported entries:
# pr:username
# issue:username
# all:username
all:hmbown
all:reidliu41
all:ousamabenyounes
all:ljm3790865
all:HUQIANTAO
all:xyuai
all:merchloubna70-dot
all:h3c-hexin
all:axobase001
all:donglovejava
all:Oliver-ZPLiu
all:idling11
all:angziii
all:aboimpinto
all:encyc
all:Duducoco
all:cyq1017
all:zlh124
all:THINKER-ONLY
all:nightt5879
all:Liu-Vince
all:JiarenWang
all:wdw8276
all:pengyou200902
all:linzhiqin2003
all:LING71671
all:JasonOA888
all:Inference1
all:hongqitai
all:gordonlu
all:gaord
all:zhuangbiaowei
all:yuanchenglu
all:Vishnu1837
all:sximelon
all:Sskift
all:New2Niu
all:shenjackyuanjie
all:AdityaVG13
all:mvanhorn
all:MengZ-super
all:membphis
all:LeoAlex0
all:Lee-take
all:lbcheng888
all:Implementist
all:jrcjrcc
all:yusufgurdogan
all:kunpeng-ai-lab
all:elowen53
all:CrepuscularIRIS
all:chnjames
all:ChaceLyee2101
all:AresNing
+176
View File
@@ -0,0 +1,176 @@
# Contributor credit identity map.
#
# Format:
# alias = Display Name <id+login@users.noreply.github.com>
#
# The right-hand side must use GitHub's numeric noreply address so harvested
# co-author credit lands in the contributor graph. The left-hand side may be a
# GitHub login, old-style noreply address, raw email from a contributor commit,
# or local machine email seen in older harvested history.
hmbown = Hmbown <101357273+Hmbown@users.noreply.github.com>
Mr-Moon121 = Jeffrey Luna <56358802+Mr-Moon121@users.noreply.github.com>
lerugray = Ray Weiss <15095329+lerugray@users.noreply.github.com>
lerugray@gmail.com = Ray Weiss <15095329+lerugray@users.noreply.github.com>
reidliu41 = reidliu41 <61492567+reidliu41@users.noreply.github.com>
reid201711@gmail.com = reidliu41 <61492567+reidliu41@users.noreply.github.com>
ousamabenyounes = Ben Younes <2910651+ousamabenyounes@users.noreply.github.com>
benyounes.ousama@gmail.com = Ben Younes <2910651+ousamabenyounes@users.noreply.github.com>
ljm3790865 = ljm3790865 <263429444+ljm3790865@users.noreply.github.com>
HUQIANTAO = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com>
Hu Qiantao = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com>
huqiantao@users.noreply.github.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com>
huqiantao@HudeMacBook-Air.local = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com>
tom_huu@qq.com = HUQIANTAO <58421104+HUQIANTAO@users.noreply.github.com>
punkcanyang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com>
Punkcan Yang = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com>
bucunzai@gmail.com = Punkcan Yang <36871858+punkcanyang@users.noreply.github.com>
merchloubna70-dot = merchloubna70-dot <258170091+merchloubna70-dot@users.noreply.github.com>
h3c-hexin = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com>
he.xin@h3c.com = h3c-hexin <13790929+h3c-hexin@users.noreply.github.com>
axobase001 = axobase001 <138223345+axobase001@users.noreply.github.com>
donglovejava = donglovejava <211940267+donglovejava@users.noreply.github.com>
Oliver-ZPLiu = Oliver-ZPLiu <47081637+Oliver-ZPLiu@users.noreply.github.com>
idling11 = idling11 <8055620+idling11@users.noreply.github.com>
Hanmiao Li = idling11 <8055620+idling11@users.noreply.github.com>
894876246@qq.com = idling11 <8055620+idling11@users.noreply.github.com>
angziii = angziii <177907677+angziii@users.noreply.github.com>
aboimpinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com>
Paulo Aboim Pinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com>
aboimpinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com>
encyc = encyc <62669951+encyc@users.noreply.github.com>
Duducoco = Duducoco <69681789+Duducoco@users.noreply.github.com>
cyq1017 = cyq1017 <61975706+cyq1017@users.noreply.github.com>
cyq = cyq1017 <61975706+cyq1017@users.noreply.github.com>
15000851237@163.com = cyq1017 <61975706+cyq1017@users.noreply.github.com>
cy2311 = CY <29836092+cy2311@users.noreply.github.com>
noaft = Toan Nguyen <118294762+noaft@users.noreply.github.com>
nvantoan1203@gmail.com = Toan Nguyen <118294762+noaft@users.noreply.github.com>
zlh124 = zlh124 <56312993+zlh124@users.noreply.github.com>
LeoLin990405 = LeoLin990405 <101193087+LeoLin990405@users.noreply.github.com>
THINKER-ONLY = THINKER-ONLY <181556007+THINKER-ONLY@users.noreply.github.com>
nightt5879 = nightt5879 <87569709+nightt5879@users.noreply.github.com>
aznikline = aznikline <27564626+aznikline@users.noreply.github.com>
Aznable = aznikline <27564626+aznikline@users.noreply.github.com>
anikline@gmail.com = aznikline <27564626+aznikline@users.noreply.github.com>
Liu-Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com>
Vince = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com>
liuwenchang.x@qq.com = Liu-Vince <56624166+Liu-Vince@users.noreply.github.com>
LI-Jialu = LI-Jialu <55438527+LI-Jialu@users.noreply.github.com>
JiarenWang = JiarenWang <33421508+JiarenWang@users.noreply.github.com>
wdw8276 = wdw8276 <3972439+wdw8276@users.noreply.github.com>
pengyou200902 = pengyou200902 <35026241+pengyou200902@users.noreply.github.com>
linzhiqin2003 = linzhiqin2003 <123250980+linzhiqin2003@users.noreply.github.com>
LING71671 = LING71671 <231181387+LING71671@users.noreply.github.com>
JasonOA888 = JasonOA888 <101583541+JasonOA888@users.noreply.github.com>
Inference1 = Inference1 <68734681+Inference1@users.noreply.github.com>
hongqitai = hongqitai <188678175+hongqitai@users.noreply.github.com>
gordonlu = gordonlu <3125629+gordonlu@users.noreply.github.com>
angus-guo = gus <217034332+angus-guo@users.noreply.github.com>
gus.guo@tec-do.com = gus <217034332+angus-guo@users.noreply.github.com>
gaord = gaord <9567937+gaord@users.noreply.github.com>
Ben Gao = gaord <9567937+gaord@users.noreply.github.com>
bengao168@msn.com = gaord <9567937+gaord@users.noreply.github.com>
MXAntian = MXAntian <271787757+MXAntian@users.noreply.github.com>
tianzimyq@126.com = MXAntian <271787757+MXAntian@users.noreply.github.com>
DarrellThomas = Darrell Thomas <10118469+DarrellThomas@users.noreply.github.com>
darrell@redshed.ai = Darrell Thomas <10118469+DarrellThomas@users.noreply.github.com>
taixinguo = Taixin Guo <188038323+taixinguo@users.noreply.github.com>
Taixin Guo = Taixin Guo <188038323+taixinguo@users.noreply.github.com>
JayBeest = JayBeest <67948678+JayBeest@users.noreply.github.com>
jcorneli = JayBeest <67948678+JayBeest@users.noreply.github.com>
jaybart@protonmail.com = JayBeest <67948678+JayBeest@users.noreply.github.com>
lucaszhu-hue = lucaszhu-hue <278269343+lucaszhu-hue@users.noreply.github.com>
zhuangbiaowei = zhuangbiaowei <93194+zhuangbiaowei@users.noreply.github.com>
yuanchenglu = yuanchenglu <4088730+yuanchenglu@users.noreply.github.com>
Vishnu1837 = Vishnu1837 <104626273+Vishnu1837@users.noreply.github.com>
sximelon = sximelon <15710511+sximelon@users.noreply.github.com>
Sskift = Sskift <163287349+Sskift@users.noreply.github.com>
New2Niu = New2Niu <19551155+New2Niu@users.noreply.github.com>
mvanhorn = mvanhorn <455140+mvanhorn@users.noreply.github.com>
MengZ-super = MengZ-super <121712068+MengZ-super@users.noreply.github.com>
membphis = membphis <6814606+membphis@users.noreply.github.com>
LeoAlex0 = LeoAlex0 <31839998+LeoAlex0@users.noreply.github.com>
Lee-take = Lee-take <210963840+Lee-take@users.noreply.github.com>
lbcheng888 = lbcheng888 <6716643+lbcheng888@users.noreply.github.com>
kunpeng-ai-lab = kunpeng-ai-lab <16793595+kunpeng-ai-lab@users.noreply.github.com>
elowen53 = elowen53 <88364845+elowen53@users.noreply.github.com>
Elowen = elowen53 <88364845+elowen53@users.noreply.github.com>
xrnc@outlook.com = elowen53 <88364845+elowen53@users.noreply.github.com>
CrepuscularIRIS = CrepuscularIRIS <126939795+CrepuscularIRIS@users.noreply.github.com>
chnjames = chnjames <44110547+chnjames@users.noreply.github.com>
ChaceLyee2101 = ChaceLyee2101 <95995339+ChaceLyee2101@users.noreply.github.com>
ci4ic4 = ci4ic4 <6495973+ci4ic4@users.noreply.github.com>
Chavdar Ivanov = ci4ic4 <6495973+ci4ic4@users.noreply.github.com>
ci4ic4@gmail.com = ci4ic4 <6495973+ci4ic4@users.noreply.github.com>
yusufgurdogan = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com>
Yusuf Gurdogan = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com>
hotelswith = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com>
contact@hotelswith.com = yusufgurdogan <13736056+yusufgurdogan@users.noreply.github.com>
AresNing = AresNing <49557311+AresNing@users.noreply.github.com>
shenjackyuanjie = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com>
shenjack = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com>
3695888@qq.com = shenjackyuanjie <54507071+shenjackyuanjie@users.noreply.github.com>
xyuai = xyuai <281015099+xyuai@users.noreply.github.com>
AdityaVG13 = AdityaVG13 <44177453+AdityaVG13@users.noreply.github.com>
adityavgcode@gmail.com = AdityaVG13 <44177453+AdityaVG13@users.noreply.github.com>
Implementist = Implementist <24910011+Implementist@users.noreply.github.com>
implecao = Implementist <24910011+Implementist@users.noreply.github.com>
yuyuyu4993@qq.com = Implementist <24910011+Implementist@users.noreply.github.com>
jrcjrcc = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com>
jrcjrcc@users.noreply.github.com = jrcjrcc <192965070+jrcjrcc@users.noreply.github.com>
RefuseOdd = RefuseOdd <192543033+RefuseOdd@users.noreply.github.com>
wywsoor = wywsoor <26341601+wywsoor@users.noreply.github.com>
hsdbeebou = hsdbeebou <284843096+hsdbeebou@users.noreply.github.com>
tdccccc = tdccccc <79492752+tdccccc@users.noreply.github.com>
greyfreedom = greyfreedom <11493871+greyfreedom@users.noreply.github.com>
greyfreedom@163.com = greyfreedom <11493871+greyfreedom@users.noreply.github.com>
puneetdixit200 = puneetdixit200 <236133619+puneetdixit200@users.noreply.github.com>
roian6 = Chanhyo Jung <23256775+roian6@users.noreply.github.com>
roian6@naver.com = Chanhyo Jung <23256775+roian6@users.noreply.github.com>
yekern = Stime <13691766+yekern@users.noreply.github.com>
Stime = Stime <13691766+yekern@users.noreply.github.com>
pkeging = pkeging <237035657+pkeging@users.noreply.github.com>
147567034@qq.com = pkeging <237035657+pkeging@users.noreply.github.com>
findshan = Wenshan Deng <224246733+findshan@users.noreply.github.com>
dengwenshan123456@outlook.com = Wenshan Deng <224246733+findshan@users.noreply.github.com>
KUK4 = KUK4 <246008043+KUK4@users.noreply.github.com>
LLL@users.noreply.github.com = KUK4 <246008043+KUK4@users.noreply.github.com>
# Harvest-credit reconciliation: contributors restored to docs/CONTRIBUTORS.md
# whose harvested work previously lacked a canonical map entry.
MMMarcinho = MMMarcinho <48539922+MMMarcinho@users.noreply.github.com>
MeAiRobot = MeAiRobot <283844506+MeAiRobot@users.noreply.github.com>
NorethSea = NorethSea <39730900+NorethSea@users.noreply.github.com>
SamhandsomeLee = SamhandsomeLee <135809116+SamhandsomeLee@users.noreply.github.com>
YaYII = YaYII <6007186+YaYII@users.noreply.github.com>
sandofree = sandofree <7775605+sandofree@users.noreply.github.com>
tiger-dog = tiger-dog <63037740+tiger-dog@users.noreply.github.com>
Jianfengwu2024 = Jianfengwu2024 <186297054+Jianfengwu2024@users.noreply.github.com>
wplll = wplll <106327929+wplll@users.noreply.github.com>
buko = buko <12692585+buko@users.noreply.github.com>
# Machine-credit reconciliation follow-up: contributors credited in prose or
# harvested by handle but missing from AUTHOR_MAP. Logins/IDs verified against
# the GitHub user API.
1Git2Clone = 1Git2Clone <171241044+1Git2Clone@users.noreply.github.com>
jieshu666 = jieshu666 <272788222+jieshu666@users.noreply.github.com>
rockeverm3m = rockeverm3m <280319705+rockeverm3m@users.noreply.github.com>
wfxqws65hc@privaterelay.appleid.com = rockeverm3m <280319705+rockeverm3m@users.noreply.github.com>
wavezhang = wavezhang <832911+wavezhang@users.noreply.github.com>
hxy91819 = hxy91819 <8814856+hxy91819@users.noreply.github.com>
hxy91819@gmail.com = hxy91819 <8814856+hxy91819@users.noreply.github.com>
heloanc = heloanc <61081755+heloanc@users.noreply.github.com>
heloanc@users.noreply.github.com = heloanc <61081755+heloanc@users.noreply.github.com>
bistack = Sun Zhenyuan <9128763+bistack@users.noreply.github.com>
zhenyuan.sun@163.com = Sun Zhenyuan <9128763+bistack@users.noreply.github.com>
# v0.8.68 underwater-lane harvests.
moduvoice = moduvoice <291867022+moduvoice@users.noreply.github.com>
moduvoicr77@gmail.com = moduvoice <291867022+moduvoice@users.noreply.github.com>
qinlinwang = qinlinwang <491831+qinlinwang@users.noreply.github.com>
hange = qinlinwang <491831+qinlinwang@users.noreply.github.com>
atnhan@qq.com = qinlinwang <491831+qinlinwang@users.noreply.github.com>
knqiufan = knqiufan <34114995+knqiufan@users.noreply.github.com>
knqiufan@foxmail.com = knqiufan <34114995+knqiufan@users.noreply.github.com>
+8
View File
@@ -0,0 +1,8 @@
# Default owner for everything in the repo.
* @Hmbown
# AI code review is advisory and not wired through CODEOWNERS: GitHub CODEOWNERS
# only accepts users and teams, not bots. @Hmbown stays the human code owner.
# - Claude: .github/workflows/claude-review.yml (GitHub Actions).
# - Codex/ChatGPT: the ChatGPT Codex cloud integration (chatgpt.com/codex ->
# connect GitHub -> enable Code review), authed by the ChatGPT subscription.
+2
View File
@@ -0,0 +1,2 @@
github: [Hmbown]
buy_me_a_coffee: hmbown
+91
View File
@@ -0,0 +1,91 @@
name: Agent task
description: Create a self-contained task that a headless agent (DeepSeek V4, remote droplet) can execute end-to-end without human context.
title: "v0.8.68: "
labels: ["agent-ready", "v0.8.68"]
body:
- type: markdown
attributes:
value: |
## Instructions for authors
This issue will be executed by an autonomous agent running `codewhale exec --auto`
on a headless VM. The body must be **self-sufficient** — every file path, command,
and acceptance criterion must be explicit. The agent has:
- A fresh clone of `Hmbown/CodeWhale` at `main`
- Shell, read, write, and git tools with auto-approvals
- No conversation context — this issue body is all it knows
Fill every section. Sections marked * are required.
- type: textarea
id: goal
attributes:
label: "Goal / Why"
description: "What problem does this fix, and why now? (2-4 sentences)"
placeholder: |
e.g. "The TUI freezes when 4+ sub-agents run concurrently because
AgentProgress events trigger a full redraw each. This blocks
recommended sub-agent fanout."
validations:
required: true
- type: textarea
id: scope
attributes:
label: "Scope / Plan"
description: "Numbered steps with file paths. Each step is one concrete action."
placeholder: |
1. crates/tui/src/tui/ui.rs — add throttle in AgentProgress handler (line ~2308)
2. crates/tui/src/tui/app.rs — add `last_agent_progress_redraw` field
3. cargo test -p codewhale-tui — verify no regressions
validations:
required: true
- type: textarea
id: key-files
attributes:
label: "Key files"
description: "One file path per line. The agent will read these first."
placeholder: |
crates/tui/src/tui/ui.rs
crates/tui/src/tui/sidebar.rs
crates/tui/src/tui/app.rs
validations:
required: true
- type: textarea
id: acceptance-criteria
attributes:
label: "Acceptance criteria"
description: "Behavior-level checkboxes. Every item must be testable."
placeholder: |
- [ ] 4 concurrent sub-agents do not freeze TUI input
- [ ] Ctrl+C works during sub-agent activity
- [ ] Sidebar updates throttle under load
validations:
required: true
- type: textarea
id: verification
attributes:
label: "Verification"
description: "Exact shell commands the agent must run to prove the fix works."
placeholder: |
cargo check -p codewhale-tui
cargo test -p codewhale-tui -- subagent
cargo clippy -p codewhale-tui -- -D warnings
validations:
required: true
- type: textarea
id: out-of-scope
attributes:
label: "Out of scope"
description: "What this issue does NOT change. Prevents scope creep."
placeholder: |
- Changing the sub-agent execution model
- Reducing the recommended fanout count
- Network-level optimizations
validations:
required: true
+49
View File
@@ -0,0 +1,49 @@
---
name: Bug report
about: Report a reproducible problem or regression
labels: bug
---
## Description
<!-- What happened? Please keep this focused on one bug. -->
## Steps to reproduce
<!-- Include exact commands, config, or key presses when relevant. -->
1.
2.
3.
## Expected behavior
## Actual behavior
## Impact
<!-- How often does this happen, and how much does it block your workflow? -->
## Environment
- OS:
- codewhale version:
- Install method:
- `codewhale doctor` summary:
- Model/provider:
- Terminal app:
- Shell:
<!-- Hints:
OS: Windows 11 / Ubuntu 22.04 / macOS 14
codewhale version: run `codewhale --version`
Install method: cargo install / release binary / source build
codewhale doctor summary: paste only the relevant lines, and redact secrets
Model/provider: deepseek-v4-pro / DeepSeek, or qwen2.5-coder / Ollama
Terminal app: iTerm2 / Windows Terminal / GNOME Terminal / VS Code terminal
Shell: bash / zsh / fish / PowerShell
-->
## Logs, screenshots, or recordings
<!-- Add logs, screenshots, config snippets, a short screen recording, or whether this also happens with --no-alt-screen. -->
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: true
+29
View File
@@ -0,0 +1,29 @@
---
name: Feature request
about: Suggest an idea or improvement
labels: enhancement
---
## Problem
<!-- What problem are you trying to solve? Who is affected? -->
## Proposed solution
<!-- What should codewhale do differently? Include commands, UI behavior, or config shape if relevant. -->
## Use case
<!-- Describe when you would use this and why the current workflow is not enough. -->
## Alternatives considered
<!-- What workarounds or other approaches have you tried? -->
## Impact
<!-- How often would you use this, and how much would it improve your workflow? -->
## Additional context
<!-- Add screenshots, mockups, examples from other tools, links, or config snippets if helpful. -->
+14
View File
@@ -0,0 +1,14 @@
## Summary
## Testing
- [ ] `cargo fmt --all -- --check`
- [ ] `cargo clippy --workspace --all-targets --all-features`
- [ ] `cargo test --workspace --all-features`
## Checklist
- [ ] Updated docs or comments as needed
- [ ] Added or updated tests where relevant
- [ ] Verified TUI behavior manually if UI changes
- [ ] Harvested/co-authored credit uses a GitHub numeric noreply address
+16
View File
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: cargo
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
- package-ecosystem: npm
directory: /web
schedule:
interval: monthly
open-pull-requests-limit: 3
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Update the Homebrew tap at Hmbown/homebrew-deepseek-tui after a release.
#
# Expected environment:
# TAG git tag, e.g. "v0.8.31"
# MANIFEST path to codewhale-artifacts-sha256.txt
# TAP_REPO owner/repo of the Homebrew tap
# TOKEN PAT with contents:write on TAP_REPO (optional; skips if unset)
set -euo pipefail
: "${TAG:?}"
: "${MANIFEST:?}"
: "${TAP_REPO:?}"
if [ -z "${TOKEN:-}" ]; then
echo "No Homebrew tap token configured; skipping."
exit 0
fi
VERSION="${TAG#v}"
die() { echo "::error::${1}" >&2; exit 1; }
sha() {
local file="${1:?}"
local val
val="$(awk -v f="${file}" '$2 == f {print $1; exit}' "${MANIFEST}")"
if [ -z "${val}" ]; then
die "Missing binary in checksum manifest: ${file}"
fi
echo "${val}"
}
# --- read checksums ---------------------------------------------------
# Canonical dispatcher and TUI
readonly SHA_COD_MACOS_ARM="$(sha codewhale-macos-arm64)"
readonly SHA_TUI_MACOS_ARM="$(sha codewhale-tui-macos-arm64)"
readonly SHA_COD_MACOS_X64="$(sha codewhale-macos-x64)"
readonly SHA_TUI_MACOS_X64="$(sha codewhale-tui-macos-x64)"
readonly SHA_COD_LINUX_ARM="$(sha codewhale-linux-arm64)"
readonly SHA_TUI_LINUX_ARM="$(sha codewhale-tui-linux-arm64)"
readonly SHA_COD_LINUX_X64="$(sha codewhale-linux-x64)"
readonly SHA_TUI_LINUX_X64="$(sha codewhale-tui-linux-x64)"
# --- temp dirs --------------------------------------------------------
FORMULA_FILE="$(mktemp)"
TAP_DIR="$(mktemp -d)"
trap 'rm -rf "${TAP_DIR}" "${FORMULA_FILE}"' EXIT
# --- generate formula --------------------------------------------------
readonly BASE_URL="https://github.com/Hmbown/CodeWhale/releases/download/${TAG}"
cat > "${FORMULA_FILE}" << EOF
class DeepseekTui < Formula
desc "Terminal-native coding agent for DeepSeek V4"
homepage "https://github.com/Hmbown/CodeWhale"
version "${VERSION}"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "${BASE_URL}/codewhale-macos-arm64", using: :nounzip
sha256 "${SHA_COD_MACOS_ARM}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-macos-arm64", using: :nounzip
sha256 "${SHA_TUI_MACOS_ARM}"
end
else
url "${BASE_URL}/codewhale-macos-x64", using: :nounzip
sha256 "${SHA_COD_MACOS_X64}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-macos-x64", using: :nounzip
sha256 "${SHA_TUI_MACOS_X64}"
end
end
end
on_linux do
if Hardware::CPU.arm?
url "${BASE_URL}/codewhale-linux-arm64", using: :nounzip
sha256 "${SHA_COD_LINUX_ARM}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-linux-arm64", using: :nounzip
sha256 "${SHA_TUI_LINUX_ARM}"
end
else
url "${BASE_URL}/codewhale-linux-x64", using: :nounzip
sha256 "${SHA_COD_LINUX_X64}"
resource "tui" do
url "${BASE_URL}/codewhale-tui-linux-x64", using: :nounzip
sha256 "${SHA_TUI_LINUX_X64}"
end
end
end
def install
bin.install Dir["*"].first => "codewhale"
resource("tui").stage { bin.install Dir["*"].first => "codewhale-tui" }
end
test do
system "#{bin}/codewhale", "--version"
end
end
EOF
# --- push to tap repo --------------------------------------------------
ENCODED_TOKEN="$(printf '%s' "${TOKEN}" | python3 -c 'import sys,urllib.parse;print(urllib.parse.quote(sys.stdin.read(),safe=""))')"
TAP_URL="https://x-access-token:${ENCODED_TOKEN}@github.com/${TAP_REPO}.git"
git clone --depth 1 "${TAP_URL}" "${TAP_DIR}"
mkdir -p "${TAP_DIR}/Formula"
cp "${FORMULA_FILE}" "${TAP_DIR}/Formula/deepseek-tui.rb"
cd "${TAP_DIR}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/deepseek-tui.rb
if git diff --cached --quiet; then
echo "Formula unchanged (already at ${VERSION}); nothing to push."
exit 0
fi
git commit -m "chore: bump formula to ${VERSION}
Automated update from the release workflow."
git push origin HEAD:main
echo "Pushed formula update to ${TAP_REPO} (v${VERSION})"
+218
View File
@@ -0,0 +1,218 @@
name: Approve gated contributor
on:
issue_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
concurrency:
group: contribution-gate-approval
cancel-in-progress: false
jobs:
approve:
runs-on: ubuntu-latest
steps:
- name: Open allowlist update PR
uses: actions/github-script@v9
with:
script: |
const comment = context.payload.comment;
const issue = context.payload.issue;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const command = (comment.body || '').trim().toLowerCase();
const scopeByCommand = new Map([
['/lgtm', 'pr'],
['lgtm', 'pr'],
['/lgtmi', 'issue'],
['lgtmi', 'issue'],
]);
const scope = scopeByCommand.get(command);
if (!scope) return;
if (!privileged.has(comment.author_association)) return;
if (scope === 'pr' && !issue.pull_request) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.',
});
return;
}
if (scope === 'issue' && issue.pull_request) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.',
});
return;
}
const path = '.github/APPROVED_CONTRIBUTORS';
const targetLogin = issue.user.login;
const normalizedLogin = targetLogin.toLowerCase();
const entry = `${scope}:${normalizedLogin}`;
const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor';
const defaultContent = [
'# Scoped contribution-gate allowlist.',
'#',
'# Maintainers and collaborators bypass the gate automatically. Use this file',
'# for external contributors who are allowed through the automated front door.',
'# Seed active contributors here before switching the gate workflows to enforce mode.',
'#',
'# Supported entries:',
'# pr:username',
'# issue:username',
'# all:username',
'',
].join('\n');
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
const { data: repoData } = await github.rest.repos.get({ owner, repo });
const defaultBranch = repoData.default_branch;
const { data: baseRef } = await github.rest.git.getRef({
owner,
repo,
ref: `heads/${defaultBranch}`,
});
const baseSha = baseRef.object.sha;
const { data: baseCommit } = await github.rest.git.getCommit({
owner,
repo,
commit_sha: baseSha,
});
let content = defaultContent;
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path,
ref: defaultBranch,
});
if (!Array.isArray(data) && data.type === 'file') {
content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8');
}
} catch (error) {
if (error.status !== 404) throw error;
}
const existing = parseAllowlist(content);
if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`,
});
return;
}
const openPrs = [];
for (let page = 1; ; page++) {
const { data: pagePrs } = await github.rest.pulls.list({
owner,
repo,
state: 'open',
per_page: 100,
page,
});
openPrs.push(...pagePrs);
if (pagePrs.length < 100) break;
}
const repoFullName = `${owner}/${repo}`.toLowerCase();
const pendingPr = openPrs.find(openPr => {
const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName;
const body = openPr.body || '';
return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`);
});
if (pendingPr) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`,
});
return;
}
const nextContent = `${content.trimEnd()}\n${entry}\n`;
const { data: blob } = await github.rest.git.createBlob({
owner,
repo,
content: nextContent,
encoding: 'utf-8',
});
const { data: tree } = await github.rest.git.createTree({
owner,
repo,
base_tree: baseCommit.tree.sha,
tree: [
{
path,
mode: '100644',
type: 'blob',
sha: blob.sha,
},
],
});
const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`;
await github.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: baseSha,
});
const { data: commit } = await github.rest.git.createCommit({
owner,
repo,
message: `chore: approve @${targetLogin} for ${scope} contributions`,
tree: tree.sha,
parents: [baseSha],
});
await github.rest.git.updateRef({
owner,
repo,
ref: `heads/${branchName}`,
sha: commit.sha,
});
const { data: pr } = await github.rest.pulls.create({
owner,
repo,
title: `chore: approve @${targetLogin} for ${scope} contributions`,
head: branchName,
base: defaultBranch,
body: [
`Adds \`${entry}\` to \`${path}\`.`,
'',
`Requested by @${comment.user.login} in #${issue.number}.`,
].join('\n'),
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `Created allowlist update PR: ${pr.html_url}`,
});
+153
View File
@@ -0,0 +1,153 @@
name: Auto-close harvested PRs
# When a commit on main contains a "Harvested from PR #N" line in its
# message, close PR #N with a templated thank-you that links back to
# the merged commit. Solves the long-standing problem where contributor
# PRs whose code lands via maintainer cherry-pick stay open and
# `CONFLICTING` forever, even though their fix is credited in the
# CHANGELOG.
#
# The expected commit-message convention is documented in
# CONTRIBUTING.md. Two patterns are recognised:
#
# * `Harvested from PR #1234 by @username` (preferred)
# * `harvested from #1234` (case-insensitive fallback)
#
# The first match's PR number is closed; multiple PRs can be closed
# per commit by repeating the line. The match runs on the commit
# body only, not on the subject line, so the subject can describe
# the change naturally without baking a number into it.
on:
push:
branches: [main]
permissions:
contents: read
pull-requests: write
issues: write
# Only one auto-close run at a time so two near-simultaneous main
# pushes can't both try to close the same PR (the second would just
# fail with "Pull request is already closed", harmless but noisy).
concurrency:
group: auto-close-harvested
cancel-in-progress: false
jobs:
close:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
# We need at least the commits that this push introduced.
# fetch-depth: 0 is the simplest correct option; the
# alternative (fetching just `before..after`) is fragile
# when force-pushes happen.
fetch-depth: 0
- name: Close PRs referenced by harvested-from lines
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
shell: bash
run: |
set -euo pipefail
# The first push to a fresh branch has BEFORE_SHA = 0000…0000.
# In that case fall back to the latest commit only — we don't
# want to scan the entire history.
if [[ "${BEFORE_SHA}" == "0000000000000000000000000000000000000000" || -z "${BEFORE_SHA:-}" ]]; then
RANGE="${AFTER_SHA}"
RANGE_ARGS=("-1" "${AFTER_SHA}")
else
RANGE="${BEFORE_SHA}..${AFTER_SHA}"
RANGE_ARGS=("${RANGE}")
fi
echo "Scanning commit range: ${RANGE}"
# `git log --format=%H%n%B%n--END--` separates commits with a
# sentinel so multi-line bodies don't get mangled.
mapfile -t commits < <(git log "${RANGE_ARGS[@]}" --format="%H")
if [[ ${#commits[@]} -eq 0 ]]; then
echo "No commits in range; nothing to do."
exit 0
fi
declare -A processed_prs=()
for sha in "${commits[@]}"; do
body="$(git log -1 --format=%B "${sha}")"
# Two patterns, both case-insensitive on the keyword:
# "Harvested from PR #1234 by @username" (preferred form)
# "harvested from #1234" (short form)
mapfile -t pr_numbers < <(
printf '%s\n' "${body}" \
| grep -oiE 'harvested from (pr )?#[0-9]+' \
| grep -oE '#[0-9]+' \
| tr -d '#' \
| sort -u || true
)
if [[ ${#pr_numbers[@]} -eq 0 ]]; then
continue
fi
short_sha="${sha:0:12}"
subject="$(git log -1 --format=%s "${sha}")"
for pr in "${pr_numbers[@]}"; do
key="${pr}-${sha}"
if [[ -n "${processed_prs[${key}]:-}" ]]; then
continue
fi
processed_prs[${key}]=1
# Idempotency: skip if the PR is already closed.
state="$(gh pr view "${pr}" --json state --jq .state 2>/dev/null || echo "MISSING")"
if [[ "${state}" == "CLOSED" || "${state}" == "MERGED" ]]; then
echo "PR #${pr} is already ${state}; skipping."
continue
fi
if [[ "${state}" == "MISSING" ]]; then
echo "::warning::PR #${pr} not found or inaccessible; skipping."
continue
fi
author="$(gh pr view "${pr}" --json author --jq '.author.login' 2>/dev/null || echo "")"
greeting="Hi"
if [[ -n "${author}" ]]; then
greeting="Thanks @${author}"
fi
# NOTE: this block intentionally avoids `<<EOF` heredocs.
# YAML's `|` block scalar requires consistent indentation,
# but heredoc bodies have to start at column 0 — those two
# constraints can't coexist in the same file. We assemble
# the body with `printf` + `\n` so every line of the
# message lives at the same indent as the surrounding
# shell code.
commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${sha}"
contributing_url="https://github.com/${GITHUB_REPOSITORY}/blob/main/CONTRIBUTING.md"
body_text="$(printf '%s\n' \
"${greeting} — your contribution landed in [\`${short_sha}\`](${commit_url}) on \`main\`:" \
"" \
"> ${subject}" \
"" \
"Closing this PR now that the code is on \`main\`. Credit lives in the commit message and (where applicable) the \`CHANGELOG.md\` entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31." \
"" \
"If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the [\`CONTRIBUTING.md\`](${contributing_url}) doc has a short note on what makes a contribution mergeable as-is." \
)"
echo "Closing PR #${pr} (harvested in ${short_sha})"
if ! gh pr close "${pr}" \
--repo "${GITHUB_REPOSITORY}" \
--comment "${body_text}"; then
echo "::warning::Failed to close PR #${pr}; continuing"
fi
done
done
echo "Auto-close pass complete."
+129
View File
@@ -0,0 +1,129 @@
name: Create release tag
# Manual helper that creates `vX.Y.Z` from the current `main` workspace
# version after the release source is frozen. This is intentionally not
# triggered by every version bump on `main`: release prep can land before the
# same-version issue/PR queue is truly empty, and an eager tag leaves later
# same-version work outside the public release anchor.
#
# IMPORTANT: tag pushes signed by the default `GITHUB_TOKEN` do NOT trigger
# downstream `on: push: tags` workflows (GitHub Actions safety rule). For
# this auto-tag flow to actually fire `release.yml`, store a PAT (or
# fine-grained token) with `contents: write` on this repo as the
# `RELEASE_TAG_PAT` secret. Without it, the tag is created but `release.yml`
# does NOT run automatically; run `release.yml` manually for the version.
on:
workflow_dispatch:
permissions:
contents: write
concurrency:
group: auto-tag-${{ github.ref_name }}
cancel-in-progress: false
jobs:
tag:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# Prefer PAT so the resulting tag push triggers release.yml.
# Falls back to GITHUB_TOKEN, which will tag but NOT trigger.
token: ${{ secrets.RELEASE_TAG_PAT || github.token }}
- name: Require main dispatch
run: |
if [ "${GITHUB_REF_NAME}" != "main" ]; then
echo "::error::Create release tags from main only; rerun this workflow with ref 'main'." >&2
exit 1
fi
- name: Read workspace version
id: ver
run: |
v="$(grep -E '^version = "' Cargo.toml | head -n1 | sed -E 's/^version = "([^"]+)".*/\1/')"
if [ -z "$v" ]; then
echo "::error::Could not parse workspace version from Cargo.toml" >&2
exit 1
fi
if ! echo "$v" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::Workspace version '$v' is not valid semver (expected X.Y.Z)" >&2
exit 1
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "tag=v$v" >> "$GITHUB_OUTPUT"
echo "Workspace version: $v"
- name: Check whether tag already exists
id: check
env:
TAG: ${{ steps.ver.outputs.tag }}
run: |
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
|| git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists; nothing to do."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} does not exist; will create."
fi
- name: Verify version consistency
if: steps.check.outputs.exists == 'false'
run: |
./scripts/release/check-versions.sh || {
echo "::error::Version consistency check failed. Aborting tag creation." >&2
exit 1
}
- name: Create and push tag
id: create
if: steps.check.outputs.exists == 'false'
env:
TAG: ${{ steps.ver.outputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch --tags --quiet
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null \
|| git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "pushed=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists after refresh; nothing to do."
exit 0
fi
git tag "${TAG}"
max_retries=3
retry_count=0
while [ "${retry_count}" -lt "${max_retries}" ]; do
if git push origin "${TAG}"; then
echo "pushed=true" >> "$GITHUB_OUTPUT"
echo "Pushed ${TAG}. release.yml should now run (requires RELEASE_TAG_PAT for trigger)."
exit 0
fi
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
echo "pushed=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} appeared during push; treating as already handled."
exit 0
fi
retry_count=$((retry_count + 1))
if [ "${retry_count}" -lt "${max_retries}" ]; then
echo "Push attempt ${retry_count} failed; retrying in 10s..."
sleep 10
fi
done
echo "::error::Failed to push tag ${TAG} after ${max_retries} attempts." >&2
exit 1
- name: Warn if PAT missing
if: steps.create.outputs.pushed == 'true'
env:
HAS_PAT: ${{ secrets.RELEASE_TAG_PAT != '' }}
run: |
if [ "${HAS_PAT}" != "true" ]; then
echo "::warning::RELEASE_TAG_PAT secret is not set. The tag was pushed using GITHUB_TOKEN, which does NOT trigger release.yml. Run 'gh workflow run release.yml --ref main -f version=${{ steps.ver.outputs.version }}' after confirming the tag points at the intended main commit."
fi
+36
View File
@@ -0,0 +1,36 @@
name: cargo-deny
permissions: {}
on:
pull_request:
paths:
- '.github/workflows/cargo-deny.yml'
- '**/Cargo.toml'
- '**/Cargo.lock'
- 'deny.toml'
push:
branches: [main, master]
schedule:
# Run weekly on Monday at 06:31 UTC
- cron: '31 6 * * 1'
env:
CARGO_TERM_COLOR: always
jobs:
deny:
name: cargo-deny
runs-on: ubuntu-latest
strategy:
matrix:
checks:
- advisories
- bans licenses sources
# Prevent sudden announcement of a new advisory from failing CI
continue-on-error: ${{ matrix.checks == 'advisories' }}
steps:
- uses: actions/checkout@v7
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check ${{ matrix.checks }}
+470
View File
@@ -0,0 +1,470 @@
name: CI
on:
push:
branches: [master, main]
pull_request:
branches: [master, main]
schedule:
- cron: '31 6 * * 1'
permissions:
contents: read
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
jobs:
changes:
name: Change detection
runs-on: ubuntu-latest
outputs:
heavy: ${{ steps.detect.outputs.heavy }}
workflow: ${{ steps.detect.outputs.workflow }}
mobile: ${{ steps.detect.outputs.mobile }}
actions: ${{ steps.detect.outputs.actions }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Detect executable changes
id: detect
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
BASE_REF: ${{ github.base_ref }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "schedule" || "${EVENT_NAME}" == "workflow_dispatch" ]]; then
echo "heavy=true" >> "${GITHUB_OUTPUT}"
echo "workflow=true" >> "${GITHUB_OUTPUT}"
echo "mobile=true" >> "${GITHUB_OUTPUT}"
echo "actions=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
base=""
if [[ "${EVENT_NAME}" == "pull_request" && -n "${BASE_REF}" ]]; then
git fetch --no-tags origin "${BASE_REF}:refs/remotes/origin/${BASE_REF}" --depth=1
base="origin/${BASE_REF}"
elif [[ -n "${BEFORE_SHA}" && "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]]; then
base="${BEFORE_SHA}"
fi
if [[ -z "${base}" ]]; then
echo "heavy=true" >> "${GITHUB_OUTPUT}"
echo "workflow=true" >> "${GITHUB_OUTPUT}"
echo "mobile=true" >> "${GITHUB_OUTPUT}"
echo "actions=true" >> "${GITHUB_OUTPUT}"
exit 0
fi
mapfile -t changed < <(git diff --name-only "${base}" "${GITHUB_SHA}" | sort)
heavy=false
workflow=false
mobile=false
actions=false
for path in "${changed[@]}"; do
# Heavy classification. ORDER MATTERS: must-stay-heavy inputs are
# matched BEFORE any light entry so a script that only a
# heavy-gated job exercises can never be misclassified as light.
# Anything unrecognized falls through to the default-heavy `*)`
# arm (fail-safe default-heavy). Light-classified scripts below
# are either never run by CI (v0867-setup-qa.sh) or exercised by
# ALWAYS-on jobs/steps that run regardless of `heavy`
# (check-versions.sh / check-ohos-deps.sh via Version drift,
# check-coauthor-trailers.py via Lint), so no coverage is lost.
case "${path}" in
scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py)
heavy=true
;;
docs/*|*.md|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/v0867-setup-qa.sh|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/check-coauthor-trailers.py)
;;
*)
heavy=true
;;
esac
case "${path}" in
crates/workflow/*|workflows/rlm_cache_change.star|.github/workflows/ci.yml)
workflow=true
;;
esac
# Mobile runtime surface: the `codewhale-tui serve --mobile`
# HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull
# requests run the smoke only when one of these changes; every
# push to main still runs it unconditionally as the pre-release
# safety net for anything this filter misses.
case "${path}" in
crates/app-server/*|crates/tui/src/runtime_api*|crates/tui/src/runtime_mobile.html|crates/tui/src/runtime_threads*|crates/tui/src/main.rs|scripts/mobile-smoke.sh|.github/workflows/ci.yml|Cargo.lock|Cargo.toml)
mobile=true
;;
esac
case "${path}" in
.github/workflows/*|.github/actionlint.yml)
actions=true
;;
esac
done
echo "heavy=${heavy}" >> "${GITHUB_OUTPUT}"
echo "workflow=${workflow}" >> "${GITHUB_OUTPUT}"
echo "mobile=${mobile}" >> "${GITHUB_OUTPUT}"
echo "actions=${actions}" >> "${GITHUB_OUTPUT}"
echo "Heavy Rust CI required: ${heavy}"
echo "Workflow RLM cache CI required: ${workflow}"
echo "Mobile runtime smoke required (PRs): ${mobile}"
echo "Workflow lint required: ${actions}"
printf 'Changed files:\n'
printf ' %s\n' "${changed[@]}"
versions:
name: Version drift
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v6
with:
node-version: 20
- name: Check version drift
run: ./scripts/release/check-versions.sh
- name: Check OHOS dependency graph
run: ./scripts/release/check-ohos-deps.sh
lint:
name: Lint
needs: changes
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@master
if: needs.changes.outputs.heavy == 'true'
with:
toolchain: stable
components: rustfmt, clippy
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
# Cache bootstrap failures (e.g. GitHub 504s fetching the sccache
# binary) degrade to an uncached build instead of failing product CI.
continue-on-error: true
if: needs.changes.outputs.heavy == 'true'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
if: needs.changes.outputs.heavy == 'true'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true'
with:
cache-bin: false
# PRs restore the cache seeded by main but skip the expensive
# post-job save; sccache covers PR-specific compilation deltas.
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting
if: needs.changes.outputs.heavy == 'true'
run: cargo fmt --all -- --check
- name: Run clippy
if: needs.changes.outputs.heavy == 'true'
run: |
cargo clippy --workspace --all-features --locked -- \
-D warnings \
-A clippy::uninlined_format_args \
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or \
-A clippy::collapsible_if \
-A clippy::assertions_on_constants
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
- name: Check provider registry drift
if: needs.changes.outputs.heavy == 'true'
run: python3 scripts/check-provider-registry.py
- name: Check README translations stay in sync
if: github.event_name != 'schedule'
run: python3 scripts/check-readme-translations.py
- name: Check harvested contributor credit
if: github.event_name != 'schedule'
shell: bash
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
git fetch --no-tags origin "${{ github.base_ref }}"
RANGE="origin/${{ github.base_ref }}..HEAD"
elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then
RANGE="${{ github.event.before }}..${{ github.sha }}"
else
RANGE="HEAD~1..HEAD"
fi
python3 scripts/check-coauthor-trailers.py \
--author-map .github/AUTHOR_MAP \
--range "$RANGE" \
--check-authors
- name: Skip Rust lint for light change
if: needs.changes.outputs.heavy != 'true'
run: echo "No executable Rust changes detected; preserving required Lint context."
- name: Linux clippy location
if: needs.changes.outputs.heavy == 'true'
run: echo "Linux clippy/test gates run on CNB for mirrored fix/*, rebrand/*, work/v*, and main branches."
workflow-rlm-cache:
name: Workflow RLM cache
needs: changes
if: needs.changes.outputs.workflow == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run RLM cache workflow mock/replay tests
run: cargo test -p codewhale-workflow --locked rlm_cache_change
test:
name: Test
needs: changes
# Required contexts "Test (ubuntu-latest)" / "Test (macos-latest)" /
# "Test (windows-latest)" derive from job name + matrix.os and are
# independent of runs-on. For light changes the macOS/Windows legs only
# echo a skip line, so run them on ubuntu instead of queueing for scarce
# macOS/Windows runners. Heavy changes use the real matrix OS as before.
# The ternary is safe: matrix.os is always a non-empty literal, so
# runs-on can never evaluate to empty.
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
strategy:
matrix:
# Linux workspace tests moved to CNB; GitHub keeps the platform
# coverage CNB cannot provide.
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- name: Skip tests for light change
if: needs.changes.outputs.heavy != 'true'
run: echo "No executable Rust changes detected; preserving required Test context."
- uses: actions/checkout@v7
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run tests
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
run: cargo test --workspace --all-features --locked
- name: Lockfile drift guard
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
run: git diff --exit-code -- Cargo.lock
- name: Run Offline Eval Harness
# The eval harness is OS-independent prompt/composition checking;
# running it once (on the faster macOS leg, warm from the test build)
# instead of once per desktop OS keeps the coverage while taking
# ~2min off the Windows critical path.
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
run: cargo run -p codewhale-tui --all-features -- eval
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
- name: Linux test location
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest'
run: echo "Linux workspace tests run on CNB for mirrored first-party branches."
npm-wrapper-smoke:
name: npm wrapper smoke
needs: changes
if: github.event_name != 'schedule'
# Same ternary rationale as the Test job: light legs only echo, so keep
# them off macOS/Windows runners. On pull_request the matrix is
# ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)"
# context is unaffected.
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
strategy:
matrix:
os: ${{ fromJSON(github.event_name == 'pull_request' && '["ubuntu-latest"]' || '["ubuntu-latest","macos-latest","windows-latest"]') }}
steps:
- name: Skip npm wrapper smoke for light change
if: needs.changes.outputs.heavy != 'true'
run: echo "No executable Rust changes detected; preserving required npm wrapper smoke context."
- uses: actions/checkout@v7
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
- name: Enable sccache
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: actions/setup-node@v6
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
with:
node-version: 20
- uses: Swatinem/rust-cache@v2
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Build wrapper binaries
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
# The smoke validates wrapper install/delegation plumbing, not
# codegen quality, so skip fat LTO + codegen-units=1 for a much
# cheaper release build. Shipped binaries keep the real profile via
# the Release workflow.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: cargo build --release --locked -p codewhale-cli -p codewhale-tui
- name: Smoke wrapper install and delegated entrypoints
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest'
run: node scripts/release/npm-wrapper-smoke.js
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'ubuntu-latest' && steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
- name: Linux smoke location
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest'
run: echo "Linux npm wrapper smoke runs on CNB for mirrored first-party branches."
mobile-smoke:
name: Mobile runtime smoke
needs: changes
# Not a required PR context. Pull requests run it only when the mobile
# runtime surface changed (see the `mobile` filter above); every push to
# main runs it unconditionally as the pre-release safety net.
if: >-
github.event_name != 'schedule' &&
needs.changes.outputs.heavy == 'true' &&
(github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Run mobile smoke tests
# The smoke exercises HTTP/SSE runtime behaviour, not codegen
# quality; skipping fat LTO + codegen-units=1 cuts the in-script
# release build from ~12min to a fraction of that.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: ./scripts/mobile-smoke.sh
- name: sccache stats
if: steps.sccache.outcome == 'success'
continue-on-error: true
shell: bash
run: sccache --show-stats
actionlint:
name: Workflow lint
needs: changes
if: needs.changes.outputs.actions == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Run actionlint
uses: docker://rhysd/actionlint:1.7.7
with:
# SC2129 (grouped redirects) is style-only and endemic to the
# existing GITHUB_ENV/GITHUB_OUTPUT append pattern; SC2221/SC2222
# flag the long-standing `*.md` glob shadowing the PR-template
# entry in change detection, which is intentional.
args: -color -ignore SC2129 -ignore SC2221 -ignore SC2222
# Check documentation builds without warnings
docs:
name: Documentation
if: github.event_name == 'schedule'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Build docs
run: cargo doc --workspace --no-deps
env:
RUSTDOCFLAGS: -Dwarnings
+81
View File
@@ -0,0 +1,81 @@
name: Claude PR Review
# Advisory AI code review by Claude (anthropics/claude-code-action) on every
# non-draft PR. CODEOWNERS (@Hmbown) stays the human owner — this review posts
# alongside it, it does not replace approval.
#
# Setup: add a CLAUDE_CODE_OAUTH_TOKEN repository secret
# 1. Run `claude setup-token` locally (Pro/Max subscription) to mint a token.
# 2. Settings -> Secrets and variables -> Actions -> New repository secret.
# Until the secret exists the job no-ops with a notice (stays green), so this
# workflow is safe to merge before the token is configured.
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
branches: [master, main]
concurrency:
group: claude-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
claude-review:
name: Claude review
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
env:
HAS_OAUTH: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN != '' }}
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Skip when token is unset
if: env.HAS_OAUTH != 'true'
run: echo "::notice::CLAUDE_CODE_OAUTH_TOKEN is not set — skipping Claude review. Add the secret to enable it."
- name: Checkout repository
if: env.HAS_OAUTH == 'true'
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Claude code review
if: env.HAS_OAUTH == 'true'
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
track_progress: true
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
You are reviewing a pull request against CodeWhale, a Rust workspace
(an agentic coding TUI/runtime). The PR branch is already checked out
in the current working directory.
Review the diff and report findings in this priority order:
1. Correctness bugs: logic errors, panics, unwrap/expect on fallible
paths, race conditions, incorrect error handling, off-by-one, and
non-exhaustive matches that could break compilation.
2. Provider/model/route safety (v0.8.65 EPIC #2608 invariant): a
provider-prefixed model string (e.g. `deepseek-ai/`, `deepseek/`,
`anthropic/`, `openai/`, `qwen/`) is a wire id or namespace hint,
never proof of provider selection. Flag any code that infers a
provider/model switch from such a prefix or from freeform prompt
text rather than from explicit user choice, config, Fleet policy,
capability requirements, or fallback policy.
3. Reuse and simplification: duplicated logic, dead code, needless
allocation/cloning, or reimplementing something the workspace
already provides.
4. Tests: missing coverage for new behavior and edge cases.
5. Security: secret handling, shell/exec policy, input validation.
Be specific and concise. Use inline comments for line-specific issues
and one top-level comment for the summary. Note genuinely good choices
briefly. Do not nitpick style that `cargo fmt` / `clippy` already
enforce.
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(git log:*),Bash(git diff:*)"
+56
View File
@@ -0,0 +1,56 @@
name: DCO
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write
jobs:
dco:
name: Check Signed-off-by
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
repository: ${{ github.event.pull_request.head.repo.full_name }}
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: Report missing Signed-off-by (dry-run advisory)
shell: bash
run: |
set -euo pipefail
base_rev=$(git merge-base origin/${{ github.base_ref }} HEAD 2>/dev/null || echo "HEAD~1")
echo "✅ merge-base: $base_rev"
echo ""
bad=()
while IFS= read -r commit; do
msg=$(git log --format=%B -n1 "$commit")
if ! echo "$msg" | grep -qi "^Signed-off-by:"; then
bad+=("$commit")
fi
done < <(git rev-list "${base_rev}..HEAD")
if [ ${#bad[@]} -eq 0 ]; then
echo "✅ All commits have Signed-off-by."
exit 0
fi
echo "⚠️ Advisory — the following commit(s) are missing Signed-off-by:"
for c in "${bad[@]}"; do
echo " • $c $(git log --format=%s -n1 "$c")"
done
echo ""
echo "This check is advisory and does not block merging."
echo "Please amend commits with:"
echo " git commit --amend -s"
echo "See CONTRIBUTING.md for details."
echo ""
echo "::warning title=DCO: missing Signed-off-by::Some commits are missing Signed-off-by — amend with git commit --amend -s"
# Dry-run: always pass, but surface the warning
exit 0
+84
View File
@@ -0,0 +1,84 @@
name: Contribution intake - issues
on:
issues:
types: [opened, reopened]
permissions:
contents: read
issues: write
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Welcome new external issue reporters
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
if (privileged.has(issue.author_association)) return;
if (issue.user.login === 'github-actions[bot]') return;
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
async function readAllowlist() {
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path: '.github/APPROVED_CONTRIBUTORS',
ref: context.payload.repository.default_branch,
});
if (Array.isArray(data) || data.type !== 'file') return new Set();
return parseAllowlist(
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
);
} catch (error) {
if (error.status === 404) return new Set();
throw error;
}
}
const allowlist = await readAllowlist();
const login = issue.user.login.toLowerCase();
if (
allowlist.has(`all:${login}`) ||
allowlist.has(`issue:${login}`)
) {
return;
}
const marker = '<!-- codewhale-issue-intake -->';
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issue.number,
per_page: 100,
});
if (comments.some(comment => (comment.body || '').includes(marker))) return;
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: [
marker,
`Thanks @${issue.user.login} for the report.`,
'',
'This issue is staying open for maintainer triage. CodeWhale gets better because people bring us real edge cases from real machines, providers, regions, and workflows.',
'',
'If you can add a reproduction, logs, version output, screenshots, or the provider/model involved, that makes it much easier for us to verify and harvest the fix. Maintainers may comment `/lgtmi` to mark recurring issue reporters as approved so this intake note is skipped next time.',
].join('\n'),
});
+159
View File
@@ -0,0 +1,159 @@
name: Nightly
# Scheduled nightly artifact build. This used to run on every push to main,
# rebuilding five release targets per merge; PR CI already builds/tests the
# same commits on macOS/Windows and CNB covers Linux, so a daily cadence
# keeps the artifact stream fresh without burning ~28 runner-minutes per
# merge. Use workflow_dispatch for an on-demand build.
on:
schedule:
- cron: '17 9 * * *'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: nightly-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
DEEPSEEK_BUILD_SHA: ${{ github.sha }}
jobs:
build:
name: Build ${{ matrix.platform }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
platform: linux-x64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-x64
tui_artifact: codewhale-tui-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
platform: linux-arm64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-arm64
tui_artifact: codewhale-tui-linux-arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos-x64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-x64
tui_artifact: codewhale-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos-arm64
cli_binary: codewhale
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-arm64
tui_artifact: codewhale-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x64
cli_binary: codewhale.exe
tui_binary: codewhale-tui.exe
cli_artifact: codewhale-windows-x64.exe
tui_artifact: codewhale-tui-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Install Rust target
run: rustup target add --toolchain stable ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Build
shell: bash
# Nightly artifacts are disposable smoke binaries (14-day retention),
# so skip fat LTO + codegen-units=1; tagged releases keep the full
# optimized profile via the Release workflow.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: |
for attempt in 1 2 3; do
if cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli -p codewhale-tui; then
exit 0
fi
if [ "${attempt}" -lt 3 ]; then
echo "Build attempt ${attempt} failed; retrying in 30s..."
sleep 30
fi
done
echo "Build failed after 3 attempts" >&2
exit 1
- name: Stage artifact
id: stage
shell: bash
run: |
short_sha="${GITHUB_SHA::12}"
stage_one() {
local binary="$1"
local artifact="$2"
local dir="$3"
local bin_path="target/${{ matrix.target }}/release/${binary}"
if [ ! -f "${bin_path}" ]; then
echo "Binary not at ${bin_path}; searching target/ for ${binary}:"
find target -name "${binary}" -type f
exit 1
fi
mkdir -p "${dir}"
cp "${bin_path}" "${dir}/${artifact}"
cat > "${dir}/nightly-build-info.txt" <<INFO
repository=${GITHUB_REPOSITORY}
ref=${GITHUB_REF_NAME}
commit=${GITHUB_SHA}
artifact=${artifact}
profile=release-nightly-no-lto
INFO
}
stage_one "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}" nightly-cli
stage_one "${{ matrix.tui_binary }}" "${{ matrix.tui_artifact }}" nightly-tui
echo "cli_name=${{ matrix.cli_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
echo "tui_name=${{ matrix.tui_artifact }}-${short_sha}" >> "${GITHUB_OUTPUT}"
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.stage.outputs.cli_name }}
path: nightly-cli/*
retention-days: 14
- uses: actions/upload-artifact@v7
with:
name: ${{ steps.stage.outputs.tui_name }}
path: nightly-tui/*
retention-days: 14
+110
View File
@@ -0,0 +1,110 @@
name: Contribution gate - pull requests
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
issues: write
pull-requests: write
env:
# Keep new gates observable first. Switch to "enforce" only after maintainers
# have seeded active contributors and reviewed the dry-run signal.
CONTRIBUTION_GATE_MODE: dry-run
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Gate unapproved external pull requests
uses: actions/github-script@v9
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase();
const enforceGate = gateMode === 'enforce';
if (!['dry-run', 'enforce'].includes(gateMode)) {
core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`);
}
if (privileged.has(pr.author_association)) return;
if (pr.user.login === 'github-actions[bot]') return;
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
async function readAllowlist() {
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path: '.github/APPROVED_CONTRIBUTORS',
ref: context.payload.repository.default_branch,
});
if (Array.isArray(data) || data.type !== 'file') return new Set();
return parseAllowlist(
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
);
} catch (error) {
if (error.status === 404) return new Set();
throw error;
}
}
const allowlist = await readAllowlist();
const login = pr.user.login.toLowerCase();
if (
allowlist.has(`all:${login}`) ||
allowlist.has(`pr:${login}`)
) {
return;
}
const gateMessage = enforceGate
? 'This repository currently limits automated PR intake to contributors listed in `.github/APPROVED_CONTRIBUTORS`. This is a maintainer-safety control for code review and CI load, not a judgment on the contribution. A maintainer can grant recurring PR access with `/lgtm` after review; once the generated allowlist PR is merged, this pull request can be reopened or resubmitted.'
: 'This repository is observing a maintainer-managed PR intake gate in dry-run mode, so this pull request is staying open. This note helps maintainers prepare the allowlist before any enforcement is considered.';
const marker = '<!-- codewhale-pr-gate -->';
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: pr.number,
per_page: 100,
});
const alreadyNoted = comments.some(comment => (comment.body || '').includes(marker));
if (!alreadyNoted) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: [
marker,
`Thanks @${pr.user.login} for taking the time to contribute.`,
'',
gateMessage,
'',
'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant recurring PR access by commenting `/lgtm` on a pull request.',
].join('\n'),
});
}
if (!enforceGate) return;
await github.rest.pulls.update({
owner,
repo,
pull_number: pr.number,
state: 'closed',
});
+652
View File
@@ -0,0 +1,652 @@
name: Release
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
version:
description: 'Package/release version to publish to npm, without the leading v'
required: true
type: string
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
jobs:
parity:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
components: clippy, rustfmt
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
# A cache bootstrap failure must degrade to an uncached build, never
# fail a release gate.
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
if: runner.os == 'Linux'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Format check
run: cargo fmt --all -- --check
- name: Compile check
run: cargo check --workspace --all-targets --locked
- name: OHOS dependency graph
run: ./scripts/release/check-ohos-deps.sh
- name: Clippy
run: |
cargo clippy --workspace --all-targets --all-features --locked -- \
-D warnings \
-A clippy::uninlined_format_args \
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or \
-A clippy::collapsible_if \
-A clippy::assertions_on_constants
- name: Workspace tests
run: cargo test --workspace --all-features --locked
- name: Protocol schema parity
run: cargo test -p codewhale-protocol --test parity_protocol --locked
- name: State persistence parity
run: cargo test -p codewhale-state --test parity_state --locked
- name: Lockfile drift guard
run: git diff --exit-code -- Cargo.lock
resolve:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
source_ref: ${{ steps.release.outputs.source_ref }}
sha: ${{ steps.release.outputs.sha }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve release source
id: release
shell: bash
run: |
if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then
tag="v${{ inputs.version }}"
if git rev-parse "refs/tags/${tag}" >/dev/null 2>&1; then
sha="$(git rev-list -n 1 "${tag}")"
source_ref="${tag}"
else
# Tag doesn't exist yet — build from HEAD
sha="${GITHUB_SHA}"
source_ref="${GITHUB_SHA}"
echo "Tag ${tag} not found; building from ${source_ref} @ ${sha}"
fi
else
tag="${GITHUB_REF_NAME}"
sha="${GITHUB_SHA}"
source_ref="${GITHUB_REF_NAME}"
fi
if [ -z "${sha}" ]; then
echo "Unable to resolve release source for ${tag}" >&2
exit 1
fi
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
echo "source_ref=${source_ref}" >> "$GITHUB_OUTPUT"
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
- name: Require release source on main
run: ./scripts/release/ensure-release-on-main.sh "${{ steps.release.outputs.sha }}"
build:
needs: [parity, resolve]
# `parity` is gated to tag-push events. On manual `workflow_dispatch`,
# parity is skipped, so let `build` proceed when resolve succeeded and
# parity either succeeded or was skipped — but never when parity actually
# failed or the run was cancelled. Operators using dispatch are expected
# to have already run the same gates locally / via ci.yml on `main`.
if: ${{ !cancelled() && needs.resolve.result == 'success' && (needs.parity.result == 'success' || needs.parity.result == 'skipped') }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
platform: linux-x64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-x64
shim_artifact: codew-linux-x64
tui_artifact: codewhale-tui-linux-x64
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
platform: linux-arm64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-linux-arm64
shim_artifact: codew-linux-arm64
tui_artifact: codewhale-tui-linux-arm64
- os: ubuntu-latest
target: aarch64-linux-android
platform: android-arm64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-android-arm64
shim_artifact: codew-android-arm64
tui_artifact: codewhale-tui-android-arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos-x64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-x64
shim_artifact: codew-macos-x64
tui_artifact: codewhale-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos-arm64
cli_binary: codewhale
shim_binary: codew
tui_binary: codewhale-tui
cli_artifact: codewhale-macos-arm64
shim_artifact: codew-macos-arm64
tui_artifact: codewhale-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x64
cli_binary: codewhale.exe
shim_binary: codew.exe
tui_binary: codewhale-tui.exe
cli_artifact: codewhale-windows-x64.exe
shim_artifact: codew-windows-x64.exe
tui_artifact: codewhale-tui-windows-x64.exe
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
targets: ${{ matrix.target }}
- name: Install Rust target
run: rustup target add --toolchain stable ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
# A cache bootstrap failure must degrade to an uncached build, never
# fail a release gate.
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Install Linux system dependencies
if: runner.os == 'Linux' && matrix.target == 'aarch64-unknown-linux-gnu'
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- name: Build static Linux x64 binary (musl)
if: matrix.target == 'x86_64-unknown-linux-musl'
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y musl-tools
rustup target add --toolchain stable x86_64-unknown-linux-musl
cargo build --release --locked --target x86_64-unknown-linux-musl -p codewhale-cli -p codewhale-tui
- name: Install AArch64 cross-compilation toolchain
if: matrix.target == 'aarch64-unknown-linux-gnu' && runner.os == 'Linux'
run: |
. /etc/os-release
sudo tee /etc/apt/sources.list.d/arm64.sources <<SRC
Types: deb
URIs: http://ports.ubuntu.com/ubuntu-ports/
Suites: ${UBUNTU_CODENAME} ${UBUNTU_CODENAME}-updates ${UBUNTU_CODENAME}-security
Components: main universe
Architectures: arm64
Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg
SRC
sudo dpkg --add-architecture arm64
sudo apt-get update -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/arm64.sources -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0
sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64 pkg-config
- name: Configure Android NDK linker
if: matrix.target == 'aarch64-linux-android' && runner.os == 'Linux'
shell: bash
env:
ANDROID_NDK_VERSION: 27.2.12479018
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install -y libclang-dev
ndk="${ANDROID_NDK_ROOT:-${ANDROID_NDK_HOME:-}}"
linker=""
if [[ -n "${ndk}" ]]; then
linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
fi
if [[ -z "${linker}" || ! -x "${linker}" ]]; then
if ! command -v sdkmanager >/dev/null 2>&1; then
echo "sdkmanager is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2
exit 1
fi
android_home="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-}}"
if [[ -z "${android_home}" ]]; then
echo "ANDROID_HOME or ANDROID_SDK_ROOT is required to install Android NDK ${ANDROID_NDK_VERSION}" >&2
exit 1
fi
yes | sdkmanager --licenses >/dev/null || true
sdkmanager --install "ndk;${ANDROID_NDK_VERSION}"
ndk="${android_home}/ndk/${ANDROID_NDK_VERSION}"
linker="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android24-clang"
fi
ar="${ndk}/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-ar"
if [[ ! -x "${linker}" ]]; then
echo "Android linker not found: ${linker}" >&2
exit 1
fi
if [[ ! -x "${ar}" ]]; then
echo "Android archiver not found: ${ar}" >&2
exit 1
fi
echo "ANDROID_NDK_ROOT=${ndk}" >> "${GITHUB_ENV}"
echo "ANDROID_NDK_HOME=${ndk}" >> "${GITHUB_ENV}"
echo "CC_aarch64_linux_android=${linker}" >> "${GITHUB_ENV}"
echo "AR_aarch64_linux_android=${ar}" >> "${GITHUB_ENV}"
echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=${linker}" >> "${GITHUB_ENV}"
echo "BINDGEN_EXTRA_CLANG_ARGS_aarch64_linux_android=--target=aarch64-linux-android24 --sysroot=${ndk}/toolchains/llvm/prebuilt/linux-x86_64/sysroot" >> "${GITHUB_ENV}"
- name: Build
if: matrix.target != 'x86_64-unknown-linux-musl'
shell: bash
env:
DEEPSEEK_BUILD_SHA: ${{ needs.resolve.outputs.sha }}
CC_aarch64_unknown_linux_gnu: aarch64-linux-gnu-gcc
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc
PKG_CONFIG_ALLOW_CROSS: 1
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu: /usr/lib/aarch64-linux-gnu/pkgconfig
run: cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli -p codewhale-tui
- name: Stage binaries
shell: bash
run: |
stage_binary() {
local binary="$1"
local artifact="$2"
local bin_path="target/${{ matrix.target }}/release/${binary}"
if [ ! -f "${bin_path}" ]; then
echo "Binary not at ${bin_path}; searching target/ for ${binary}:"
find target -name "${binary}" -type f
exit 1
fi
cp "${bin_path}" "${artifact}"
}
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}"
stage_binary "${{ matrix.shim_binary }}" "${{ matrix.shim_artifact }}"
stage_binary "${{ matrix.tui_binary }}" "${{ matrix.tui_artifact }}"
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.cli_artifact }}
path: ${{ matrix.cli_artifact }}
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.shim_artifact }}
path: ${{ matrix.shim_artifact }}
- uses: actions/upload-artifact@v7
with:
name: ${{ matrix.tui_artifact }}
path: ${{ matrix.tui_artifact }}
bundle:
needs: [build, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: '*'
- name: Create platform archives
shell: bash
run: |
set -euo pipefail
mkdir -p bundles/checksums
MANIFEST="bundles/checksums/codewhale-bundles-sha256.txt"
: > "$MANIFEST"
bundle() {
local platform="$1" # linux-x64, linux-arm64, android-arm64, macos-x64, macos-arm64, windows-x64
local cli_src="$2" # artifact name for codewhale binary
local shim_src="$3" # artifact name for codew shim
local tui_src="$4" # artifact name for codewhale-tui binary
local ext="$5" # tar.gz or zip
local variant="$6" # '' (standard) or 'portable' (Windows only, no install script)
shift 6
local dir="bundles/codewhale-${platform}${variant:+-}${variant}"
mkdir -p "$dir"
# Copy binaries, stripping platform suffixes
local cli_dst="codewhale"
local shim_dst="codew"
local tui_dst="codewhale-tui"
if [[ "$platform" == windows-* ]]; then
cli_dst="codewhale.exe"
shim_dst="codew.exe"
tui_dst="codewhale-tui.exe"
fi
cp "artifacts/${cli_src}/${cli_src}" "$dir/${cli_dst}"
cp "artifacts/${shim_src}/${shim_src}" "$dir/${shim_dst}"
cp "artifacts/${tui_src}/${tui_src}" "$dir/${tui_dst}"
# Add install script (standard variant only)
if [[ "$variant" != "portable" ]]; then
if [[ "$platform" == windows-* ]]; then
cp scripts/release/install.bat "$dir/"
# Convert line endings to CRLF for Windows
sed -i 's/$/\r/' "$dir/install.bat" 2>/dev/null || true
else
cp scripts/release/install.sh "$dir/"
chmod +x "$dir/install.sh"
fi
fi
if [[ "$ext" == "zip" ]]; then
(cd bundles && zip -r "codewhale-${platform}${variant:+-}${variant}.zip" "codewhale-${platform}${variant:+-}${variant}/")
else
tar -czf "bundles/codewhale-${platform}${variant:+-}${variant}.tar.gz" -C bundles "codewhale-${platform}${variant:+-}${variant}/"
fi
local archive="codewhale-${platform}${variant:+-}${variant}.${ext}"
sha256sum "bundles/${archive}" | awk '{printf "%s %s\n", $1, $2}' >> "$MANIFEST"
echo " Created bundles/${archive}"
}
# Platform: linux-x64
bundle linux-x64 \
codewhale-linux-x64 codew-linux-x64 codewhale-tui-linux-x64 tar.gz ""
# Platform: linux-arm64
bundle linux-arm64 \
codewhale-linux-arm64 codew-linux-arm64 codewhale-tui-linux-arm64 tar.gz ""
# Platform: android-arm64 (Termux)
bundle android-arm64 \
codewhale-android-arm64 codew-android-arm64 codewhale-tui-android-arm64 tar.gz ""
# Platform: macos-x64
bundle macos-x64 \
codewhale-macos-x64 codew-macos-x64 codewhale-tui-macos-x64 tar.gz ""
# Platform: macos-arm64
bundle macos-arm64 \
codewhale-macos-arm64 codew-macos-arm64 codewhale-tui-macos-arm64 tar.gz ""
# Platform: windows-x64 (standard + portable)
bundle windows-x64 \
codewhale-windows-x64.exe codew-windows-x64.exe codewhale-tui-windows-x64.exe zip ""
bundle windows-x64 \
codewhale-windows-x64.exe codew-windows-x64.exe codewhale-tui-windows-x64.exe zip "portable"
echo ""
echo "=== Archive checksums ==="
cat "$MANIFEST"
- name: Upload bundle artifacts
uses: actions/upload-artifact@v7
with:
name: codewhale-bundles
path: bundles/*
if-no-files-found: error
windows-installer:
needs: [build, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: '*windows-x64.exe'
- name: Install NSIS
shell: pwsh
run: choco install nsis -y --no-progress
- name: Build NSIS installer
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$version = "${{ needs.resolve.outputs.tag }}".TrimStart("v")
Copy-Item "artifacts\codewhale-windows-x64.exe\codewhale-windows-x64.exe" "scripts\installer\codewhale.exe"
Copy-Item "artifacts\codew-windows-x64.exe\codew-windows-x64.exe" "scripts\installer\codew.exe"
Copy-Item "artifacts\codewhale-tui-windows-x64.exe\codewhale-tui-windows-x64.exe" "scripts\installer\codewhale-tui.exe"
$makensis = "${env:ProgramFiles(x86)}\NSIS\makensis.exe"
if (!(Test-Path $makensis)) {
$makensis = "${env:ProgramFiles}\NSIS\makensis.exe"
}
if (!(Test-Path $makensis)) {
throw "makensis.exe not found after NSIS install"
}
Push-Location scripts\installer
& $makensis "/DVERSION=$version" "codewhale.nsi"
Pop-Location
if (!(Test-Path "scripts\installer\CodeWhaleSetup.exe")) {
throw "CodeWhaleSetup.exe was not produced"
}
- name: Upload installer artifact
uses: actions/upload-artifact@v7
with:
name: CodeWhaleSetup.exe
path: scripts/installer/CodeWhaleSetup.exe
if-no-files-found: error
docker:
needs: [build, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout release source
uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.source_ref }}
path: source
- name: Checkout release infrastructure
uses: actions/checkout@v7
with:
path: infra
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Normalize image name
id: image
shell: bash
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- name: Extract metadata
id: meta
uses: docker/metadata-action@v6
with:
images: |
${{ steps.image.outputs.name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern=v{{major}}
type=ref,event=tag
type=semver,pattern={{version}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=semver,pattern={{major}}.{{minor}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=semver,pattern=v{{major}},value=${{ needs.resolve.outputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=v${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v7
env:
# The build record is useful in CI, but it is uploaded as a
# `.dockerbuild` artifact. The release job intentionally downloads
# all binary artifacts, so suppress the extra record artifact there.
DOCKER_BUILD_RECORD_UPLOAD: false
DOCKER_BUILD_SUMMARY: false
with:
context: source
file: infra/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
build-args: |
DEEPSEEK_BUILD_SHA=${{ needs.resolve.outputs.sha }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
release:
needs: [build, bundle, windows-installer, docker, resolve]
if: ${{ !cancelled() && needs.build.result == 'success' && needs.bundle.result == 'success' && needs.windows-installer.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# Checked out into a subdirectory so it cannot clobber the downloaded
# artifacts; used for the release-body generator and the CHANGELOG.
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve.outputs.tag }}
path: repo
- uses: actions/download-artifact@v8
with:
path: artifacts
pattern: '*'
- name: Generate Windows npm launcher asset
shell: bash
run: |
set -euo pipefail
mkdir -p artifacts/codewhale-windows-launcher
{
printf '@echo off\r\n'
printf 'where wt >nul 2>nul\r\n'
printf 'set NO_ANIMATIONS=1\r\n'
printf 'if "%%ERRORLEVEL%%"=="0" (\r\n'
printf ' wt --title CodeWhale cmd /k "%%~dp0codewhale-windows-x64.exe"\r\n'
printf ') else (\r\n'
printf ' "%%~dp0codewhale-windows-x64.exe"\r\n'
printf ')\r\n'
printf '\r\n'
} > artifacts/codewhale-windows-launcher/codewhale.bat
- name: List artifacts
run: find artifacts -type f
- name: Generate checksum manifest
shell: bash
run: |
mkdir -p artifacts/checksums
# Canonical manifest used by codewhale's `codewhale update` flow.
manifest="artifacts/checksums/codewhale-artifacts-sha256.txt"
: > "${manifest}"
while IFS= read -r -d '' file; do
hash="$(sha256sum "${file}" | awk '{print $1}')"
base="$(basename "${file}")"
printf '%s %s\n' "${hash}" "${base}" >> "${manifest}"
done < <(find artifacts -type f ! -path 'artifacts/checksums/*' -print0 | sort -z)
cat "${manifest}"
- name: Generate release body from CHANGELOG
shell: bash
run: |
./repo/scripts/release/generate-release-body.sh \
"${{ needs.resolve.outputs.tag }}" repo/CHANGELOG.md > release-body.md
- uses: softprops/action-gh-release@v3
with:
tag_name: ${{ needs.resolve.outputs.tag }}
files: artifacts/*/*
prerelease: false
body_path: release-body.md
homebrew:
needs: [release, resolve]
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check Homebrew tap token
id: homebrew-token
env:
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
run: |
if [ -z "${TOKEN:-}" ]; then
echo "No Homebrew tap token configured; skipping tap update."
echo "available=false" >> "${GITHUB_OUTPUT}"
else
echo "available=true" >> "${GITHUB_OUTPUT}"
fi
# Checkout main (not the tag) so the release-infra script is always
# available, even for tags created before this workflow was added.
- uses: actions/checkout@v7
if: steps.homebrew-token.outputs.available == 'true'
with:
ref: main
- name: Download checksum manifest
if: steps.homebrew-token.outputs.available == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release download ${{ needs.resolve.outputs.tag }} \
--repo ${{ github.repository }} \
--pattern 'codewhale-artifacts-sha256.txt' \
--dir /tmp
- name: Update Homebrew tap
if: steps.homebrew-token.outputs.available == 'true'
env:
TAG: ${{ needs.resolve.outputs.tag }}
MANIFEST: /tmp/codewhale-artifacts-sha256.txt
TAP_REPO: Hmbown/homebrew-deepseek-tui
TOKEN: ${{ secrets.HOMEBREW_TAP_PAT || secrets.RELEASE_TAG_PAT }}
run: bash .github/scripts/update-homebrew-tap.sh
# npm publish is intentionally not automated. The npm account requires 2FA OTP
# on every publish, and a granular automation token that bypasses 2FA has not
# been provisioned. Release the npm wrapper manually from a developer machine
# after the GitHub Release has been created — see CLAUDE.md "Releases" for the
# exact commands.
+31
View File
@@ -0,0 +1,31 @@
name: Security audit
permissions: {}
on:
pull_request:
paths:
- '.github/workflows/security-audit.yml'
- '**/Cargo.toml'
- '**/Cargo.lock'
- '**/audit.toml'
push:
branches: [main, master]
schedule:
# Run weekly on Monday at 06:31 UTC
- cron: '31 6 * * 1'
env:
CARGO_TERM_COLOR: always
jobs:
self-audit:
name: cargo-audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install cargo-audit
run: cargo install cargo-audit
- name: Run cargo audit
run: cargo audit
+74
View File
@@ -0,0 +1,74 @@
name: Lock down obvious spam issues
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
lockdown:
runs-on: ubuntu-latest
steps:
- name: Auto-close spam patterns from new accounts
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
const author = issue.user;
// Only consider brand-new accounts. If the user has been around
// long enough to file good-faith issues elsewhere, don't touch.
const created = new Date(author.created_at || 0);
const ageDays = (Date.now() - created.getTime()) / 86_400_000;
if (ageDays > 30) return;
const blob = `${issue.title || ''}\n${issue.body || ''}`;
const patterns = [
/\bcrypto\b/i,
/\bairdrop\b/i,
/\bnft\b/i,
/\bpresale\b/i,
/\busdt\b/i,
/\btg\s*@/i,
/\btelegram\s+@/i,
/\bt\.me\//i,
/\bwhatsapp\s+\+/i,
/\bseo\s+service/i,
/\bguest\s+post/i,
/\bbacklink/i,
/\bbuy\s+followers/i,
/\bjoin\s+our\s+(community|server|group)/i,
/\bpromot[ei]\s+your\b/i,
];
const hit = patterns.find(p => p.test(blob));
if (!hit) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: [
'This issue was auto-closed because the title or body matches',
'a spam pattern (paid promotion / unrelated link) and the author',
'account is less than 30 days old. If this is a real bug or',
'feature request, please reopen with a clearer description',
'(in English or 中文) of the project-relevant context.',
].join(' '),
});
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['spam'],
}).catch(() => {}); // ignore if label doesn't exist yet
+37
View File
@@ -0,0 +1,37 @@
name: Close stale issues
on:
schedule:
- cron: '17 5 * * *' # daily, off-peak
workflow_dispatch: {}
permissions:
issues: write
pull-requests: write
jobs:
stale:
runs-on: ubuntu-latest
steps:
# Pinned from actions/stale@v10; update deliberately when refreshing the policy.
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899
with:
days-before-stale: 14
days-before-close: 7
stale-issue-message: >
This issue has been inactive for 14 days while waiting on
additional information. It will close automatically in 7 days
unless someone responds. If you still need help, drop a
comment with the requested details and a maintainer can
reopen.
close-issue-message: >
Closing for inactivity. Feel free to comment to reopen if
you can share the requested information.
stale-issue-label: 'stale'
only-labels: 'needs-info'
exempt-issue-labels: 'pinned,keep-open,release-blocker,security'
# Don't touch PRs — `actions/stale` defaults can be aggressive
# there. We only want it for `needs-info` issues.
days-before-pr-stale: -1
days-before-pr-close: -1
operations-per-run: 60
+125
View File
@@ -0,0 +1,125 @@
name: Sync to CNB
# Mirror commits and release tags to cnb.cool/codewhale.net/codewhale
# so users behind GitHub-blocking networks can fetch the source and tagged
# releases from the Tencent-hosted mirror.
#
# Triggers:
# * push to main → mirrors that commit to CNB main
# * tag matching v* → mirrors that tag to CNB
# * release work branches → mirrors release-candidate refs for CNB preflight
# * fix/rebrand branches → mirrors first-party heavy Linux CI refs
# * Tencent release branches → mirrors Feishu/Lighthouse setup branches
# * workflow_dispatch → manual fallback if any of the above fails
#
# Why the rewrite (v0.8.31):
# The previous implementation used the opaque tencentcom/git-sync Docker
# action, which discovered every local ref via fetch-depth: 0 and tried
# to push them all — including dependabot/* branches that GitHub had
# locally. Those follow-on pushes ran without the configured credential
# helper in scope and failed with
# `fatal: could not read Username for 'https://cnb.cool'`
# No concurrency block meant the main-push and tag-push workflow runs
# that auto-tag.yml fires within seconds of each other raced. About
# half of recent runs failed for those two reasons combined.
on:
push:
branches:
- main
- 'work/v*'
- 'fix/*'
- 'rebrand/*'
- 'work/v*-feishu-*'
- 'work/v*-lighthouse*'
tags: ['v*']
workflow_dispatch: {}
# Serialize runs so the back-to-back main-push + tag-push from auto-tag.yml
# don't race each other rebasing onto CNB. cancel-in-progress: false so
# every commit actually arrives — we'd rather queue than drop.
concurrency:
group: cnb-sync
cancel-in-progress: false
permissions:
contents: read
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Push triggering ref to CNB
env:
CNB_TOKEN: ${{ secrets.CNB_GIT_TOKEN }}
shell: bash
run: |
set -euo pipefail
if [ -z "${CNB_TOKEN:-}" ]; then
echo "::error::CNB_GIT_TOKEN secret is not set; cannot push to CNB." >&2
exit 1
fi
# URL-encode any '%' in the token so basic-auth doesn't break on
# special characters. CNB tokens are typically alphanumeric so
# this is belt-and-suspenders.
ENCODED_TOKEN="$(printf '%s' "${CNB_TOKEN}" | python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.stdin.read(), safe=""))')"
REMOTE_URL="https://cnb:${ENCODED_TOKEN}@cnb.cool/codewhale.net/codewhale.git"
# Use a masked alias so the token never appears in log lines.
git remote add cnb "${REMOTE_URL}"
# Push with retry on transient failures (CNB rate-limits, DNS
# blips, etc.). Args after `kind` are forwarded to `git push`
# so callers can pass `--force-with-lease`, multiple refspecs,
# etc. without quoting them into one string.
push_with_retry() {
local kind="$1"
shift
local attempt
for attempt in 1 2 3; do
echo "Attempt ${attempt}: pushing ${kind} to CNB"
if git push cnb "$@" 2>&1; then
echo "Successfully pushed ${kind} to CNB"
return 0
fi
if [ "${attempt}" -lt 3 ]; then
sleep $((attempt * 5))
fi
done
echo "::error::Failed to push ${kind} to CNB after 3 attempts" >&2
return 1
}
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
# Release tags may be repointed while rebuilding a failed
# publish attempt. CNB is a one-way mirror, so force the tag
# there to match GitHub instead of failing on "already exists".
push_with_retry "tag ${TAG}" "+refs/tags/${TAG}:refs/tags/${TAG}"
elif [[ "${GITHUB_REF}" == refs/heads/main ]]; then
# Plain --force. The CNB mirror is one-way by design —
# nothing else pushes to it, so there's no contributor work
# to protect against. `--force-with-lease` would be safer
# in a multi-writer scenario, but in our setup the lease
# check requires `refs/remotes/cnb/main` to exist in the
# runner's local clone, which it never does (we add `cnb`
# as a fresh remote in this step and don't fetch first).
# That made the lease check spuriously fail with
# `! [rejected] HEAD -> main (stale info)` even when CNB
# was actually behind GitHub.
push_with_retry "main" HEAD:refs/heads/main --force
else
# First-party fix/rebrand/release branches are first-class CNB
# sources for heavy Linux CI, release preflight, and
# Lighthouse/Feishu bootstrap.
# Mirror the triggering branch exactly so the CNB clone path stays
# useful before the branch has merged to main or become a release
# tag.
BRANCH="${GITHUB_REF#refs/heads/}"
push_with_retry "branch ${BRANCH}" "HEAD:refs/heads/${BRANCH}" --force
fi
+63
View File
@@ -0,0 +1,63 @@
name: Issue triage
on:
issues:
types: [opened, reopened]
permissions:
issues: write
contents: read
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Auto-label by title and body
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const text = `${title}\n${body}`;
const labels = new Set();
// Type
if (/\b(bug|crash|panic|broken|stack ?trace|regression|err(?:or)?|fail(?:ed|ure)?)\b/.test(text)) labels.add('bug');
if (/\b(feat(?:ure)?|request|enhancement|wishlist|proposal|please add|would be nice|support for)\b/.test(text)) labels.add('enhancement');
if (/\b(docs?|readme|documentation|typo|grammar|wording|spelling)\b/.test(text)) labels.add('documentation');
if (/\b(question|how (?:do|to)|why does|what does|is it possible)\b/.test(text)) labels.add('question');
// Locale — title contains CJK (Chinese, Japanese, Korean) characters
if (/[぀-ヿ㐀-鿿가-힯]/.test(issue.title || '')) labels.add('lang:zh');
// Areas (path-driven hints)
if (/crates\/tui|\btui\b|ratatui|composer|sidebar/.test(text)) labels.add('area:tui');
if (/crates\/core|engine|turn ?loop|agent ?loop/.test(text)) labels.add('area:core');
if (/crates\/mcp|\bmcp\b/.test(text)) labels.add('area:mcp');
if (/crates\/state|sqlite|sessions?|persistence/.test(text)) labels.add('area:state');
if (/crates\/execpolicy|approval|sandbox|seatbelt|landlock/.test(text)) labels.add('area:execpolicy');
if (/crates\/tools|tool[ _]call|tool[ _]registry/.test(text)) labels.add('area:tools');
if (/install|cargo install|npm install|scoop|homebrew|prebuilt|binary/.test(text)) labels.add('area:install');
if (/windows/.test(text)) labels.add('os:windows');
if (/macos|darwin|apple silicon/.test(text)) labels.add('os:macos');
if (/\blinux\b|ubuntu|debian|fedora|arch ?linux/.test(text)) labels.add('os:linux');
if (labels.size === 0) return;
// Only add labels that already exist on the repo to avoid creating noise.
const existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const existingNames = new Set(existing.map(l => l.name));
const toAdd = [...labels].filter(name => existingNames.has(name));
if (toAdd.length === 0) return;
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: toAdd,
});
@@ -0,0 +1,97 @@
name: v0.8.68 milestone hygiene
on:
issues:
types: [opened, labeled, milestoned]
workflow_dispatch:
permissions:
issues: write
contents: read
jobs:
sync-labels:
runs-on: ubuntu-latest
steps:
- name: Sync v0.8.68 label with milestone
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
if (!issue) return;
const milestoneTitle = issue.milestone?.title || '';
const labels = (issue.labels || []).map(l => l.name);
const hasMilestone = milestoneTitle === 'v0.8.68';
const hasLabel = labels.includes('v0.8.68');
if (hasMilestone && !hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: ['v0.8.68'],
});
core.info(`Added v0.8.68 label to #${issue.number}`);
}
if (hasLabel && !hasMilestone && issue.state === 'open') {
const milestones = await github.paginate(
github.rest.issues.listMilestones,
{ owner: context.repo.owner, repo: context.repo.repo, state: 'open', per_page: 100 }
);
const target = milestones.find(m => m.title === 'v0.8.68');
if (target) {
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
milestone: target.number,
});
core.info(`Added #${issue.number} to v0.8.68 milestone`);
}
}
- name: Auto-label agent-task issues
uses: actions/github-script@v9
with:
script: |
const issue = context.payload.issue;
if (!issue) return;
// Only auto-label freshly opened issues. Running on `labeled`
// events re-adds labels (e.g. agent-ready) that a maintainer or
// triage agent deliberately removed, breaking the
// "agent-ready = startable now" contract on the v0.8.68 board.
if (context.payload.action !== 'opened') return;
const title = (issue.title || '').toLowerCase();
const body = (issue.body || '').toLowerCase();
const labels = new Set((issue.labels || []).map(l => l.name));
// Agent-ready tasks: title starts with v0.8.68 and has structured sections
if (title.startsWith('v0.8.68:') && body.includes('acceptance criteria')) {
labels.add('agent-ready');
}
// Area hints for v0.8.68 work
const text = `${title}\n${body}`;
if (/\bworkflow\b|whaleflow|workflow-runtime/.test(text)) labels.add('workflow-runtime');
if (/crates\/tui|\btui\b|ratatui/.test(text)) labels.add('tui');
if (/fleet|subagent|sub-agent/.test(text)) labels.add('subagents');
if (/catalog|openrouter|model.?picker|provider.?lake/.test(text)) labels.add('reliability');
const existing = await github.paginate(github.rest.issues.listLabelsForRepo, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
const existingNames = new Set(existing.map(l => l.name));
const toAdd = [...labels].filter(name => existingNames.has(name) && !(issue.labels || []).some(l => l.name === name));
if (toAdd.length === 0) return;
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: toAdd,
});
+82
View File
@@ -0,0 +1,82 @@
name: Web Frontend
on:
push:
branches: [master, main]
paths:
- 'web/**'
- '.github/workflows/web.yml'
pull_request:
branches: [master, main]
paths:
- 'web/**'
- '.github/workflows/web.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
lint:
name: Lint & Type Check
runs-on: ubuntu-latest
defaults:
run:
working-directory: web
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check facts drift
# facts.generated.ts is TRACKED (committed), so verify the committed
# copy matches the workspace BEFORE regenerating. Running prebuild first
# would self-heal the working tree and let a stale committed file pass
# (#3771). check:facts ignores the volatile generatedAt/latestRelease
# fields by design, so it is safe to run against the committed copy that
# exists at checkout.
run: npm run check:facts
- name: Generate derived facts
# Regenerate after the drift gate so tsc --noEmit (TS2307 without it) and
# the build use a current facts.generated.ts. When the gate passes this
# only refreshes the generatedAt timestamp.
run: npm run prebuild
- name: Check docs parity
# Fails CI when docs-map.ts references non-existent repo files or
# when website version / command snippets are stale.
run: npm run check:docs
- name: Run ESLint
run: npm run lint
- name: TypeScript type check
run: npx tsc --noEmit
deploy:
name: Deploy to Cloudflare
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
defaults:
run:
working-directory: web
env:
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check Cloudflare deploy environment
run: npm run check:deploy-env
- name: Build OpenNext bundle
run: npm run build && npx opennextjs-cloudflare build
- name: Deploy
run: npm run deploy
+145
View File
@@ -0,0 +1,145 @@
# Build artifacts
/target
/extensions/vscode/out/
*.pdb
*.exe
*.dll
*.so
*.dylib
*.rlib
*.o
# Development
.env
.env.*
!.env.example
node_modules/
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Python
__pycache__/
*.py[cod]
*$py.class
.pytest_cache/
venv/
ENV/
env/
.venv/
*.egg-info/
dist/
# Logs
*.log
# Generated
outputs/
tmp/
backup/
# Reference papers / large research blobs (keep locally if needed, don't ship)
docs/DeepSeek_V4.pdf
docs/*.pdf
# Note: Cargo.lock is intentionally NOT ignored for reproducible builds
# Local dev scripts and temp files
*.sh
*.cmd
!scripts/**
!.github/scripts/**
!web/public/install.sh
test.txt
TODO*.md
todo*.md
AGENTS.md
# …except the deliberate, maintained agent-guidance files:
!/AGENTS.md
!crates/tui/AGENTS.md
!crates/tui/locales/AGENTS.md
!web/AGENTS.md
NEXT_SESSION.md
AI_HANDOFF.md
result.json
count_deps.py
project_overhaul_prompt.md
.codex/
.context/
.wrangler/
# Local runtime state
# Ignore everything under any .codewhale/ (snapshots, auto-generated
# instructions.md, etc.) at any depth EXCEPT the committed repo authority policy.
**/.codewhale/*
!**/.codewhale/constitution.json
.deepseek/
**/session_*.json
*.db
npm/*/bin/downloads/
# Companion app (tracked separately)
apps/
# Claude Code runtime artifacts
.claude/settings.json
.claude/scheduled_tasks.lock
.claude/worktrees/
.worktrees/
.ace-tool/
# Local-only Claude / ralph notes
.claude/*.local.md
.claude/*.local.json
# Maintainer handoff + codemap notes are working-state, not user-facing
# artifacts. They've leaked into the public repo via .claude/ in the past
# (HANDOFF_v0.8.28, CODEMAP_v0.8.25) — those files now live under
# .private/handoffs/ instead. Block the patterns here so a future accidental
# add doesn't silently land on main.
.claude/HANDOFF_*
.claude/CODEMAP_*
.claude/handoff_*
.claude/codemap_*
# Maintainer-internal design notes (trade-secret material, never published)
.private/
# Agent handoffs and version-specific setup plans are working-state notes, not
# public docs. Keep durable setup guidance in docs/runbooks instead.
docs/*HANDOFF*.md
docs/*handoff*.md
docs/*_PLAN.md
!docs/archive/**
# direnv
.envrc
.direnv
scripts/run_deep_swe.py
.claude/
# Local run artifacts and caches re-included by !scripts/**
results/
scripts/**/__pycache__/
# Maintainer-local verification artifacts
.uv-bin/
.uv-cache/
.uv-tools/
issues/
logs/
notes/
# Agent-local roadmap and scratch (never commit — may hold unreleased plans)
.agents/
scratchpad/
# Exploratory operations spec / prompt — never track (canonical copy lives at CW root)
operations9.md
OPERATIONS_0_9_0.md
operations_0_9_0.md
CODEWHALE_0_9_0_CUTOVER.md
+16
View File
@@ -0,0 +1,16 @@
Hunter Bown <hmbown@gmail.com> devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> GitHub <noreply@github.com>
Hunter Bown <hmbown@gmail.com> Claude <noreply@anthropic.com>
Hunter Bown <hmbown@gmail.com> Claude Opus 4.6 <noreply@anthropic.com>
Hunter Bown <hmbown@gmail.com> Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Hunter Bown <hmbown@gmail.com> Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hunter Bown <hmbown@gmail.com> Copilot <223556219+Copilot@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> Copilot <1693627+github-copilot-cli[bot]@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> Copilot <946600+copilot-pull-request-reviewer[bot]@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> Qwen-Coder <224605497+qwencoder@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> qwencoder <224605497+qwencoder@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> Cursor Agent <cursoragent@cursor.com>
Hunter Bown <hmbown@gmail.com> cursoragent <199161495+cursoragent@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> gemini-code-assist[bot] <gemini-code-assist[bot]@users.noreply.github.com>
Hunter Bown <hmbown@gmail.com> gemini-code-assist[bot] <956858+gemini-code-assist[bot]@users.noreply.github.com>
+157
View File
@@ -0,0 +1,157 @@
# Repository Agent Guidance
## Where to work right now (read this first)
- **Repo:** `Hmbown/CodeWhale`. This repo lives on multiple devices, so work in
whichever local checkout you have — keep paths here device-agnostic and always
**confirm with `git branch --show-current` before editing.**
- **Active branch:** start from live truth. Confirm the current fix/integration
branch from the latest handoff/objective file and `git branch --show-current`;
recent work has landed on `main` through small PRs rather than a long-lived
`codex/...` integration branch, so verify a named integration branch still
exists before relying on it.
- **Workspace version:** read it from `Cargo.toml` (`[workspace.package]
version`); it advances per release lane, so treat that file as the source of
truth over any memorized number. Bump versions deliberately, keeping a bump to
its own commit.
- **Milestone guidepost:** use the current release milestone named in the active
handoff and list it live, e.g.
`gh issue list --repo Hmbown/CodeWhale --milestone "<current milestone>" --state open`.
- **Default branch is `main`.** Committing directly to `main` is fine for
release-lane work — keep each commit to one reviewable concern with a real
body. A fresh `codex/...` branch or worktree is still the right call for an
isolated or risky change, opened as a PR when that reads better for review.
- **Always run before pushing a change:** `cargo fmt`, then the targeted tests
for the area (`cargo test -p codewhale-tui --bin codewhale-tui --locked <filter>`,
`cargo test -p codewhale-config`, `cargo test -p codewhale-protocol`, …). Full
gate: `cargo test --workspace`. Release build:
`cargo build --release -p codewhale-cli -p codewhale-tui`.
- **Known suite papercuts (pre-existing, not regressions):**
`run_verifiers_background_*` is flaky under full-suite parallelism but passes
in isolation. Attribute it to the known flake, not to your change. (The old
`config_command_allow_shell_*` failures on machines with
`default_mode = "yolo"` were fixed by pinning the command-test app to
Agent mode.)
## Continuous agent work conventions
- One concern per commit; write a real commit body. Keep unrelated changes in
separate commits.
- Commit as **WIP** unless you have actually verified the behavior (built the
binary, ran the test, reproduced the fix). Stating "fixed" without evidence is
worse than an honest WIP.
- Build only on the surfaces that exist today (removed machinery stays gone):
the model-facing sub-agent surface is **`agent` only** — the
`agent_open`/`agent_eval`/`agent_close`/`delegate_to_agent` variants,
capacity/coherence/runtime-tag systems, lifecycle tools, and runtime prompt/tag
injection were all removed. `constitution.md` is the sole base prompt.
- Configurable sub-agent depth stays. Add a new limit only when it's clearly
needed, and explain why.
- **Do-not-delete guardrail** (salvaged from the 0.8.68 handoff; these were
repeatedly misflagged as dead code and deleting them broke the build):
`tui/src/memory.rs`, `tui/src/context_budget.rs`,
`tui/src/model_registry.rs`, `tui/src/prompt_zones.rs`,
`tui/src/tools/remember.rs`, and the entire `config/src/route/` directory
are all actively imported. Verify consumers with `rg` before believing any
dead-code audit.
- The sub-agent **TUI freeze reported in older handoffs is resolved** by the
v0.8.61 cutover (cap-20, persist-debounce, AgentProgress redraw throttle,
ListSubAgents coalescing, input-pump-off-render-thread). The leading
"blocking I/O starves the worker pool" theory was measured and **disproven**
(`git rev-parse` ~10ms, 18-core machine). Treat the freeze as closed and spend
effort elsewhere rather than on a speculative `spawn_blocking` fix.
## CodeWhale Stewardship
- Treat community contributors as partners. Good-faith PRs, issue reports,
repros, logs, reviews, and verification comments are maintainer evidence,
not queue noise.
- Keep gates warm and dry-run unless Hunter explicitly approves enforcement.
Gate copy should guide contributors clearly and respectfully.
- Credit every harvested PR, issue report, or comment that materially shaped a
fix. Preserve authorship when possible; otherwise use mappable GitHub
noreply `Co-authored-by` trailers from `.github/AUTHOR_MAP`.
- CodeWhale started as a DeepSeek-only harness; it's now about building the
greatest possible coding harness with the help of an open-source community.
Keep CodeWhale branding and every model/provider first-class — none
privileged. When retiring legacy names like `deepseek-tui`, keep it clear that
every model and provider stays fully supported.
- Review PRs from code, tests, linked issues, comments, and check results — let
those, rather than the title or labels alone, drive every merge, close,
harvest, or defer decision on community work.
- Respect concurrent work in the tree — leave unrelated edits by other people or
agents intact.
## Release PR Integration
- Use scratch integration branches when triaging a crowded release queue. A
branch such as `scratch/vX.Y.Z-pr-train-YYYYMMDD` may merge or cherry-pick
many PR heads to expose conflicts, missing tests, duplicate work, and hidden
coupling quickly.
- Treat scratch branches as evidence, not as the artifact to ship. Land work by
harvesting the safe resolved hunks or commits back into the release branch in
narrow, reviewable commits — keep tags, releases, and fast-forwards off the
scratch train.
- Prefer direct GitHub merge only when the PR is clean against the real landing
branch, has acceptable checks, and does not cross trust-boundary surfaces. A
PR that is clean against `main` can still conflict with a release branch; test
against the actual release head before calling it merge-ready.
- For already approved PRs, start with a scratch merge against the release
branch, then decide between direct merge, cherry-pick with conflict
resolution, or credited harvest. Maintainer approval is a priority signal,
not permission to skip review or tests.
- When harvesting, preserve or add machine-readable credit: keep the original
author where possible, add `Co-authored-by` using `.github/AUTHOR_MAP` or
GitHub numeric noreply identity, and include `Harvested from PR #N by
@handle` in the commit body so the auto-close workflow can close the PR with
credit after it reaches `main`. Merge a PR whose commit carries that line
with rebase or a merge commit so the body survives intact — a squash can
rewrite it, drop the `Harvested from PR` line, and silently lose both the
machine-readable credit and the auto-close.
- Keep `Co-authored-by` trailers to human contributors —
`scripts/check-coauthor-trailers.py` rejects bot/tool ones (Claude, codex,
cursor, `noreply@anthropic.com`) on harvest commits. Also refresh the manual
credit surfaces that do not auto-populate from trailers: `docs/CONTRIBUTORS.md`
and `CHANGELOG.md`.
- Close or update issues and PRs only after verifying the landed commit on the
relevant branch. If the release branch already contains equivalent behavior,
leave a clear note linking the commit and describing any remaining delta.
- For the active release queue, start from the current GitHub release milestone
named in the active handoff
(`gh issue list --repo Hmbown/CodeWhale --milestone "<current milestone>"`) and
refresh state before acting. Older per-version triage docs under `docs/` are
historical reference only.
## Cursor Cloud specific instructions
Standard build/test/run commands are already documented above and in
`CONTRIBUTING.md`; this section only records the non-obvious cloud-VM caveats.
- **System build dep:** the build needs `libdbus-1-dev` (pulled in by
`crates/secrets` for the OS keyring). It is installed by the startup update
script; if a `cargo build` fails with a `dbus`/`pkg-config` error, that dep is
missing.
- **`rustup default` must be set:** some tests and runtime paths spawn shells in
temp dirs *outside* this checkout (e.g. `run_verifiers_background_*`, sub-agent
worktrees). Those spawned shells only see the repo's `rust-toolchain.toml`
override while inside `/workspace`, so without a global default they fail with
"rustup could not choose a version of rustc to run". The update script runs
`rustup default stable` to fix this.
- **Known env-specific test failures at `/workspace` (not code bugs):** because
the checkout sits directly under `/`, two `codewhale-tui` subagent tests fail
here — `git_repo_root_reports_attempted_paths_when_no_repo_found` (cannot
create a temp dir in the unwritable parent `/`) and
`create_isolated_worktree_reports_friendly_error_when_no_repo_found` (walking
up to `/` discovers `/workspace` itself as a repo). Both pass when the repo is
checked out under a normal, writable parent. `run_verifiers_background_*` is
the separate pre-existing flake already noted above. Everything else in
`cargo test --workspace` passes (~6384 tests).
- **Running the agent without provider API keys:** point CodeWhale at any local
OpenAI-compatible endpoint via the keyless `vllm`/`ollama`/`sglang` providers,
e.g. `CODEWHALE_PROVIDER=vllm VLLM_BASE_URL=http://127.0.0.1:8000/v1
VLLM_MODEL=<id> codewhale exec --auto "..."`. `codewhale exec` (add `--auto`
for tool use) is the non-interactive path to exercise the full agent loop.
- **Dispatcher needs its sibling:** the `codewhale` binary shells out to a
sibling `codewhale-tui` in the same directory (both land in `target/debug`
after a build). If they are not co-located, set `DEEPSEEK_TUI_BIN` to the
`codewhale-tui` path.
+3226
View File
File diff suppressed because it is too large Load Diff
+62
View File
@@ -0,0 +1,62 @@
# Claude Repository Guidance
Read `AGENTS.md` first. This file exists as a compatibility instruction source
for Claude-based agents working in this repository.
## Stewardship Defaults
- Treat community PRs and issues as maintainer evidence. Inspect code, tests,
linked issues, comments, and CI before merging, harvesting, closing, or
deferring work.
- CodeWhale started as a DeepSeek-only harness; it's now about building the
greatest possible coding harness with the help of an open-source community.
Keep CodeWhale branding and every model/provider first-class — none
privileged — and preserve legacy migration care.
- Preserve contributor credit for harvested work with authorship,
`Co-authored-by`, `Harvested from PR #N by @handle`, and changelog/release
notes where applicable. Keep `Co-authored-by` trailers to human contributors,
using canonical GitHub-noreply identities from `.github/AUTHOR_MAP` — the
`check-coauthor-trailers.py` CI gate accepts those and rejects bot/tool ones
(Claude, codex, cursor), so use a plain commit body to note agent assistance.
## Scratch Integration Branches
- For release queues, create disposable local branches from the real landing
branch, for example `scratch/vX.Y.Z-pr-train-YYYYMMDD`.
- Use the scratch branch to merge or cherry-pick candidate PR heads in batches
and learn which conflicts, tests, and overlaps are real.
- Treat the scratch branch as throwaway evidence — it collects noisy merge
commits, partial conflict resolutions, and unrelated PR interactions, so ship
from the release branch instead.
- After the scratch experiment, move only the safe result back to the release
branch as narrow commits or direct merges. Keep each final commit explainable
and testable.
- A PR that is clean against `main` is not necessarily clean against a release
branch. Test mergeability against the branch that will actually receive the
work.
- For already approved PRs, treat approval as a strong priority signal. Still
inspect diffs, comments, check results, and release-branch conflicts before
landing.
## Current Release Work
- Confirm the active branch for the current release lane from the latest handoff
and `git branch --show-current`; recent work has landed on `main` through small
PRs rather than a long-lived `codex/...` integration branch. This repo lives on
multiple devices, so work in whichever local checkout you have and confirm the
branch before editing.
- Read the workspace version from `Cargo.toml`; it advances per release lane.
- Base release triage on the current GitHub release milestone named in the active
handoff (`gh issue list --repo Hmbown/CodeWhale --milestone "<current>" --state open`)
unless Hunter gives a newer branch/milestone.
- Work the queue in this order: release blockers, recently approved PRs, clean
PRs with small scope, blocked PRs with obvious fixes, dirty PRs that can be
harvested safely, then larger architecture issues.
- Prefer batching PR conflict discovery on scratch branches, then harvesting
reviewed, credited, tested slices back into the release branch.
- Before claiming an issue is done, verify whether the branch already contains
equivalent work. If it does, prepare the GitHub note/closure path instead of
reimplementing it.
- See `AGENTS.md` → "Where to work right now" for build/test commands, known
suite papercuts, and the removed-machinery guardrails (agent-only surface,
no lifecycle/coherence systems).
+503
View File
@@ -0,0 +1,503 @@
# CodeWhale 0.8.68 — Workflow + Stability Patch Release
> **Status (2026-07-12): Historical.** v0.8.68 shipped on 2026-07-10; this
> tracker is superseded by the shipped release. Caution for readers: the
> Wave-7 "Multitask" rows below are marked Done but describe work that was
> **rolled back before ship** — Multitask never shipped and leftover settings
> normalize to Operate (`crates/tui/src/tui/app.rs`, mode picker). The
> "deferred to 0.9.0" lists remain useful as a raw next-major backlog feed;
> the release label itself is a maintainer decision.
>
> **What this is.** Local tracker + handoff for the CodeWhale 0.8.68 release
> candidate. It documents what's in scope, what's deferred to 0.9.0, and the
> current verification state.
>
> **2026-07-07 kickoff — READ THIS FIRST.** This release follows the 0.8.67
> Fleet/Workflow usability pass (PR #4047). Implementation work continues on
> `work/v0.9.0-cutover` in worktree
> `.cw-worktrees/v0867-pr4047`. Issue inventory lives in
> `milestone-audit-20260622/buckets/v0.8.68.md` (30 issues in bucket).
>
> **Sub-agent sweep completed 2026-07-07.** Six parallel scouts produced the
> release plan below. Workflow file:
> `CodeWhale/workflows/v0868_issue_sweep.workflow.js`.
>
> **2026-07-07 cutover correction — source of truth.** Work for this release
> belongs in `.cw-worktrees/v0867-pr4047` on `work/v0.9.0-cutover`. The
> durable PR base was `883f94df6`; the quick-win layer that followed was
> briefly stranded as uncommitted working-tree changes. This tracker update
> travels with that quick-win layer: OpenRouter live catalog parsing, provider
> picker search, Status/ToolDescriptor concept-map cleanup, copy/perf fixes,
> foreground shell compacting, and test alignment are locally verified here.
> Do not mark work complete from another checkout unless the symbols and tests
> are verified in this worktree.
## VERDICT: **partial** — can ship a narrow 0.8.68 patch after Wave 12
Wave 1 (three workflow correctness hazards) + Wave 2 (TUI P0 stability + core
workflow UX) is the minimum credible release. The full 30-issue bucket is
34 weeks; scope must be trimmed via closes and defers.
---
## SCOPE (ship in 0.8.68)
### P0 bugs (must-fix before tag)
| # | Title | Fix train commit |
|---|-------|------------------|
| **1830** | Input freeze / progress loss | Commits 1 + 4 (input fairness + periodic snapshot) |
| **1338** | Enter causes GUI crash (Windows) | Commit 2 (Enter-during-busy hardening) |
### P1 bugs (target for release)
| # | Title | Fix train commit |
|---|-------|------------------|
| **2317** | Long reply blocks further input | Commits 1 + 5 (fairness + queue UX toasts) |
| **1198** | No response on key input (approval/git) | Commits 1 + 3 (fairness + modal submit errors) |
| **1862** | TUI read "Reading" stuck | Tool hang watchdog tuning (15min → shorter + footer clear) |
### Workflow correctness (release-blocking)
| Hazard | Location | Fix |
|--------|----------|-----|
| `completion_from_manager` fabricates Completed | `workflow.rs:14121443` | Fail closed after 1s poll timeout |
| Cancel doesn't interrupt VM | `workflow.rs:380404` | Wire `CancelHandle` + abort `run_workflow_vm` |
| `budget.spent()` stub | `workflow.rs:11211125` | Delegate to manager `aggregate_budget_spent` |
### P1 features (target for release)
| # | Title | Notes |
|---|-------|-------|
| **4038** | Workflow run view / phase progress UI | `SharedWorkflowRuns` exists; TUI never reads it |
| **4011** | Durable runs + journal/resume | In-memory only today; `codewhale-state` schema exists |
| **4013** | Verification gates | Reuse `task_gate_run` / Fleet verifier infra |
| **3380** | Approval modal key hints | **Good-first issue** — badges landed (#3799); footer contrast remains |
### Provider / model picker (2026-07-07)
**Source:** User-reported `/provider` and `/model` menu bugs (dogfood).
**Priority:** P1 · **Status:** Partial (P0 done 2026-07-07) · **GH:** new local scope (no GH # yet).
Related prior work: #3830, #3831 (0.8.67 configured-provider manager), #3083
(provider→model handoff), #3385 (live catalog — deferred).
| # | Acceptance criterion | Priority | Status |
|---|---------------------|----------|--------|
| 1 | **Catalog vs picker audit** — Together AI missing from `/model`; audit `models_dev.bundled.json` / `bundled_catalog_offerings()` vs picker sources (`model_completion_names_for_provider`); fix provider/model gaps | P1 | **Partial** — bundled flash rows added; full 13-provider audit done for bundled set |
| 2 | **Provider section UX**`/provider` lists **configured** providers only; press `a` to add/browse **remaining unconfigured** providers (full catalog minus configured) | P1 | **Done** (#3830) |
| 3 | **Model section UX**`/model` shows models from **configured providers only** — no more, no less | P1 | **Done** (#3830 + lake) |
| 4 | **Model search** — search matches **provider name OR model name** (substring across both) | P1 | **Done** (pre-existing) |
| 5 | **`ConfiguredProviderLake` facade** — single seam over `catalog.rs` + config auth: `configured_providers`, `models_for_provider`, `all_catalog_*` for expand | P0 | **Done**`crates/tui/src/provider_lake.rs` |
| 6 | **Replace `model_completion_names_for_provider` consumers** — pickers, hotbar, `ModelInventory`, slash completions, subagent validation | P0 | **Done** — legacy table kept as fallback for unbundled providers |
| 7 | **Model picker `A` toggle**`ModelListView { Configured, Catalog }` parity with `/provider` | P0 | **Done** |
| 8 | **Bundled↔picker audit** — sync `models_dev.bundled.json` with picker rows (Together Flash drift vs hardcoded list) | P0 | **Done** — Together/OpenRouter/Novita/SiliconFlow flash rows added (31 offerings) |
**Likely files:** `crates/tui/src/tui/model_picker.rs`, `provider_picker.rs`,
`crates/tui/src/config.rs`, `crates/config/src/models_dev.rs`,
`crates/config/assets/models_dev.bundled.json`, `crates/config/src/catalog.rs`,
`crates/tui/src/provider_lake.rs`.
### Progressive disclosure / provider lake (ethos audit) — 2026-07-07
**Source:** Agent d7e2f642 ethos audit.
**Verdict:** **Mostly aligned (2026-07-07 Wave 5b).** Lake = `provider_lake.rs` + `models_dev.bundled.json` + configured predicate (#3830). Pickers, hotbar, `ModelInventory`, slash completions, and subagent hints now read the lake; legacy `model_completion_names_for_provider` remains fallback for providers not yet in bundled JSON. Live catalog (#3385) still unwired.
| Surface | Score | Fix |
|---------|-------|-----|
| Provider picker | ✅ Aligned | Slim configured rows + catalog model counts via lake |
| Model picker | ✅ Aligned | `A` expand + catalog lake rows |
| Fleet roster | ⚠️ Partial | Loadout/model pins via lake |
| Workflow tool schema | ✅ Aligned | — |
| Mode footer (Operate/Multitask) | ✅ Aligned | — |
| Hotbar route slots | ✅ Aligned | Lake enumeration |
| Operate/Multitask prompts | ✅ Aligned | Conductor guidance; no catalog leak |
| ModelInventory / auto-router | ✅ Aligned | Lake-backed inventory |
**P0 fixes**
1. **`ConfiguredProviderLake` facade** (extend `catalog.rs` or thin `provider_lake.rs`) — `configured_providers`, `models_for_provider` from merged catalog snapshot, `all_catalog_*` for `A` expand.
2. **Replace `model_completion_names_for_provider` consumers** — pickers, hotbar, `ModelInventory`, slash completions, subagent validation (**subagent spawn validation done 2026-07-07**).
3. **Model picker `A` toggle**`ModelListView { Configured, Catalog }` + footer hint (parity with provider picker).
4. **Bundled↔picker audit** — sync `models_dev.bundled.json` with picker expectations (Together Flash drift).
**P1 fixes**
1. **Provider picker disclosure trim** — Configured view: display name + auth chip; move `compact_hint` internals to detail panel / `A` catalog view only.
2. **Unify `bundled_offerings` seeds** into catalog-derived offerings (remove `OFFERING_SEEDS` drift).
3. **Wire live cache read** into lake (refresh async; stale fallback to bundled) — completes #3385 when ready.
**Wave alignment:** Wave 7 Operate/Multitask ✅ ethos-aligned (thin footer, no catalog leak). Waves 14 neutral. **Wave 5b P0 done 2026-07-07** (`provider_lake.rs`, model/provider `A` toggles, bundled flash sync); P1 live-cache + `OFFERING_SEEDS` dedupe remain.
### Subagent route validation (2026-07-07)
**Source:** User screenshot — sub-agent failure:
`Failed: [model] Model error: Model "deepseek-v4-flash" not found (provider 'Sakana AI (Fugu)' …)`.
**Priority:** P0 · **Status:** Fixed (local, uncommitted) · **Worktree:** `.cw-worktrees/v0867-pr4047`.
**Root cause:** Sub-agent spawn resolved a DeepSeek-only model id (`deepseek-v4-flash`) against a
different active provider (Sakana AI / Fugu). `validate_route` (#3227) existed but was
`#[cfg(test)]`-only; inherit/faster routing and permissive `requested_model_for_provider` let
stale operator models or fleet profile pins cross namespaces before the upstream 400.
| # | Acceptance criterion | Priority | Status |
|---|---------------------|----------|--------|
| 1 | **Spawn-time model↔provider validation**`ensure_subagent_model_for_provider` calls production `validate_route` before spawn | P0 | Done |
| 2 | **Operator inheritance must not cross namespaces** — inherit/faster/auto remap to operator catalog default when parent model is foreign | P0 | Done |
| 3 | **Explicit pins fail fast** — fixed model / role override / spawn `model=` rejected locally with diagnostic naming the pair | P0 | Done |
| 4 | **`normalize_requested_subagent_model` uses lake + `validate_route`** — not just permissive pass-through | P0 | Done |
**Likely files:** `crates/tui/src/tools/subagent/mod.rs`, `crates/tui/src/config.rs`,
`crates/tui/src/core/engine.rs`, `crates/tui/src/provider_lake.rs`.
**Tests added:** `inherit_route_remaps_stale_deepseek_model_for_sakana_provider`,
`faster_route_remaps_stale_deepseek_model_for_sakana_provider`,
`fixed_route_rejects_deepseek_model_for_sakana_provider`,
`normalize_requested_subagent_model_rejects_cross_namespace_for_sakana`,
`validate_route_rejects_mismatched_provider_model_tuple` (Sakana case).
**Symptom link:** screenshot error pairs `deepseek-v4-flash` with Sakana AI (Fugu) — exactly the
#3227 route-isolation contamination class; fix prevents spawn instead of upstream model-not-found.
### UI/UX copy slop audit (2026-07-07)
**Source:** Agent 9a53917c copy-slop audit (worktree `v0867-pr4047`).
**Verdict:** **18 findings** — 7 P1 (same-screen status/mode repetition + foreground shell wait verbosity), 11 P2 (toast vs chip, approval/setup boilerplate).
**Ethos:** **Disclose once, not thrice** — each fact at one highest-signal layer; drop redundant chrome over rephrasing.
**Wave:** **5c** (TUI copy dedupe) — **Done 2026-07-07** (original P1 #1#6 + P2 #7/#8/#9/#10/#12; #17/#18 tracked separately).
| # | Location | What repeats | Sev | Suggested fix (dedupe one layer) | Status |
|---|----------|--------------|-----|----------------------------------|--------|
| 1 | `header.rs:534` + `footer.rs:307-314` | Mode in **header left** (`Plan`/`Act`/…) and **footer left** (`plan`/`act`/…) simultaneously | **P1** | Keep mode in **one** chrome row only (header *or* footer); footer keeps model/cost/status | **Done** — footer blanks `mode_label` |
| 2 | `header.rs:377-393` + `footer_ui.rs:67-97,1127-1146` | Header `● Live` while footer shows `busy` / animated `working...` / tool detail during same turn | **P1** | Header owns live pulse; footer shows **action detail only** (tool name, stall reason) — drop coarse `busy`/`working` when header streams | **Done**`header_owns_live_pulse`, action-only footer |
| 3 | `history.rs:808-868` | Explore card: header state `done`/`running` + summary `{N} done, {M} running` + per-entry KV prefix `done`/`live` | **P1** | Header = aggregate glyph only; **either** counts line **or** per-entry prefixes, not both | **Done** — multi-entry: glyph header + dot counts, label-only rows |
| 4 | `agent_card.rs:318-356` | Fanout card: header `[done]`/`[running]` + dot grid + `FanoutCounts` (`{done} done · {running} running · …`) | **P1** | Drop `FanoutCounts` when header+grid present; or header shows role/title only, counts line owns status words | **Done** — counts line removed |
| 5 | `sidebar.rs:2770-2810` + `footer_ui.rs:474-504` | Agents panel: `N running / M` or `N done` header + rows `… is working`/`… is done` while footer may show `agents N/M running · …` | **P1** | Footer **or** sidebar owns fanout summary; rows show **name + objective**, not status verb | **Done** — sidebar rows name—objective; footer suppresses when agents panel visible |
| 6 | `app.rs:3043` + header/footer mode chips | `Switched to ACT mode` toast while mode already visible in header+footer | **P1** | Suppress mode-switch toast when mode chips visible; toast only on picker `/mode` or first session | **Done** — no status_message on Tab cycle; `/mode` command message retained |
| 7 | `history.rs:1525-1539` | Workflow run card (Wave 3): header `tool_status_label` (`done`/`running`) + body KV `status: <same>` | **P2** | Header owns lifecycle; body shows goal/children/progress only | **Done** |
| 8 | `footer_ui.rs:566-578` + tool cards in `history.rs` | Footer `read foo · 2 active · 1 done` while transcript cards already show per-tool `done`/`running` | **P2** | Footer = **primary running action + elapsed**; drop `active`/`done` counts | **Done**`include_counts=false` when header streams |
| 9 | `app.rs:3183-3187` + `footer.rs:326-337` | Shift+Tab toast `Permissions: Ask` + footer `perm Ask` chip | **P2** | Chip is canonical; drop permission toast (or toast on lock-denial only) | **Done** |
| 10 | `app.rs:3157-3165` + `header.rs:314-338` | Ctrl+T toast `Thinking: high` + header effort chip `◆ high` | **P2** | Header chip only; toast on first post-migration session only | **Done** |
| 11 | `header.rs:396-407` + `footer_ui.rs:857-871` + `sidebar.rs:3175-3203` | Context % in header, optional footer `active ctx N%`, sidebar `context: X/Y tokens … N%` | **P2** | **Disclose once:** header default; hide sidebar bar when header shows %; footer chip off by default | Open |
| 12 | `widgets/mod.rs:1595-1617` + `en.json:520-522` | Approval: per-row `[1 / y]` + `Choose: Enter selected option, or press y/a/d directly` + `v: full params · Esc: abort` | **P2** | Rows keep key badges; collapse footer to `v` pager + `Esc` only | **Done** |
| 13 | `mode_picker.rs:102-130` + `en.json:484` | Modal title `Mode` + prompt `Choose how CodeWhale should operate:` | **P2** | Title carries intent; drop `ModePickerPrompt` body line | Open |
| 14 | `en.json:147,151,245,262` | Fleet status phrasing repeated: `/fleet status`, `Fleet worker status`, `Fetching Fleet worker status...` | **P2** | One canonical phrase in slash help; home/quick lines reference command name only | Open |
| 15 | `en.json:389-422` (setup hints) | Six near-identical `Enter records…` hints differing only by step noun | **P2** | Single shared hint template + step-specific **one-word** action (`P`/`M`/`R`) | Open |
| 16 | `en.json:494` + `app.rs:3102-3106` | YOLO deprecation in picker hint **and** one-shot compat toast | **P2** | Picker hint for discoverability; suppress repeat toast after first sighting per install | Open |
| 17 | `history.rs:659-770` (`ExecCell::render`) | Foreground shell wait: main transcript shows header + `command:` + live output/artifact paths + separate Ctrl+B line (4+ lines) | **P1** | Live foreground wait = one header line (`▶ run running (Ns) · Ctrl+B → /jobs`); command/output/artifacts in Tasks sidebar + `/jobs` only; Transcript mode keeps full body |
| 18 | `footer_ui.rs:600-601,831-870` + `history.rs` compact wait | Footer `shell fg: <cmd>` + `Ctrl+B /jobs` duplicates transcript/sidebar shell detail | **P2** | Footer keeps primary action chip; drop redundant counts (see #8) |
**Likely files:** `crates/tui/src/tui/widgets/header.rs`, `footer.rs`, `footer_ui.rs`, `history.rs`, `agent_card.rs`, `sidebar.rs`, `widgets/mod.rs`, `views/mode_picker.rs`, `app.rs`, `crates/tui/locales/en.json`.
**Acceptance (finding #17 — foreground shell wait):** While a foreground `exec_shell` blocks the turn, the **main transcript** shows only spinner + `running` (+ elapsed badge) + `Ctrl+B → /jobs`. Command line, live stdout, spillover/artifact paths (`call_*.txt`), and call IDs appear in the **Tasks/jobs sidebar scroll** and `/jobs show` detail — not in the live transcript card.
### Modes & permissions (Multitask) — Wave 7
**Source:** Multitask mode design (agent 776bb3c0, 2026-07-07).
**Priority:** P1 · **Status:** Done (2026-07-07) · **Depends on:** Wave 3 workflow UX (done),
authority baseline (#3386 shipped in 0.8.67).
**2026-07-07 correction:** Operate AppMode = **Fleet operator posture** — session `/model` route is the operator slot (pinned first row in `/fleet roster`); operator decomposes into workflow/Fleet, workers execute, operator monitors. Multitask = lighter delegation; Operate = full conductor. See `prompts/modes/operate.md`.
| Epic | ID | Acceptance criteria | Status |
|------|----|---------------------|--------|
| Tab 4-mode cycle | **M1** | Tab cycles Plan → Act → Multitask → Operate → Plan; YOLO removed from cycle; `/mode` + hotbar accept new modes; footer shows **Act** label for Agent | **Done**`app.rs` CYCLE/CHOICES, footer, hotbar, `KEYBINDINGS.md` |
| Permission on Shift+Tab | **M2** | Shift+Tab cycles Suggest → Auto → Bypass (Ask / Auto-Review / Full Access) with trust/sandbox projection; footer permission chip separate from mode chip; locked while turn running (#2982) | **Done**`cycle_approval_posture`, `footer_permission_chip` |
| Thinking on Ctrl+T | **M3** | Ctrl+T cycles reasoning effort (moved from Shift+Tab); live-transcript overlay relocated (e.g. `Ctrl+Shift+T` or `Alt+T`); KEYBINDINGS.md + in-app migration toast | **Done (verified 2026-07-07)**`ui.rs` Ctrl+T/Ctrl+Shift+T, `KEYBINDINGS.md`; review pass added the missing one-shot Shift+Tab rebinding toast (`notify_keybinding_migration_once` + test) and fixed stale Ctrl+T doc comments in `live_transcript.rs` |
| Multitask mode behavior | **M4** | `multitask.md` prompt delta; higher default subagent fan-out; Agents sidebar auto-focus; non-blocking `workflow start`; operator-vs-worker spawn contract | **Done**`multitask.md`, `apply_session_spawn_policy` (Multitask→Faster default), `mode_delegation_launch_floor`, Multitask→Agents sidebar |
| Operate mode (thin) | **M5** | Operate AppMode = Fleet operator: session model route, `operate.md` conductor prompt, workflow run cards, `operate_ready` hints; full Operation value-stream → 0.9.0 | **Done**`operate.md`, `SubAgentRuntime.parent_mode`, Operate spawn_policy metadata on `agent` start; full value-stream → 0.9.0 |
| YOLO → permissions migration | **M6** | `--yolo` / `default_mode=yolo` / hotbar `mode.yolo` map to Agent + `ApprovalMode::Bypass` via shim; deprecation notice in MODES.md; `AppMode::Yolo` kept for parse/back-compat only | **Done**`set_mode` shim + one-shot toast |
**Linked GH:**
| Issue | Action |
|-------|--------|
| **#3386** | **Close** — mode/permission untangle shipped 0.8.67 (`authority.rs`, `base_policy_for_mode`) |
| **#3387** | **Close** — prompt-as-mode-switch fixed 0.8.67 (#3491) |
| **#3211** | **Defer** full permission profiles + `/permissions` UX to 0.9.0; M2 ships Shift+Tab chord slice only |
**Likely files:** `crates/tui/src/tui/app.rs` (`AppMode`, `CYCLE`), `ui.rs` (Tab/Shift+Tab/Ctrl+T),
`core/authority.rs`, `prompts/modes/{multitask,operate}.md`, `widgets/footer.rs`,
`hotbar/actions.rs`, `tools/subagent/mod.rs`, `tools/workflow.rs`, `docs/KEYBINDINGS.md`,
`docs/MODES.md`.
**Risks:**
1. **Muscle memory:** Shift+Tab (thinking → permission) and Ctrl+T (overlay → thinking) churn — ship one-release migration toast + KEYBINDINGS.md banner.
2. **Back-compat:** `default_mode = "yolo"`, hotbar `mode.yolo`, `--yolo` CLI must keep working via shim through 0.8.68.
3. **Operate vs cutover tension:** 0.9.0 cutover doc treats Operate as orchestration structure, not `AppMode`; 0.8.68 Operate is a **thin AppMode** — document scope boundary.
4. **Ctrl+T conflict:** live-transcript overlay already binds Ctrl+T — relocation required before thinking migration.
### Hotbar (partial — close what's done)
| # | Title | Status |
|---|-------|--------|
| **2067** | Slash commands source | **Done** — close |
| **2068** | MCP tools source | **Done 2026-07-07**`McpToolHotbarActionSource`, prefill-only dispatch (never executes; agent approval flow untouched); lists enabled-server tools from discovery snapshot |
| **2069** | Skills source | **Done 2026-07-07**`SkillHotbarActionSource` from startup skill cache; dispatch via existing `$skill` alias |
| **2070** | Plugins source | **Audited 2026-07-07 → close** — no TUI-side plugin registry/snapshot exists; a source would need side-effectful disk scans + config reloads; descriptor stays Deferred with tests enforcing it |
---
## DEFER (explicitly out of 0.8.68)
| Issues | Reason |
|--------|--------|
| **18901897** | Refactor epics (workbench, truth surface, slash suite) — 0.9.0+ |
| **1754** | Shell-aware AI commands — L-sized, cross-cutting |
| **1708** | `tui_help` tool — net-new surface |
| **1682** | Output/thinking preview redesign — open-ended UX epic |
| **2342** | File click preview — needs product design |
| **4039** | Background task phase ledger UI — UX polish |
| **4010, #4012, #4014#4016** | Conductor, topology, lag, context budget, worktree pool — 0.9.0 architecture |
---
## CLOSE
| # | Title | Reason |
|---|-------|--------|
| **3324** | mosaic-compress promo | Third-party recommendation, no codebase tie-in |
| **1607** | More currency units | CNY already implemented (`cost_currency = "cny"`) |
| **1678** | Version check + GitHub link | Already exists (`UpdateConfig`, `/links`) |
| **1853** | Terminal copy line breaks | Documented behavior; `/copy` + mouse_capture handle it |
| **2067** | Hotbar slash source | Implemented in v0867-pr4047 |
---
## WAVES (execution order)
| Wave | Theme | Issues / hazards | Owner lane |
|------|-------|------------------|------------|
| **1** | Workflow correctness | H1 completion_from_manager, H2 cancel→VM, H3 budget.spent() | `.cw-worktrees/v0867-pr4047/crates/tui/src/tools/workflow.rs` |
| **2a** | TUI input fairness | #1830, #2317, #1198 foundation | `crates/tui/src/tui/ui.rs` event loop |
| **2b** | TUI P0 hardening | #1338 Windows Enter, #1830 persistence | `ui.rs`, `app.rs` |
| **3** | Workflow UX | #4038 run view, #4011 journal, #4013 gates | `history.rs`, `workflow.rs`, `verifier.rs`**Done** (minimal slices) |
| **4a** | Deep-dive security & infra | DD #1, #2, #4, #5, #14, #15, #19#21, #34, #36#37, #39#40, #42 | `app-server/`, `execpolicy/`, `config/`, `secrets/`, `cli/`, `hooks/` |
| **4b** | Deep-dive core/runtime | DD #10#13, #16#18, #23, #25, #33, #50 | `core/`, `state/`, `mcp/`, `workflow-js/` |
| **5** | Hotbar + polish | #3380 approval UX, #2068/#2069 adapters | `approval.rs`, `hotbar/actions.rs` |
| **5b** | Provider/model picker UX + provider lake | Local #1#8: P0 `ConfiguredProviderLake` facade, replace `model_completion_names_for_provider` consumers, model picker `A` toggle, bundled↔picker audit; P1 configured-only lists, search, disclosure trim | `model_picker.rs`, `provider_picker.rs`, `config.rs`, `catalog.rs`, `provider_lake.rs` (new) |
| **5c** | TUI copy dedupe | Header/footer mode dup, done×3 on explore/fanout cards, toast vs chip, workflow status KV, approval footer trim (18 findings: P1 #1#6 **done**, P2 #7/#8/#9/#10/#12 **done**) | `header.rs`, `footer.rs`, `footer_ui.rs`, `history.rs`, `agent_card.rs`, `sidebar.rs`, `widgets/mod.rs`, `app.rs`**Done 2026-07-07** |
| **6** | Platform investigate | #1327 FreeBSD, #1675 CJK, #1854 Windows .bat | Platform-specific |
| **7** | Modes & permissions (Multitask) | M1M6 (Tab cycle, Shift+Tab permission, Ctrl+T thinking, Multitask MVP, Operate thin, YOLO shim) | `app.rs`, `ui.rs`, `authority.rs`, `prompts/modes/`, `footer.rs` |
### TUI fix train detail (Wave 2)
1. **Commit 1** — Event-loop input fairness (`ui.rs` — break engine drain every 816 events)
2. **Commit 2**#1338 Windows Enter-during-busy hardening
3. **Commit 3**#1198 modal submit error handling (stop swallowing `submit_user_input` errors)
4. **Commit 4**#1830 periodic recovery snapshot (3060s while loading)
5. **Commit 5**#2317 queue UX toasts during streaming
---
## Control board
| Lane | Status | Constraint |
|------|--------|------------|
| Core workflow (#4038, #4011, #4013) | **Done** — Wave 3 | Transcript run cards + `.codewhale/workflow-runs.jsonl` journal + optional `verify` completion gates |
| Workflow hazards (H1H3) | **Verified 2026-07-07** — Wave 1 | `workflow.rs` + `vm.rs`; `cargo test … --locked workflow` 20/20, `codewhale-workflow` 73/73, `codewhale-workflow-js` pass; regression tests `completion_from_manager_fails_closed_when_status_stays_running`, `workflow_cancel_interrupts_vm_and_blocks_further_spawns`, `workflow_budget_spent_delegates_to_manager_scope`; uncommitted in worktree |
| TUI stability (#1830, #1338, #1198, #2317) | **Done** — Wave 2 | Input fairness + steer hardening + modal submit + snapshots + queue toasts; **verified 2026-07-07**`cargo check -p codewhale-tui --bin codewhale-tui` clean; Wave 2 targeted tests 4/4 pass after Wave 7 `AppMode::Multitask`/`Operate` match exhaustiveness (`status.rs`, `core.rs`, `config_ui.rs`, `header.rs`, `authority.rs`, `engine.rs`, `footer.rs`, `widgets/mod.rs`) |
| Deep-dive security/infra (DD #1#5, app-server) | **Done** — Wave 4A | #1 auth proxy, #2 HTTP status, #4 execpolicy layer, #5 atomic save, #14#15, #20#21 |
| Deep-dive core/runtime (DD #10#18, MCP) | **In motion** — Wave 4b | Paused jobs, checkpoints, tool timeout |
| Hotbar adapters (#20682070, #3380) | **Done 2026-07-07** — Wave 5 | #2067 done (close); #2068/#2069 implemented (prefill/skill-alias dispatch); #3380 footer contrast TEXT_MUTED + regression test; #2070 audited → close; `cargo test … -- hotbar approval` 258/258 |
| Provider/model picker + lake (local #1#8) | **Partial** — Wave 5b P0 done | Lake facade + picker wiring + bundled flash sync; P1 live cache + `OFFERING_SEEDS` dedupe remain — see [ethos audit](#progressive-disclosure--provider-lake-ethos-audit--2026-07-07) |
| TUI copy dedupe (copy slop audit) | **Done** — Wave 5c | P1 #1#6 + P2 #7/#8/#9/#10/#12 shipped in `v0867-pr4047`; open P2: context % (#11), mode picker (#13), fleet phrasing (#14), setup hints (#15), YOLO repeat toast (#16), shell footer dup (#18) — see [copy slop audit](#uiux-copy-slop-audit-2026-07-07) |
| Platform (#1327, #1675, #1854) | **Investigated 2026-07-07** — Wave 6 | #1327: already fixed by #2468, reporter-confirmed — close, no action. #1675: no code bug found (stream pipeline is grapheme/width-safe end-to-end); needs live CJK repro — defer 0.9.0. #1854: fix = `wt.exe`-preferring `.bat` launcher in release packaging; needs Hunter approval; not a 0.8.68 blocker |
| Modes & permissions (Multitask, M1M6) | **Done** — Wave 7 | M1M6 shipped; Operate = Fleet operator (`operate.md` + spawn policy); all `AppMode` match arms exhaustive; close #3386/#3387 |
| Defer/close (#3324, #1890-series) | **Done** — 15 issues trimmed | Scope reduction |
---
## Issue inventory (v0.8.68-tagged)
| # | Title | Type | Priority | Status |
|---|-------|------|----------|--------|
| 3380 | Approval modal key hints more prominent | UX | P2 | **Done (Wave 5)** — footer hints TEXT_HINT→TEXT_MUTED |
| 3324 | mosaic-compress library recommendation | Community | — | **Close** |
| 2342 | File preview on click | Enhancement | P3 | Defer |
| 2317 | Long reply blocks further input | Bug | P1 | Wave 2 |
| 2070 | Hotbar: plugins source (exploratory) | Enhancement | P3 | Audit → close |
| 2069 | Hotbar: skills source | Enhancement | P2 | **Done (Wave 5)**`SkillHotbarActionSource` via `$skill` alias |
| 2068 | Hotbar: MCP tools source | Enhancement | P2 | **Done (Wave 5)** — prefill-only MCP source |
| 2067 | Hotbar: slash commands source | Enhancement | P2 | **Close (done)** |
| 2061 | Hotbar umbrella | Epic | — | Update checklist |
| 1862 | TUI read stuck | Bug | P1 | Wave 2 |
| 1830 | Input freeze / progress loss | Bug | P0 | Wave 2 |
| 1338 | Enter causes GUI crash (Windows) | Bug | P0 | Wave 2 |
| 1327 | FreeBSD dispatch timeout | Bug | P2 | Wave 6 investigate |
| 1198 | No response on key input | Bug | P1 | Wave 2 |
| 1165 | Settings border rendering (Windows) | Bug | P2 | P2 cosmetic — defer if time |
---
## Deep-dive additions (2026-07-07)
Full report: [`CODEWHALE_0_8_68_DEEP_DIVE.md`](CODEWHALE_0_8_68_DEEP_DIVE.md)
Parallel scouts across all 17 crates + web frontend found **64 numbered items**
(~75 raw bugs) independent of the 30-issue milestone bucket. Deduped against
Waves 13:
| Wave overlap | Deep-dive # | Tracker link |
|--------------|-------------|--------------|
| Wave 1 (workflow correctness) | **#3**, **#8**, **#9** | H1H3 hazards |
| Wave 2 (TUI stability) | **#6**, **#7**, **#45** | #1830, #1338, #2317 |
| Wave 3 (workflow UX) | **#4038**, **#4011**, **#4013** | Done — transcript cards, JSONL journal, completion gates |
### Deep-dive disposition (all 64 items)
| # | Sev | Crate | Disposition |
|---|-----|-------|-------------|
| 1 | C | app-server | **Done (Wave 4A)** |
| 2 | C | app-server | **Done (Wave 4A)** |
| 3 | C | tui/workflow | **Done (Wave 1, verified 2026-07-07)** — fail-closed + regression test |
| 4 | C | execpolicy | **Done (Wave 4A)** |
| 5 | C | config | **Done (Wave 4A)** |
| 6 | C | tui | **Done (Wave 2 gap-fill 2026-07-07)**`restart_detached()` un-gated from Windows; Unix stall recovery now restarts pump (ui.rs) + tests |
| 7 | C | tui | **Done (Wave 2 gap-fill 2026-07-07)** — raw-mode probe handshake disables raw mode on abandoned startup probe (ui.rs) + tests |
| 8 | H | tui/workflow | **Done (Wave 1, verified 2026-07-07)** — WorkflowRunController cancels VM + aborts run handle |
| 9 | H | tui/workflow | **Done (Wave 1, verified 2026-07-07)** — budget.spent() wired to manager scope |
| 10 | H | core | **Done (Wave 4b, verified 2026-07-07)**`JobStateStatus::Paused` variant + mapping; test `paused_job_persists_as_paused_not_running` |
| 11 | H | core | **Done (Wave 4b, verified 2026-07-07)** — unarchive refreshes `running_threads` cache; test `unarchive_thread_updates_running_threads_cache` |
| 12 | H | core | **Done (Wave 4b, verified 2026-07-07)**`tool_dispatch_timeout()` wrapper (300s prod) + timeout error frame; test `invoke_tool_returns_timeout_status_for_slow_tools` |
| 13 | H | mcp | **Done (Wave 4b, verified 2026-07-07)** — notifications (id-less) get no JSON-RPC response; test `jsonrpc_notifications_do_not_require_responses` |
| 14 | H | execpolicy | **Done (Wave 4A)** |
| 15 | H | secrets | **Done (Wave 4A)** |
| 16 | H | state | **Done (Wave 4b, verified 2026-07-07)** — checkpoint parse errors propagate; test `load_checkpoint_propagates_invalid_state_json` |
| 17 | H | state/config | **Done (Wave 4b, verified 2026-07-07)**`ProviderChain::current()` no longer indexes empty list; test `current_on_empty_chain_returns_default_provider` |
| 18 | H | state | **Done (Wave 4b, verified 2026-07-07)** — session index compacts at threshold; test `session_index_compacts_after_threshold` |
| 19 | H | app-server | **Done (Wave 4a gap-fill 2026-07-07)**`with_graceful_shutdown` (ctrl_c + SIGTERM) |
| 20 | H | app-server | **Done (Wave 4A)** |
| 21 | H | app-server | **Done (Wave 4A)** |
| 22 | H | tui | **Deferred 0.9.0 (2026-07-07)** — Wave 2 landed; OSC 52 off main loop is polish |
| 23 | H | tui/workflow | **Done (Wave 1/4b)** — interrupt load Relaxed→Acquire, cancel store SeqCst (vm.rs) |
| 24 | H | protocol | **Deferred 0.9.0** |
| 25 | H | app-server | **Done (Wave 4b gap-fill 2026-07-07)** — child reaped on detached thread; Drop no longer blocks runtime |
| 26 | M | core | **Deferred 0.9.0** |
| 27 | M | core | **Deferred 0.9.0** |
| 28 | M | core | **Deferred 0.9.0** |
| 29 | M | execpolicy | **Deferred 0.9.0** |
| 30 | M | mcp | **Deferred 0.9.0** |
| 31 | M | state | **Deferred 0.9.0** |
| 32 | M | state | **Deferred 0.9.0** |
| 33 | M | secrets | **Done (Wave 4b gap-fill 2026-07-07)**`sync_all` before tempfile persist |
| 34 | M | app-server | **Done (Wave 4a gap-fill 2026-07-07)** — constant-time bearer token compare |
| 35 | M | app-server | **Deferred 0.9.0** |
| 36 | M | app-server | **Done (Wave 4a gap-fill 2026-07-07)** — 16 MiB SSE frame bound in `stream_turn_events` |
| 37 | M | app-server | **Done (Wave 4a gap-fill 2026-07-07)** — stdio `shutdown` kills runtime child via `shutdown_child()` |
| 38 | M | app-server | **Deferred 0.9.0** |
| 39 | M | cli | **Deferred 0.9.0 (2026-07-07)** — login provider-switch is a product-behavior decision, not a patch fix |
| 40 | M | cli | **Deferred 0.9.0 (2026-07-07)** — flag validation needs scoping; no crash/security impact |
| 41 | M | cli | **Deferred 0.9.0** (known plaintext storage) |
| 42 | M | hooks | **Done (Wave 4a gap-fill 2026-07-07)** — 10s reqwest timeout on WebhookHookSink client |
| 43 | M | workflow | **Deferred 0.9.0** |
| 44 | M | workflow | **Deferred 0.9.0** |
| 45 | M | tui | **Deferred 0.9.0 (2026-07-07)** — paste-burst infra untouched by Wave 2; no regression, tuning only |
| 46 | M | tui | **Deferred 0.9.0** |
| 47 | M | tui | **Deferred 0.9.0** |
| 48 | M | tui | **Deferred 0.9.0** |
| 49 | M | tui | **Already tracked (#1678)** |
| 50 | M | config | **Done (Wave 4b, verified 2026-07-07)**`ConfigStore::save` uses `atomic_write` + one-time backup on all platforms |
| 51 | M | tools | **Deferred 0.9.0** |
| 52 | L | workflow | **Deferred 0.9.0** |
| 53 | L | workflow | **Deferred 0.9.0** |
| 54 | L | workflow | **Deferred 0.9.0** |
| 55 | L | tui | **Already tracked (#1165)** |
| 56 | L | tui | **Already tracked (#1338)** |
| 57 | L | tui | **Already tracked (#1338)** |
| 58 | L | tui | **Deferred 0.9.0** |
| 59 | L | core | **Deferred 0.9.0** |
| 60 | L | core | **Deferred 0.9.0** |
| 61 | L | web | **Deferred 0.9.0** |
| 62 | L | web | **Deferred 0.9.0** |
| 63 | L | web | **Deferred 0.9.0** |
| 64 | L | web | **Deferred 0.9.0** |
**Wave 4 uncovered count: 27** (4 Critical + 15 High + 8 Medium; excludes Wave 13
in-motion items and deferred/tracked overlap).
---
## OPEN RISKS
1. **Full bucket overload:** 30 issues is 34 weeks; Wave 13 is the realistic ship set.
2. **Windows conhost cluster:** #1338, #1165, #1830 share legacy conhost paths — WT launcher (#1854) mitigates but doesn't fix.
3. **Workflow integration test flake:** `completion_from_manager` race likely root cause (H1 fix should deflake).
4. **Dogfood gap:** `v0867-main-dogfood` has transcript workflow card polish not in `v0867-pr4047` — port before #4038.
5. **Deep-dive surface area:** 64 new items expand scope beyond the 30-issue bucket; Wave 4 (security + core) is required for a credible ship alongside Waves 12.
---
## Implementation lane
- **Worktree:** `/Users/hunter/Desktop/Harnesses/CW/.cw-worktrees/v0867-pr4047`
- **Branch:** `work/v0.9.0-cutover`
- **Base version:** `0.8.67` (from PR #4047)
- **Target version:** `0.8.68`
## Verification gate
```bash
cargo fmt --all --check
cargo clippy --workspace --all-features --locked -D warnings
cargo test -p codewhale-tui --bin codewhale-tui --locked
cargo test -p codewhale-workflow --locked javascript
./scripts/release/check-versions.sh
```
Manual: Win11 Enter-during-busy (#1338), 15+ min turn follow-up (#2317), approval Enter (#1198), `/workflow cancel` mid-run.
---
## Quick-win cutover verification (2026-07-07)
Verified in `.cw-worktrees/v0867-pr4047` on `work/v0.9.0-cutover`.
Topology before the quick-win commit: `origin/main...HEAD = 0 behind / 9 ahead`
with `origin/main` at `cdb52ee48` and branch head `883f94df6`.
**Verified complete in this layer**
- **S1.1 OpenRouter live parser:** `parse_openrouter_models_response` maps
limits, pricing, reasoning support, and modalities into `CatalogOffering`.
- **Provider lake live bridge:** `refresh_catalog_cache` now publishes fresh
cache snapshots into `provider_lake`; the remaining #3385 work is scheduling
or invoking refresh from UI/runtime surfaces.
- **S3 picker search:** provider picker search matches provider name and model
ids across configured/catalog views.
- **S4 concept-map cleanup:** `ThreadStatus` dedupe, `ToolDescriptor` rename,
`Status` trait, and `MODEL_ALIAS_PRECEDENCE.md` are present.
- **S5 copy cleanup:** verified items 5.1-5.6 plus compact foreground shell
wait and stale test copy updates are in this layer; remaining copy items stay
open in the audit table.
- **S6 perf quick wins:** the verified subset is present; do not mark the full
perf list complete until the remaining unchecked S6 items are implemented.
- **Wave 2 wait-state behavior:** streaming Enter queues follow-up; model
waiting Enter steers immediately; double-tap still steers while streaming.
**Local verification**
- `cargo fmt --all --check` — pass
- `cargo clippy --workspace --all-features --locked -- -D warnings ...` — pass
- `cargo test -p codewhale-tools --locked` — pass, 18 tests
- `cargo test -p codewhale-tui --bin codewhale-tui --locked --quiet` — pass,
**5976 passed / 0 failed / 2 ignored**
- `cargo test -p codewhale-workflow --locked javascript --quiet` — pass,
6 tests
- `./scripts/release/check-versions.sh` — pass, workspace/npm/lockfile in sync
**Still open before merge to main**
- Push the quick-win commit to `origin/work/v0.9.0-cutover` so PR #4099 runs CI
on this exact state.
- Check PR #4099 macOS CI after push; the earlier red macOS job ran against the
old committed head and is not evidence for or against this quick-win layer.
- Fleet/AgentProfile cutover remains open: Fleet should keep execution
durability (`manager.rs`, `ledger.rs`, `executor.rs`, `task_spec.rs`) while
consuming canonical AgentProfiles instead of maintaining a separate
loadout/model-class profile system.
- Catalog consumer migration beyond S1.1/S3, Section 2 workflow UI/launch, and
unchecked copy/perf items remain open unless separately verified in code.
*Last updated: 2026-07-07 after quick-win layer verification and tracker
correction.*
+76
View File
@@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org),
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html).
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq](https://www.contributor-covenant.org/faq).
Translations are available at [https://www.contributor-covenant.org/translations](https://www.contributor-covenant.org/translations).
+393
View File
@@ -0,0 +1,393 @@
# Contributing to codewhale
Thank you for your interest in contributing to codewhale! This document provides guidelines and instructions for contributing.
## Getting Started
### Prerequisites
- Rust 1.88 or later (edition 2024)
- Cargo package manager
- Git
### Setting Up Development Environment
1. Fork and clone the repository:
```bash
git clone https://github.com/YOUR_USERNAME/CodeWhale.git
cd CodeWhale
```
2. Build the project:
```bash
cargo build
```
3. Run tests:
```bash
cargo test --workspace --all-features
```
4. Run with development settings:
```bash
cargo run --bin codewhale
```
## Development Workflow
### Code Style
- Run `cargo fmt` before committing to ensure consistent formatting
- Run `cargo clippy` and address all warnings
- Follow Rust naming conventions (snake_case for functions/variables, CamelCase for types)
- Add documentation comments for public APIs
### Testing
- Write tests for new functionality
- Ensure all existing tests pass: `cargo test --workspace --all-features`
- Colocate unit tests beside the code they cover (standard Rust `#[cfg(test)]`
modules), and add integration tests under the owning crate's `tests/`
directory (for example `crates/tui/tests/` or `crates/state/tests/`). The
repository root `tests/` directory is not used
### Commit Messages
Use clear, descriptive commit messages following conventional commits:
- `feat:` New feature
- `fix:` Bug fix
- `docs:` Documentation changes
- `refactor:` Code refactoring
- `test:` Adding or updating tests
- `chore:` Maintenance tasks
Example: `feat: add doctor subcommand for system diagnostics`
When a commit harvests code from a community PR (see "How Your Contribution
Lands" below), include a `Harvested from PR #N by @author` line in the commit
body. An auto-close workflow watches for this pattern and closes the
referenced PR with credit so the contributor gets a clear signal that
their work shipped.
## How Your Contribution Lands
We follow a deliberate "land what's useful, credit the contributor" model
that occasionally surprises new contributors. Two paths:
### Path 1 — Direct merge
If your PR is well-scoped, passes CI, doesn't touch the trust-boundary
surface (auth / sandbox / publishing / branding), and doesn't conflict
with main, a maintainer merges it directly. This is the most common
outcome for small bug fixes and well-tested feature additions.
### Path 2 — Harvest
If your PR is large, mixes scope, conflicts with main, or needs polish
that's faster for the maintainer to apply than to round-trip with the
contributor, the maintainer may **harvest** the useful commits or hunks
into a new commit on `main` rather than merging the PR directly. This is
**not a rejection** — it means your code landed.
When this happens:
- The harvested commit's message includes `Harvested from PR #N by
@your-handle`. This is the contract: that line is your credit and the
signal that your contribution shipped.
- If the maintainer copies or adapts your code, the harvested commit also
keeps attribution with the original author identity when possible: either by
preserving the commit author on a cherry-pick or by adding a
`Co-authored-by: Name <id+login@users.noreply.github.com>` trailer. This is
what lets GitHub's contribution surfaces recognize more than prose credit.
Maintainers should use `.github/AUTHOR_MAP`, or run
`gh api users/<login> --jq '"\(.id)+\(.login)@users.noreply.github.com"'`,
rather than copying raw, `.local`, or old-style noreply emails from a
contributor's machine.
- The `CHANGELOG.md` entry for the next release credits you by handle.
- The auto-close workflow closes your PR with a templated thank-you and
a link to the commit on `main`.
When a maintainer closes a harvested PR by hand, the closing comment
follows this template (the pattern set on PR #2634):
```text
Closing with harvest credit, @handle — <what landed> landed via
<commit sha(s) or PR #N>. <If work remains:> The remainder is tracked
in #NNN — follow-ups welcome there.
Thank you for <one specific thing the contribution got right>.
```
Three required elements: the contributor's handle, the exact commits or
PRs where their work landed, and — when the PR contained more than what
landed — a tracking issue for the remainder. A harvested PR is never
closed with a bare "superseded".
To make a future contribution land via the faster Direct-Merge path
instead of the Harvest path, the highest-leverage things you can do are:
1. **Keep PRs single-purpose.** One bug fix per PR; one feature per PR.
Don't mix a refactor with a feature.
2. **Rebase onto current `main` before opening the PR**, and after CI
feedback. Conflicts force the harvest path even when the change is
small.
3. **Include tests** with new behavior. The maintainer often harvests
PRs without tests because adding the test is faster than asking the
contributor for one.
4. **Avoid the trust-boundary surface** without prior maintainer
sign-off. That includes auth/credential flows, sandbox policy,
publishing/release plumbing, and `prompts/` content. PRs that touch
these without prior discussion are unlikely to merge directly even
when the change is well-implemented.
## Layered and EPIC-Sized Work
Some architecture work is too large for one PR but still needs to be built in
dependent layers. For those changes, use this workflow:
1. Start with a tracking issue or EPIC when the work spans multiple PRs. Name
the intended slices and state what each slice is not trying to close yet.
2. Keep each implementation PR focused on one behavior boundary.
3. Later layers may stay in your fork or open as draft PRs while the lower
layer is still moving. Draft stacked PR titles or descriptions should say
`Draft / depends on #NNNN`.
4. A dependent PR is not ready for merge review until the lower layer has
landed, the branch has been rebased onto current `main`, and the PR targets
`main`.
5. The PR body should identify which earlier PR it builds on, what is in scope,
what is explicitly out of scope, which issues it references, and which local
commands were run.
6. Use `Closes #...` only when the slice fully satisfies an issue. Use
`Refs #...` with a short `(partial)` note when the PR advances a broad issue
but leaves follow-up work.
7. Structured commits are fine during review. Maintainers may squash or harvest
at merge time, with contributor credit preserved through authorship,
co-author trailers, changelog entries, or PR/issue comments. When the merge
commit itself carries a `Harvested from PR #N by @author` line, that PR is
merged with rebase or a merge commit rather than squashed, so the line
reaches `main` intact and the auto-close credit fires.
Before asking for merge review on a layered PR, check that it is:
- rebased onto current `main`
- marked ready for review, not draft
- focused to one behavior boundary
- backed by local command evidence in the PR body
- green in CI, or has any remaining red lane clearly explained
- covered by round-trip or migration-preservation tests when it changes config
or schema behavior
- referencing broad issues as partial unless it really closes them
For layered work, a useful PR description shape is:
```text
Summary:
Scope:
Not in this slice:
Builds on:
Issues:
Validation:
```
## The Stewardship Branch
Large refactors and architecture work stage on
`codex/v0.9.0-stewardship` before reaching `main`. The branch exists so
that multi-layer series (like the command-group refactor) can land layer
by layer against a stable base, get validated by their parity harnesses,
and then flow to `main` in periodic stewardship merges — instead of each
layer racing `main`'s daily churn.
What this means for you:
- **Base layered/EPIC-sized refactor PRs on `codex/v0.9.0-stewardship`**
and target the PR there (see #2888 for the model). Ordinary bug fixes
and features still target `main`.
- Maintainers merge the stewardship branch into `main` periodically;
your work reaches `main` with its history and credit intact.
- If you're unsure which base to use, ask in your tracking issue — the
default for anything that isn't a multi-PR series is `main`.
## Contribution Gate
CodeWhale uses a maintainer-managed contribution gate for the community front
door. Maintainers and collaborators bypass this gate automatically. The gate
workflows default to dry-run / comment-only mode so maintainers can observe the
signal before changing contributor flow.
The maintainer posture is documented in
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): automation should reduce load while
keeping good-faith contributors seen, credited, and able to keep helping.
Issues are never auto-closed by the contribution gate. Unapproved external
issues receive a short welcome note that asks for reproduction details and then
remain open for maintainer triage. CodeWhale depends on real edge cases from
real users, so issue intake should stay warm and open.
Pull requests are different because they can touch code, CI, release plumbing,
auth, sandboxing, provider policy, and other trust-boundary surfaces. The PR
gate can be switched from dry-run to enforcement when maintainers decide they
need that safety control, but it should be treated as a review-load control,
not a judgment on contributor quality. Before enabling PR enforcement, seed the
allowlist broadly enough for active external contributors who should not be
interrupted by the rollout.
The allowlist is scoped:
- `pr:username` allows pull requests.
- `issue:username` allows issues.
- `all:username` allows both.
A maintainer can approve someone by commenting `/lgtm` on a pull request for PR
access, or `/lgtmi` on an issue for issue access. The exact bare commands
`lgtm` and `lgtmi` are also accepted for compatibility, but the prefixed forms
are preferred because they are harder to trigger accidentally in ordinary review
discussion.
Approvals do not edit `main` directly. The approval workflow opens a small
allowlist update PR so the new entry is reviewable before it takes effect.
If the PR gate fires on a good contributor incorrectly, use the same approval
flow to restore them: comment `/lgtm`, merge the generated allowlist PR, then
reopen the affected pull request. If GitHub will not allow the closed PR to be
reopened, ask the contributor to resubmit after the allowlist PR is merged.
## Agent-Assisted Improvements
CodeWhale is allowed to help improve CodeWhale, but the contribution still has
to be shaped for human review. The recommended workflow is the
[recursive self-improvement prompt](docs/RECURSIVE_SELF_IMPROVEMENT.md): run it
from a fresh fork or branch, let the agent find exactly one small friction point,
and stop after one patch. DeepSeek V4 Pro is the reference path for this loop
today, but any configured provider works — the review shape matters more than
the provider.
Agents and maintainers should follow the stewardship posture in
[docs/AGENT_ETHOS.md](docs/AGENT_ETHOS.md): use automation for evidence,
verification, and narrow patches while keeping the final community decision
human-reviewed.
The useful output is not "ideas for improvement." The useful output is a
specific reproduction, a minimal diff, focused checks, and a PR description that
explains the trade-off. Do not use an agent to touch auth, credentials, sandbox
policy, publishing/release plumbing, provider policy, telemetry, sponsorship,
branding, or global prompts without prior maintainer sign-off.
## Project Structure
codewhale is a Cargo workspace. The live runtime and the majority of TUI,
engine, and tool code currently live in `crates/tui/src/`. Smaller workspace
crates provide shared abstractions that are being extracted incrementally.
```
crates/
├── tui/ codewhale-tui binary (interactive TUI + runtime API)
├── cli/ codewhale binary (dispatcher facade)
├── app-server/ HTTP/SSE + JSON-RPC transport
├── core/ Agent loop / session / turn management
├── protocol/ Request/response framing
├── config/ Config loading, profiles, env precedence
├── state/ SQLite thread/session persistence
├── tools/ Typed tool specs and lifecycle
├── mcp/ MCP client + stdio server
├── hooks/ Lifecycle hooks (stdout/jsonl/webhook)
├── execpolicy/ Approval/sandbox policy engine
├── agent/ Model/provider registry
```
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the live data flow across
these crates, including the bottom-up build order.
## Submitting Changes
1. Create a feature branch from `main`:
```bash
git checkout -b feat/your-feature
```
2. Make your changes and commit them
3. Ensure CI passes:
```bash
cargo fmt --all -- --check
cargo clippy --workspace --all-targets --all-features
cargo test --workspace --all-features
```
4. Push your branch and create a Pull Request
5. Describe your changes clearly in the PR description
## Pull Request Guidelines
- Use the [pull request template](.github/PULL_REQUEST_TEMPLATE.md) when opening
a PR — it includes the Summary, Testing, and Checklist sections reviewers
expect
- Keep PRs focused on a single change
- Update documentation if needed
- Add tests for new functionality
- Ensure CI passes before requesting review
## Shape of a Typical PR
A well-structured PR follows a consistent pattern. Recent exemplars include:
- **#386** — `/init` command: new `crates/tui/src/commands/groups/project/init.rs` module, project-type detection,
AGENTS.md generation, command registration in `commands/mod.rs`, localization strings.
- **#389** — Inline LSP diagnostics: LSP subsystem in `crates/tui/src/lsp/`, engine hooks in
`crates/tui/src/core/engine/lsp_hooks.rs`, config toggle, test coverage.
- **#387** — Self-update: new `crates/cli/src/update.rs` module, CLI subcommand registration,
HTTP download + SHA256 verification + atomic binary replacement.
- **#393** — `/share` session URL: new `crates/tui/src/commands/groups/project/share.rs`, HTML rendering,
`gh gist create` integration, command registration.
- **#343/#346** — (v0.8.5) Runtime thread/turn timeline and durable task manager refactors.
Typically each PR touches 13 new files, modifies 25 existing files for wiring
(registries, dispatch matches, localization), and adds or updates tests. Changes
are scoped to a single feature or fix — if you discover related work that needs
doing, open a separate issue rather than expanding the PR scope.
Before submitting, run:
```bash
cargo fmt --check
cargo clippy --workspace --all-targets --all-features 2>&1 | head -50
cargo check
```
## Reporting Issues
When reporting issues, please use one of the issue templates:
- [Bug report](.github/ISSUE_TEMPLATE/bug_report.md) — for reproducible problems
or regressions
- [Feature request](.github/ISSUE_TEMPLATE/feature_request.md) — for ideas and
improvements
Issue reports should include:
- Operating system and version
- Rust version (`rustc --version`)
- codewhale version (`codewhale --version`)
- Steps to reproduce the issue
- Expected vs actual behavior
- Relevant error messages or logs
## Security
If you discover a security vulnerability, please do **not** open a public issue.
See [SECURITY.md](SECURITY.md) for the responsible disclosure process and
contact information.
## Code of Conduct
Be respectful and inclusive. We welcome contributors of all backgrounds and
experience levels. See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for the full
code of conduct.
## License
By contributing to codewhale, you agree that your contributions will be licensed under the MIT License.
## Questions?
Feel free to open an issue for any questions about contributing.
Generated
+7415
View File
File diff suppressed because it is too large Load Diff
+73
View File
@@ -0,0 +1,73 @@
[workspace]
members = [
"crates/agent",
"crates/app-server",
"crates/build-support",
"crates/cli",
"crates/config",
"crates/core",
"crates/execpolicy",
"crates/hooks",
"crates/lane",
"crates/mcp",
"crates/protocol",
"crates/release",
"crates/secrets",
"crates/state",
"crates/tools",
"crates/tui",
"crates/workflow",
"crates/workflow-js",
]
default-members = ["crates/cli", "crates/app-server", "crates/tui"]
resolver = "2"
[workspace.package]
version = "0.8.68"
edition = "2024"
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
# codebase relies on extensively. Cargo enforces this so users on older
# toolchains get a clear "package requires rustc 1.88+" error instead of a
# confusing E0658 from rustc.
rust-version = "1.88"
license = "MIT"
repository = "https://github.com/Hmbown/CodeWhale"
[workspace.dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
axum = { version = "0.8.5", features = ["json"] }
chrono = { version = "0.4.43", features = ["serde"] }
clap = { version = "4.5.54", features = ["derive"] }
clap_complete = "4.5"
dirs = "6.0.0"
jsonschema = { version = "0.46", default-features = false }
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls-no-provider", "socks"] }
# NOT "parallel": the Workflow VM stays single-threaded and bridges to the
# multi-thread engine over channels (see crates/workflow-js).
rquickjs = { version = "0.12", features = ["futures"] }
rustls = { version = "0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.39.0", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
semver = "1.0.28"
thiserror = "2.0"
tempfile = "3.27"
tokio = { version = "1.50.0", features = ["fs", "io-util", "io-std", "macros", "net", "process", "rt", "rt-multi-thread", "signal", "sync", "time"] }
toml = "1.0.6"
toml_edit = "0.25.12"
sha2 = "0.11"
tower-http = { version = "0.7", features = ["cors"] }
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
uuid = { version = "1.11", features = ["v4"] }
mimalloc = { version = "0.1", default-features = false }
[profile.release]
lto = true
strip = true
# NOTE: no `panic = "abort"` here — the TUI's panic supervision
# (catch_unwind/spawn_supervised) needs unwinding so one panicking tool call
# or task fails gracefully instead of aborting the whole session.
codegen-units = 1
+95
View File
@@ -0,0 +1,95 @@
# syntax=docker/dockerfile:1
# CodeWhale multi-arch Docker image (#501)
#
# Build: docker buildx build --platform linux/amd64,linux/arm64 -t codewhale:latest .
# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.codewhale codewhale
#
# The image ships the canonical binaries (`codewhale`, `codewhale-tui`) plus
# the legacy `deepseek` / `deepseek-tui` shims in a minimal runtime layer.
#
# API keys MUST be passed at runtime (never baked into the image):
# docker run --rm -it -e DEEPSEEK_API_KEY codewhale
# Or mount an env file:
# docker run --rm -it --env-file .env codewhale
ARG RUST_VERSION=1.88
# ── Stage 1: Build ────────────────────────────────────────────────────
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
ARG TARGETPLATFORM
ARG TARGETARCH
ARG BUILDPLATFORM
ARG DEEPSEEK_BUILD_SHA
ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
PKG_CONFIG_ALLOW_CROSS=1 \
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \
DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}
RUN if [ "${TARGETARCH}" = "arm64" ] && [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \
dpkg --add-architecture arm64; \
fi \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
pkg-config libdbus-1-dev \
&& if [ "${TARGETARCH}" = "arm64" ] && [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \
apt-get install -y --no-install-recommends \
gcc-aarch64-linux-gnu libc6-dev-arm64-cross libdbus-1-dev:arm64; \
fi \
&& rm -rf /var/lib/apt/lists/*
# Translate Docker platform into Rust target triple.
# linux/amd64 → x86_64-unknown-linux-gnu
# linux/arm64 → aarch64-unknown-linux-gnu
RUN case "${TARGETPLATFORM}" in \
linux/amd64) echo x86_64-unknown-linux-gnu > /rust-target ;; \
linux/arm64) echo aarch64-unknown-linux-gnu > /rust-target ;; \
*) echo "Unsupported platform: ${TARGETPLATFORM}" >&2; exit 1 ;; \
esac
RUN rustup target add "$(cat /rust-target)"
WORKDIR /build
COPY . .
# Build both binaries for the target platform. --locked ensures
# reproducible builds from the committed lockfile.
RUN --mount=type=cache,id=codewhale-target-${TARGETARCH},target=/build/target,sharing=locked \
--mount=type=cache,id=codewhale-cargo-registry-${TARGETARCH},target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=codewhale-cargo-git-${TARGETARCH},target=/usr/local/cargo/git,sharing=locked \
rustup target add "$(cat /rust-target)" \
&& cargo build --release --locked --target "$(cat /rust-target)" \
-p codewhale-cli -p codewhale-tui \
&& mkdir -p /out \
&& cp target/$(cat /rust-target)/release/codewhale /out/ \
&& cp target/$(cat /rust-target)/release/codewhale-tui /out/
# ── Stage 2: Runtime ──────────────────────────────────────────────────
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
libdbus-1-3 \
&& rm -rf /var/lib/apt/lists/*
# Non-root user with explicit UID/GID for filesystem ownership clarity.
RUN groupadd --gid 1000 codewhale \
&& useradd --create-home --shell /bin/bash --uid 1000 --gid 1000 codewhale \
&& install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.codewhale \
&& install -d -m 0700 -o codewhale -g codewhale /home/codewhale/.deepseek \
# Legacy entrypoints from the deepseek-tui era; the real binaries are
# copied in below (symlinks may dangle until then).
&& ln -s /usr/local/bin/codewhale /usr/local/bin/deepseek \
&& ln -s /usr/local/bin/codewhale-tui /usr/local/bin/deepseek-tui
USER codewhale
WORKDIR /home/codewhale
COPY --from=builder --chown=codewhale:codewhale /out/codewhale /usr/local/bin/codewhale
COPY --from=builder --chown=codewhale:codewhale /out/codewhale-tui /usr/local/bin/codewhale-tui
# The dispatcher expects to find its companion binary next to it.
# Both are in /usr/local/bin — no further path setup needed.
ENTRYPOINT ["codewhale"]
CMD []
+141
View File
@@ -0,0 +1,141 @@
# CodeWhale 0.8.68 Handoff — Finish the Landing
> **Status (2026-07-12): Historical / completed.** Every actionable item below
> either landed by v0.8.68 (multipart/brotli removal, palette migration,
> allowed_tools deletion, tools_file/removed_messages cleanup, double-Enter
> steer) or was deliberately superseded — item 7's "remove `todo_*` aliases at
> 0.9.0" went the other way: #4132 keeps `todo_*`/`checklist_*` as hidden
> replay aliases, so do not execute it. The DO-NOT-DELETE module table below
> remains a standing guardrail. The companion `opportunities.md` was never
> committed; its link is dangling by design.
>
> ~~**Companion document:** [`opportunities.md`](../../opportunities.md)~~ (not
> in the repository) — this file was the *what to do next*; the catalog behind
> it stayed private.
## Branch & Location
- **Worktree:** `/Users/hunter/Desktop/Harnesses/CW/.cw-worktrees/v0867-pr4047`
- **Branch:** `work/v0.9.0-cutover` (7 ahead of origin/main, 0 behind)
- **PR:** #4099 open to main
- **Remote:** `Hmbown/CodeWhale`
## Current State (verified)
- `cargo check --workspace` ✅ PASSES
- `cargo clippy --workspace --all-features -- -D warnings` ✅ PASSES
- 16 files changed in working tree (unstaged, not committed yet)
## ⛔ DO NOT DELETE — Verified Active
These six modules were flagged as "dead code" by the original scout audit.
**They are all actively imported and used.** Previous agents deleted them and
caused 19+ compile errors. Do not touch them under any circumstances.
| Module | Active consumers |
|--------|-----------------|
| `tui/src/memory.rs` | `prompts.rs`, `engine.rs`, `context_report.rs`, `ui.rs` |
| `tui/src/context_budget.rs` | `core/engine/context.rs:9`, `engine/tests.rs` |
| `tui/src/model_registry.rs` | `tui/model_picker.rs:737`, `model_profile.rs:149` |
| `tui/src/prompt_zones.rs` | `core/session.rs:8`, `core/engine/turn_loop.rs:10` |
| `tui/src/tools/remember.rs` | `tools/registry.rs:890`, `tools/mod.rs:41` |
| `config/src/route/` (entire dir) | `catalog.rs:38`, `models_dev.rs:20`, `pricing.rs:25` |
---
## What's Actually Done (verified in working tree)
| Item | Status | Evidence |
|------|--------|----------|
| B1.1 — mimalloc for CLI | ✅ Done | `crates/cli/Cargo.toml:38` + `crates/cli/src/main.rs:1-2` |
| B1.6 — pdf-extract feature-gated | ✅ Done | `Cargo.toml:17` (`pdf = ["dep:pdf-extract"]`), `web_run.rs` `#[cfg(feature = "pdf")]` |
| B9.1 — `to_vec` in app-server | ✅ Done | `app-server/src/lib.rs:293,309,325,336,1155` |
| B9.2 — compact JSON tool output | ✅ Done | `tools/src/lib.rs`, `tool_execution.rs`, `registry.rs` — no `to_string_pretty` in these paths |
| B8.2 — spawn_blocking in tasks.rs | ✅ Done | `tasks.rs:769,848,935` |
| file.rs perf tweaks | ✅ Done | `tools/file.rs` modified |
| Palette migration (partial) | ✅ 3 files | `logging.rs`, `remote_setup/mod.rs`, `palette_audit.rs` — done |
| Double-enter tests | ✅ Written | `tui/app/tests.rs``enter_with_double_tap()`, `last_enter_instant` |
## What the PREVIOUS Handoff Claimed Was Done — But ISN'T
| Claim | Reality |
|-------|---------|
| B1.4 — reqwest multipart removed | ❌ **Still in Cargo.toml**: `features = [..., "multipart", ...]` |
| B1.5 — reqwest brotli removed | ❌ **Still in Cargo.toml**: `features = [..., "brotli"]` |
| allowed_tools() deleted | ❌ **Still present** at `subagent/mod.rs:448` |
| smoothness.md deleted | ❌ **Still present** (29,613 bytes) |
---
## Remaining Work (in priority order, compile after each)
### 1. Remove reqwest `multipart` + `brotli` features
- **File:** `crates/tui/Cargo.toml` — remove `"multipart"` and `"brotli"` from
the `reqwest` features array. Zero uses of `reqwest::multipart` exist. `gzip`
alone is sufficient.
- **Risk:** None. Verify `cargo check` after.
### 2. Palette brand migration (`DEEPSEEK_*` → `WHALE_*`)
- See the 🎨 section in `opportunities.md` for full details.
- **⚠️ main.rs is NOT part of this.** Its 98 `DEEPSEEK_*` refs are **environment
variable names** (`DEEPSEEK_API_KEY`, `DEEPSEEK_BASE_URL`, etc.). Renaming
them breaks every user's config. Leave them alone.
- The actual migration targets ~216 `palette::DEEPSEEK_*` color references
across 40+ files in `crates/tui/src/`.
- **Missing prerequisite:** No `WHALE_INFO`/`WHALE_BG`/`WHALE_PANEL`/`WHALE_ERROR`
`Color` constants exist (only `_RGB` tuples). Add them to `tokens.rs` first.
- **Plan:** Add Color constants → `sed` replace `_RGB` variants first, then bare
names → add `#[deprecated]` to now-unused aliases → compile.
### 3. Delete `allowed_tools()` method
- **File:** `crates/tui/src/tools/subagent/mod.rs:448` — delete the method and
its match arms (deprecated since v0.6.6). Keep the struct fields at lines 1261
and 1363.
### 4. Delete `smoothness.md`
- **File:** `smoothness.md` (29,613 bytes) — unreferenced doc file.
### 5. Remove `tools_file` config field
- `config.rs:1881` — remove `pub tools_file: Option<String>` field
- `config.rs:5513` — remove merge line
- `config.example.toml:162` — remove commented `# tools_file` line
### 6. Remove `removed_messages` dead field
- `compaction.rs:918` — remove field with `#[allow(dead_code)]` + `TODO(v0.8.71)`
comment. Dead in production.
### 7. Remove `todo_*` alias scaffolding (v0.9.0 gate)
- `tools/todo.rs:177-178` — remove `TODO_ALIAS_FIRST_DEPRECATED_VERSION`,
`TODO_ALIAS_REMOVAL_VERSION` constants and `is_compat_alias()` (line 182)
- `tools/registry.rs` — remove `todo_*` registrations (keep `checklist_*`)
### 8. B8.1 — spawn_blocking for blocking cmd.output()
- `tools/git_history.rs:485` — wrap `cmd.output()` in `tokio::task::spawn_blocking`
- `tools/review.rs:645,672` — same
### 9. B5.2 — clippy await_holding_lock audit
- Run `cargo clippy --workspace --all-features -- -W clippy::await_holding_lock -W clippy::await_holding_refcell_ref`
- Fix any warnings (guards held across `.await`).
### 10. Double-Enter for Steer (feature implementation)
Tests exist in `tui/app/tests.rs` but the feature code does not:
1. Add `last_enter_instant: Option<Instant>` field to `App` struct in `tui/app.rs`
2. Add `enter_with_double_tap(&mut self) -> Option<SubmitDisposition>` method
3. In `decide_submit_disposition()`, change busy-waiting arm to return `Queue`
instead of `Steer`
4. Wire up in `tui/ui.rs` Enter handler
5. Update composer hint text in `tui/widgets/mod.rs`
---
## Final Steps
1. Commit all changes with descriptive message
2. Push to `work/v0.9.0-cutover`
3. Verify PR #4099 CI passes (fix any failures)
4. Run `cargo test --workspace --locked` to verify tests pass
5. Build release binary: `cargo build --release -p codewhale-tui`
## Guidelines
- Work file-by-file, compile after each change
- NEVER delete a file without first grepping for ALL imports of that module
- For dead code removal: prefer adding `#[allow(dead_code)]` over deleting files
with active imports
- Commit in logical chunks, not one giant commit
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024-2025 DeepSeek CLI Contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+85
View File
@@ -0,0 +1,85 @@
# Model Alias Precedence
This document describes how model aliases (like `"deepseek-v4-pro"`) are resolved
when they collide across multiple providers in `crates/agent/src/lib.rs`.
## Resolution Algorithm
The `ModelRegistry` resolves a user-requested model name through these steps, in order:
1. **Provider hint + match.** If a `provider_hint` is supplied, the registry scans
all models matching that provider. The first model whose canonical `id` or
any alias matches the request wins.
2. **Provider-specific passthrough.** Certain providers (Atlascloud, Arcee,
XiaomiMimo) allow any model id through via a passthrough function when the
provider hint matches.
3. **Alias map lookup.** A case-insensitive alias map built from all canonical ids
and aliases. The map uses `HashMap::entry(...).or_insert(idx)`, so the **first
model registered with a given name wins**.
4. **Provider default fallback.** If no match, the first model for the hinted
provider (or DeepSeek if no hint) is returned with `used_fallback = true`.
5. **Global default.** If the provider has no models, falls back to the very first
model in the registry (DeepSeek `deepseek-v4-pro`).
## `"deepseek-v4-pro"` - Which Provider Wins?
Without a `provider_hint`, `"deepseek-v4-pro"` resolves to **DeepSeek**
(`ProviderKind::Deepseek`) because:
- The DeepSeek entry's canonical `id` is `"deepseek-v4-pro"` and it is the very
first model inserted into the alias map (index 0).
- All subsequent entries that list `"deepseek-v4-pro"` as an alias are ignored
(`or_insert` keeps the first value).
With a `provider_hint`, the resolution respects the hint. For example:
- `provider_hint = Some(NvidiaNim)` resolves to NvidiaNim's `deepseek-ai/deepseek-v4-pro`
- `provider_hint = Some(Atlascloud)` resolves to Atlascloud's `deepseek-ai/deepseek-v4-pro`
- `provider_hint = Some(Volcengine)` resolves to Volcengine's `DeepSeek-V4-Pro`
## Providers Registering `"deepseek-v4-pro"`
The alias `"deepseek-v4-pro"` is registered by these providers, in this order.
The first entry (DeepSeek) wins by default; the others require a provider hint.
| Order | Provider | Canonical ID | Wins by default? |
|-------|-------------------|---------------------------------|------------------|
| 1 | **DeepSeek** | `deepseek-v4-pro` | Yes |
| 2 | NvidiaNim | `deepseek-ai/deepseek-v4-pro` | No |
| 3 | Atlascloud | `deepseek-ai/deepseek-v4-pro` | No |
| 4 | Volcengine | `DeepSeek-V4-Pro` | No |
| 5 | Openrouter | `deepseek/deepseek-v4-pro` | No |
| 6 | Novita | `deepseek-ai/deepseek-v4-pro` | No |
| 7 | Fireworks | `deepseek-ai/deepseek-v4-pro` | No |
| 8 | Siliconflow | `deepseek-ai/DeepSeek-V4-Pro` | No |
| 9 | Sglang | `deepseek-ai/deepseek-v4-pro` | No |
| 10 | Vllm | `deepseek-ai/deepseek-v4-pro` | No |
| 11 | Huggingface | `deepseek-ai/deepseek-v4-pro` | No |
| 12 | Together | `deepseek-ai/deepseek-v4-pro` | No |
| 13 | Deepinfra | `deepseek-ai/deepseek-v4-pro` | No |
> **Note:** The Openai provider registers a model with canonical id `"deepseek-v4-pro"`
> but does **not** include `"deepseek-v4-pro"` in its aliases - it only exposes
> `"openai-compatible-deepseek-v4-pro"`. Therefore Openai's entry does not
> participate in the `"deepseek-v4-pro"` alias collision.
## Other Notable Alias Collisions
### `"deepseek-chat"`
Registered by: DeepSeek (canonical), NvidiaNim, Volcengine, Openrouter, Siliconflow.
**Winner by default:** DeepSeek (index win).
### `"deepseek-reasoner"`
Registered by: DeepSeek (alias), NvidiaNim, Openrouter, Siliconflow, WanjieArk (canonical).
**Winner by default:** DeepSeek (index win, via its alias on `deepseek-v4-flash`).
### `"deepseek-v4-flash"`
Registered by: DeepSeek (canonical), NvidiaNim, Openrouter, Atlascloud, Volcengine, Siliconflow, Novita, Fireworks, Sglang, Vllm, Huggingface, Together, Deepinfra.
**Winner by default:** DeepSeek (index win).
### `"glm-5.2"`
Registered by: Openrouter (canonical `z-ai/glm-5.2`), Zai (canonical `GLM-5.2`).
**Winner by default:** Openrouter (registered first).
+116
View File
@@ -0,0 +1,116 @@
<!-- source: README.md sha256:561a074b0e36 -->
# CodeWhale
Un agente de código para tu terminal. Funciona con cualquier modelo; los
modelos abiertos primero.
Le das un proveedor, un modelo y una tarea. Lee código, edita archivos, ejecuta
comandos, verifica los resultados y sigue avanzando hasta que la tarea queda
lista o te necesita. TUI para el trabajo interactivo, `codewhale exec` para
scripts y CI. Rust, MIT, corre completamente en tu máquina.
Empezó como `deepseek-tui`. La comunidad que se formó a su alrededor necesitaba
más proveedores, así que ahora DeepSeek, Claude, GPT, Kimi, GLM y más de 30
otros corren sobre el mismo runtime y las mismas herramientas.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [한국어](README.ko-KR.md) · [Português](README.pt-BR.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
![CodeWhale ejecutándose en una terminal](assets/screenshot.png)
## Instalación
```bash
npm install -g codewhale
```
Cargo, Docker, Nix, Scoop, archivos precompilados, Android/Termux y un espejo
en CNB para quienes no pueden acceder a GitHub están cubiertos en
[docs/INSTALL.md](docs/INSTALL.md). ¿Vienes de `deepseek-tui`? Tu configuración
y tus sesiones se conservan — mira [docs/REBRAND.md](docs/REBRAND.md).
## Uso
```bash
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
```
En la TUI: `/model` cambia proveedor y modelo juntos, `/fleet` ejecuta un
equipo de workers, `/restore` deshace un turno, `Tab` cicla entre
Plan / Act / Operate, `Shift+Tab` cicla la postura de aprobación
Ask / Auto-Review / Full Access, y `!` ejecuta un comando de shell por la ruta
normal de aprobación.
## Qué hace
- Resuelve tu elección de proveedor + modelo a una ruta concreta: endpoint,
wire protocol, límite de contexto, precio. Los presupuestos de contexto y el
costo que se muestra vienen de la ruta real; un precio desconocido se muestra
como desconocido, no como $0.
([docs/PROVIDERS.md](docs/PROVIDERS.md))
- Habla con proveedores que alojan modelos abiertos (`deepseek`, `openrouter`,
`moonshot`, `zai`, `minimax`, `nvidia-nim`, …), con tu propio `vllm` /
`sglang` / `ollama` sin clave, y con Anthropic de forma nativa sobre la
Messages API, con thinking y caché de prompts.
- Ejecuta múltiples workers de forma durable: Fleet registra el trabajo en un
ledger append-only, así que las ejecuciones sobreviven reinicios y
`fleet resume` retoma donde quedaron las cosas. Workflow planifica trabajos
más grandes en carriles reanudables y verificables.
([docs/FLEET.md](docs/FLEET.md))
- Regula el riesgo con código, no con corazonadas: tres modos (Plan es de solo
lectura), una postura de aprobación separada, sandbox a nivel del sistema
operativo (Seatbelt, Landlock + seccomp, bwrap), hooks que pueden
permitir/denegar/preguntar por cada llamada a herramienta, y snapshots en un
git paralelo para que `/restore` nunca toque tu historial real.
- Permite que un repo declare su propia ley: los invariantes de
`.codewhale/constitution.json` se compilan en bloqueos de escritura que ni
siquiera Full Access puede saltarse.
([docs/CONFIGURATION.md](docs/CONFIGURATION.md))
- Habla MCP en ambas direcciones, carga skills reutilizables, expone APIs de
runtime HTTP/SSE y ACP, y respalda una
[GUI para VS Code](https://github.com/HengQuWorld/CodeWhale-VSCode) de la
comunidad.
- La TUI muestra el trabajo como recibos que puedes inspeccionar, mantiene en
movimiento una sola fila en vivo, tiene un inspector de contexto real, 12
temas, modos de movimiento reducido y ASCII seguro, y está disponible en
English, 简体中文, 日本語, Tiếng Việt, Español, Português, 한국어 y 繁體中文
parcial.
Todo lo demás — configuración, atajos de teclado, detalles del sandbox,
arquitectura — está en [docs](docs) y en [codewhale.net](https://codewhale.net/).
## Contribuir
Todo feedback es un regalo. Issues, PRs, pasos de reproducción, logs,
solicitudes de features y primeras contribuciones: todo eso es trabajo real del
proyecto aquí. Cuando un PR no se puede fusionar tal cual, los mantenedores
rescatan lo que funciona y el autor conserva su crédito — en el commit, en el
changelog y en [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md). Si falta un modelo
o proveedor que usas, o algo se rompe en tu máquina, decírnoslo es lo más útil
que puedes hacer.
- [Issues abiertos](https://github.com/Hmbown/CodeWhale/issues) — las buenas
primeras contribuciones viven aquí
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup de desarrollo y flujo de PRs
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — todas las personas que le han
dado forma a esto
- [Invítame un café](https://www.buymeacoffee.com/hmbown)
Gracias a [DeepSeek](https://github.com/deepseek-ai) por los modelos y el apoyo
que dieron inicio al proyecto, a [DataWhale](https://github.com/datawhalechina)
🐋 por recibirnos en la familia Whale Brother, y a
[OpenWarp](https://github.com/zerx-lab/warp) y
[Open Design](https://github.com/nexu-io/open-design) por colaborar en la
experiencia de agente en terminal.
## Licencia
[MIT](LICENSE). Proyecto comunitario independiente; sin afiliación con ningún
proveedor de modelos.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+63
View File
@@ -0,0 +1,63 @@
<!-- source: README.md sha256:561a074b0e36 -->
# CodeWhale
ターミナルで動くコーディングエージェント。あらゆるモデルで動作し、オープンモデルを最優先します。
プロバイダ、モデル、タスクを渡すと、コードを読み、ファイルを編集し、コマンドを実行し、結果を確認して、タスクが完了するかあなたの手が必要になるまで作業を続けます。対話的な作業には TUI を、スクリプトと CI には `codewhale exec` を。Rust 製、MIT ライセンスで、すべて手元のマシン上で動きます。
このプロジェクトは `deepseek-tui` として始まりました。その周りに生まれたコミュニティがより多くのプロバイダを必要としたため、いまでは DeepSeek、Claude、GPT、Kimi、GLM ほか 30 以上のモデルが、同じランタイムと同じツール群を通って動いています。
[English](README.md) · [简体中文](README.zh-CN.md) · [Tiếng Việt](README.vi.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
![ターミナルで動作する CodeWhale](assets/screenshot.png)
## インストール
```bash
npm install -g codewhale
```
Cargo、Docker、Nix、Scoop、ビルド済みアーカイブ、Android/Termux、そして GitHub に到達できないユーザー向けの CNB ミラーについては [docs/INSTALL.md](docs/INSTALL.md) で扱っています。`deepseek-tui` からの移行なら、設定とセッションはそのまま引き継がれます — [docs/REBRAND.md](docs/REBRAND.md) を参照してください。
## 使い方
```bash
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
```
TUI では、`/model` がプロバイダとモデルをまとめて切り替え、`/fleet` がワーカーのチームを走らせ、`/restore` がターンを取り消します。`Tab` は Plan / Act / Operate を順に切り替え、`Shift+Tab` は Ask / Auto-Review / Full Access の承認スタンスを順に切り替え、`!` は Shell コマンドを通常の承認経路で実行します。
## できること
- プロバイダとモデルの選択を具体的なルートに解決します: エンドポイント、ワイヤプロトコル、コンテキスト上限、価格。コンテキスト予算とコスト表示は実際のルートに基づき、不明な価格は $0 ではなく不明として表示されます。([docs/PROVIDERS.md](docs/PROVIDERS.md))
- ホスト型のオープンモデルプロバイダ(`deepseek``openrouter``moonshot``zai``minimax``nvidia-nim` など)、キー不要で使える自前の `vllm` / `sglang` / `ollama`、そして thinking とプロンプトキャッシュに対応した Messages API 経由のネイティブな Anthropic と通信します。
- 複数のワーカーを耐久的に走らせます: Fleet は作業を追記専用の台帳(ledger)に記録するため、実行は再起動を生き延び、`fleet resume` が止まったところから再開します。Workflow は大きなジョブを、再開可能で検証可能なレーンへ計画します。([docs/FLEET.md](docs/FLEET.md))
- リスクは雰囲気ではなくコードでゲートします: 3 つのモード(Plan は読み取り専用)、独立した承認スタンス、OS サンドボックス(Seatbelt、Landlock + seccomp、bwrap)、ツール呼び出しごとに allow/deny/ask を判定できるフック、そして `/restore` が実際の履歴に決して触れないようにする side-git スナップショット。
- リポジトリが自らの法を宣言できます: `.codewhale/constitution.json` の不変条件は、Full Access でもスキップできない書き込みホールドにコンパイルされます。([docs/CONFIGURATION.md](docs/CONFIGURATION.md))
- MCP はクライアントとサーバーの両方向に対応し、再利用可能なスキルを読み込み、HTTP/SSE と ACP の Runtime API を公開し、コミュニティ製の [VS Code GUI](https://github.com/HengQuWorld/CodeWhale-VSCode) を支えています。
- TUI は作業を検査可能なレシートとして表示し、ライブに動く行は常に 1 行だけに保ち、本物のコンテキストインスペクタ、12 のテーマ、モーション低減モードと ASCII セーフモードを備えます。UI は英語、简体中文、日本語、Tiếng Việt、Español、Português、한국어で利用でき、繁體中文には部分的に対応しています。
それ以外のすべて — 設定、キーバインド、サンドボックスの詳細、アーキテクチャ — は [docs](docs) と [codewhale.net](https://codewhale.net/) にあります。
## コントリビューション
すべてのフィードバックは贈り物です。Issue、PR、再現手順、ログ、機能要望、初めてのコントリビューションは、どれもここでは本物のプロジェクト作業です。PR がそのままマージできない場合、メンテナは使える部分を収穫(harvest)し、作者のクレジットは残ります — コミットにも、changelog にも、[docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) にも。使っているモデルやプロバイダが見当たらないとき、あるいは手元のマシンで何かが壊れたとき、それを知らせてもらえることが何より役に立ちます。
- [Open issues](https://github.com/Hmbown/CodeWhale/issues) — 最初のコントリビューションに向くものはここにあります
- [CONTRIBUTING.md](CONTRIBUTING.md) — 開発環境のセットアップと PR の流れ
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — このプロジェクトを形づくってきた全員
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
プロジェクトの出発点となったモデルとサポートを提供してくれた [DeepSeek](https://github.com/deepseek-ai)、「鯨兄弟」ファミリーに迎え入れてくれた [DataWhale](https://github.com/datawhalechina) 🐋、そしてターミナルエージェント体験で協力してくれている [OpenWarp](https://github.com/zerx-lab/warp) と [Open Design](https://github.com/nexu-io/open-design) に感謝します。
## ライセンス
[MIT](LICENSE)。独立したコミュニティプロジェクトであり、いかなるモデルプロバイダとも提携していません。
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+116
View File
@@ -0,0 +1,116 @@
<!-- source: README.md sha256:561a074b0e36 -->
# CodeWhale
터미널에서 쓰는 코딩 에이전트입니다. 어떤 모델과도 동작하며, 오픈 모델을
우선합니다.
프로바이더, 모델, 작업을 지정하면 코드를 읽고, 파일을 편집하고, 명령을
실행하고, 결과를 확인하며, 작업이 끝나거나 사용자의 판단이 필요해질
때까지 계속 진행합니다. 대화형 작업에는 TUI를, 스크립트와 CI에는
`codewhale exec`를 사용합니다. Rust로 작성되었고, MIT 라이선스이며,
전부 사용자의 컴퓨터에서 실행됩니다.
이 프로젝트는 `deepseek-tui`로 시작했습니다. 그 주위에 형성된
커뮤니티에 더 많은 프로바이더가 필요했고, 지금은 DeepSeek, Claude, GPT,
Kimi, GLM과 그 밖의 30개 이상이 같은 런타임과 같은 도구를 통해
실행됩니다.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
![터미널에서 실행 중인 CodeWhale](assets/screenshot.png)
## 설치
```bash
npm install -g codewhale
```
Cargo, Docker, Nix, Scoop, 사전 빌드 아카이브, Android/Termux, 그리고
GitHub에 접근할 수 없는 사용자를 위한 CNB 미러는
[docs/INSTALL.md](docs/INSTALL.md)에서 다룹니다. `deepseek-tui`에서
넘어오나요? 설정과 세션은 그대로 이어집니다 —
[docs/REBRAND.md](docs/REBRAND.md)를 참고하세요.
## 사용
```bash
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
```
TUI 안에서: `/model`은 프로바이더와 모델을 함께 전환하고, `/fleet`
워커 팀을 실행하며, `/restore`는 한 턴을 되돌립니다. `Tab`
Plan / Act / Operate 모드를 순환하고, `Shift+Tab`
Ask / Auto-Review / Full Access 승인 태세를 순환하며, `!`는 일반 승인
경로를 거쳐 셸 명령을 실행합니다.
## 하는 일
- 선택한 프로바이더 + 모델을 구체적인 라우트로 해석합니다: 엔드포인트,
와이어 프로토콜, 컨텍스트 한도, 가격. 컨텍스트 예산과 비용 표시는
실제 라우트에서 나오며, 알 수 없는 가격은 $0가 아니라 알 수 없음으로
표시됩니다. ([docs/PROVIDERS.md](docs/PROVIDERS.md))
- 호스팅형 오픈 모델 프로바이더(`deepseek`, `openrouter`, `moonshot`,
`zai`, `minimax`, `nvidia-nim`, …)와 통신하고, 키 없이 자체
`vllm` / `sglang` / `ollama`에 연결하며, Anthropic에는 thinking과
프롬프트 캐싱을 갖춘 Messages API로 네이티브 연결합니다.
- 여러 워커를 내구성 있게 실행합니다: Fleet은 작업을 추가 전용 원장에
기록하므로 실행은 재시작에도 살아남고, `fleet resume`은 멈춘
지점부터 이어서 진행합니다. Workflow는 더 큰 작업을 재개 가능하고
검증 가능한 레인으로 계획합니다. ([docs/FLEET.md](docs/FLEET.md))
- 위험을 감이 아니라 코드로 통제합니다: 세 가지 모드(Plan은 읽기 전용),
별도의 승인 태세, OS 샌드박싱(Seatbelt, Landlock + seccomp, bwrap),
도구 호출마다 허용/거부/질문할 수 있는 훅, 그리고 `/restore`가 실제
히스토리를 결코 건드리지 않게 하는 side-git 스냅샷.
- 저장소가 자체 법을 선언할 수 있습니다:
`.codewhale/constitution.json`의 불변 조건은 Full Access조차 건너뛸
수 없는 쓰기 보류로 컴파일됩니다.
([docs/CONFIGURATION.md](docs/CONFIGURATION.md))
- 양방향으로 MCP를 지원하고, 재사용 가능한 스킬을 불러오며, HTTP/SSE 및
ACP 런타임 API를 노출하고, 커뮤니티
[VS Code GUI](https://github.com/HengQuWorld/CodeWhale-VSCode)를
뒷받침합니다.
- TUI는 작업을 점검할 수 있는 리시트로 보여 주고, 움직이는 라이브 행은
하나로 유지하며, 실제 컨텍스트 인스펙터, 12가지 테마, 모션 축소
모드와 ASCII 안전 모드를 갖추고 있습니다. UI 언어는 영어, 중국어
간체, 일본어, 베트남어, 스페인어, 포르투갈어, 한국어를 지원하며,
중국어 번체는 부분 지원입니다.
그 밖의 모든 것 — 설정, 키 바인딩, 샌드박스 세부 사항, 아키텍처 — 은
[docs](docs)와 [codewhale.net](https://codewhale.net/)에 있습니다.
## 기여
모든 피드백은 선물입니다. 이슈, PR, 재현 절차, 로그, 기능 요청, 첫
기여는 모두 이곳에서 실제 프로젝트 작업입니다. PR을 그대로 병합할 수
없을 때는 메인테이너가 작동하는 부분을 거두어 반영하고, 작성자의
크레딧은 커밋, 변경 로그,
[docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md)에 그대로 남습니다.
사용하는 모델이나 프로바이더가 빠져 있거나 무언가가 여러분의 컴퓨터에서
깨진다면, 그것을 알려 주는 일이 할 수 있는 가장 유용한 일입니다.
- [열려 있는 이슈](https://github.com/Hmbown/CodeWhale/issues) — 처음
기여하기 좋은 작업이 여기에 있습니다
- [CONTRIBUTING.md](CONTRIBUTING.md) — 개발 환경 설정과 PR 흐름
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — 이 프로젝트를 빚어 온
모든 사람
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
프로젝트를 시작하게 해 준 모델과 지원을 제공한
[DeepSeek](https://github.com/deepseek-ai), Whale Brother family로
맞이해 준 [DataWhale](https://github.com/datawhalechina) 🐋, 그리고
터미널 에이전트 경험에 함께 협력해 준
[OpenWarp](https://github.com/zerx-lab/warp)와
[Open Design](https://github.com/nexu-io/open-design)에 감사드립니다.
## 라이선스
[MIT](LICENSE). 독립 커뮤니티 프로젝트이며, 어떤 모델 프로바이더와도
제휴 관계가 없습니다.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+105
View File
@@ -0,0 +1,105 @@
# CodeWhale
A coding agent for your terminal. Works with any model; open models first.
You give it a provider, a model, and a task. It reads code, edits files, runs
commands, checks the results, and keeps going until the task is done or it
needs you. TUI for interactive work, `codewhale exec` for scripts and CI.
Rust, MIT, runs entirely on your machine.
It started as `deepseek-tui`. The community that formed around it needed more
providers, so now DeepSeek, Claude, GPT, Kimi, GLM, and 30+ others run through
the same runtime and tools.
[简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
![CodeWhale running in a terminal](assets/screenshot.png)
## Install
```bash
npm install -g codewhale
```
Cargo, Docker, Nix, Scoop, prebuilt archives, Android/Termux, and a CNB mirror
for users who cannot reach GitHub are covered in
[docs/INSTALL.md](docs/INSTALL.md). Coming from `deepseek-tui`? Your config and
sessions carry over — see [docs/REBRAND.md](docs/REBRAND.md).
## Use
```bash
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
```
In the TUI: `/model` switches provider and model together, `/fleet` runs a
team of workers, `/restore` undoes a turn, `Tab` cycles Plan / Act / Operate,
`Shift+Tab` cycles the Ask / Auto-Review / Full Access approval posture, and
`!` runs a shell command through the normal approval path.
## What it does
- Resolves your provider + model choice to a concrete route: endpoint, wire
protocol, context limit, price. Context budgets and cost display come from
the real route; an unknown price shows as unknown, not $0.
([docs/PROVIDERS.md](docs/PROVIDERS.md))
- Talks to hosted open-model providers (`deepseek`, `openrouter`, `moonshot`,
`zai`, `minimax`, `nvidia-nim`, …), to your own `vllm` / `sglang` / `ollama`
with no key, and to Anthropic natively over the Messages API with thinking
and prompt caching.
- Runs multiple workers durably: Fleet records work in an append-only ledger,
so runs survive restarts and `fleet resume` picks up where things stopped.
Workflow plans bigger jobs into resumable, verifiable lanes.
([docs/FLEET.md](docs/FLEET.md))
- Gates risk in code, not vibes: three modes (Plan is read-only), a separate
approval posture, OS sandboxing (Seatbelt, Landlock + seccomp, bwrap),
hooks that can allow/deny/ask per tool call, and side-git snapshots so
`/restore` never touches your real history.
- Lets a repo declare its own law: `.codewhale/constitution.json` invariants
compile into write holds that even Full Access can't skip.
([docs/CONFIGURATION.md](docs/CONFIGURATION.md))
- Speaks MCP in both directions, loads reusable skills, exposes HTTP/SSE and
ACP runtime APIs, and backs a community
[VS Code GUI](https://github.com/HengQuWorld/CodeWhale-VSCode).
- The TUI shows work as receipts you can inspect, keeps one live row moving,
has a real context inspector, 12 themes, reduced-motion and ASCII-safe
modes, and ships in English, 简体中文, 日本語, Tiếng Việt, Español,
Português, 한국어, and partial 繁體中文.
Everything else — configuration, keybindings, sandbox details, architecture —
is in [docs](docs) and on [codewhale.net](https://codewhale.net/).
## Contributing
All feedback is a gift. Issues, PRs, repro steps, logs, feature requests, and
first contributions are all real project work here. When a PR can't merge
as-is, maintainers harvest what works and the author stays credited — in the
commit, the changelog, and [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md). If a
model or provider you use is missing, or something breaks on your machine,
telling us is the most useful thing you can do.
- [Open issues](https://github.com/Hmbown/CodeWhale/issues) — good first
contributions live here
- [CONTRIBUTING.md](CONTRIBUTING.md) — dev setup and PR flow
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — everyone who has shaped this
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
Thanks to [DeepSeek](https://github.com/deepseek-ai) for the models and support
that started the project, [DataWhale](https://github.com/datawhalechina) 🐋 for
welcoming us into the Whale Brother family, and
[OpenWarp](https://github.com/zerx-lab/warp) and
[Open Design](https://github.com/nexu-io/open-design) for collaborating on the
terminal-agent experience.
## License
[MIT](LICENSE). Independent community project; not affiliated with any model
provider.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+116
View File
@@ -0,0 +1,116 @@
<!-- source: README.md sha256:561a074b0e36 -->
# CodeWhale
Um agente de código para o seu terminal. Funciona com qualquer modelo; modelos
abertos em primeiro lugar.
Você informa um provedor, um modelo e uma tarefa. Ele lê código, edita
arquivos, executa comandos, verifica os resultados e continua até a tarefa
terminar ou até precisar de você. TUI para trabalho interativo,
`codewhale exec` para scripts e CI. Rust, MIT, roda inteiramente na sua
máquina.
Começou como `deepseek-tui`. A comunidade que se formou em volta dele
precisava de mais provedores, então hoje DeepSeek, Claude, GPT, Kimi, GLM e
mais de 30 outros rodam pelo mesmo runtime e pelas mesmas ferramentas.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
![CodeWhale rodando em um terminal](assets/screenshot.png)
## Instalação
```bash
npm install -g codewhale
```
Cargo, Docker, Nix, Scoop, arquivos pré-compilados, Android/Termux e um
espelho CNB para quem não consegue acessar o GitHub estão cobertos em
[docs/INSTALL.md](docs/INSTALL.md). Vindo do `deepseek-tui`? Sua configuração
e suas sessões são preservadas — veja [docs/REBRAND.md](docs/REBRAND.md).
## Uso
```bash
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
```
Na TUI: `/model` troca provedor e modelo juntos, `/fleet` executa uma equipe
de workers, `/restore` desfaz um turno, `Tab` cicla entre Plan / Act / Operate,
`Shift+Tab` cicla a postura de permissão Ask / Auto-Review / Full Access, e
`!` executa um comando de shell pelo caminho normal de aprovação.
## O que ele faz
- Resolve sua escolha de provedor + modelo em uma rota concreta: endpoint,
protocolo de comunicação, limite de contexto, preço. Orçamentos de contexto
e a exibição de custo vêm da rota real; um preço desconhecido aparece como
desconhecido, não como $0.
([docs/PROVIDERS.md](docs/PROVIDERS.md))
- Conversa com provedores hospedados de modelos abertos (`deepseek`,
`openrouter`, `moonshot`, `zai`, `minimax`, `nvidia-nim`, …), com seu
próprio `vllm` / `sglang` / `ollama` sem chave, e com a Anthropic
nativamente pela Messages API, com thinking e cache de prompt.
- Executa vários workers de forma durável: o Fleet registra o trabalho em um
ledger append-only, então as execuções sobrevivem a reinícios e
`fleet resume` retoma de onde as coisas pararam. O Workflow planeja
trabalhos maiores em trilhas retomáveis e verificáveis.
([docs/FLEET.md](docs/FLEET.md))
- Controla risco em código, não em achismo: três modos (Plan é somente
leitura), uma postura de permissão separada, sandbox do sistema operacional
(Seatbelt, Landlock + seccomp, bwrap), hooks que podem
permitir/negar/perguntar a cada chamada de ferramenta, e snapshots em um git
paralelo para que `/restore` nunca toque no seu histórico real.
- Permite que um repositório declare sua própria lei: os invariantes de
`.codewhale/constitution.json` compilam em bloqueios de escrita que nem o
Full Access consegue pular.
([docs/CONFIGURATION.md](docs/CONFIGURATION.md))
- Fala MCP nas duas direções, carrega skills reutilizáveis, expõe APIs de
runtime HTTP/SSE e ACP, e dá suporte a uma
[GUI para VS Code](https://github.com/HengQuWorld/CodeWhale-VSCode) da
comunidade.
- A TUI mostra o trabalho como recibos que você pode inspecionar, mantém uma
única linha ao vivo em movimento, tem um inspetor de contexto de verdade,
12 temas, modos de movimento reduzido e ASCII seguro, e é distribuída em
English, 简体中文, 日本語, Tiếng Việt, Español, Português, 한국어 e 繁體中文
parcial.
Todo o resto — configuração, atalhos de teclado, detalhes do sandbox,
arquitetura — está em [docs](docs) e em [codewhale.net](https://codewhale.net/).
## Contribuindo
Todo feedback é um presente. Issues, PRs, passos de reprodução, logs, pedidos
de funcionalidade e primeiras contribuições — tudo isso é trabalho real do
projeto aqui. Quando um PR não pode ser mesclado como está, os mantenedores
aproveitam o que funciona e o autor continua creditado — no commit, no
changelog e em [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md). Se um modelo ou
provedor que você usa está faltando, ou se algo quebra na sua máquina, nos
contar é a coisa mais útil que você pode fazer.
- [Issues abertas](https://github.com/Hmbown/CodeWhale/issues) — boas
primeiras contribuições moram aqui
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup de desenvolvimento e fluxo de PR
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — todo mundo que ajudou a
moldar o projeto
- [Me pague um café](https://www.buymeacoffee.com/hmbown)
Obrigado à [DeepSeek](https://github.com/deepseek-ai) pelos modelos e pelo
apoio que deram início ao projeto, à
[DataWhale](https://github.com/datawhalechina) 🐋 por nos receber na família
Whale Brother, e a [OpenWarp](https://github.com/zerx-lab/warp) e
[Open Design](https://github.com/nexu-io/open-design) pela colaboração na
experiência de agente no terminal.
## Licença
[MIT](LICENSE). Projeto comunitário independente; sem afiliação com nenhum
provedor de modelos.
[![Gráfico de Star History](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+111
View File
@@ -0,0 +1,111 @@
<!-- source: README.md sha256:561a074b0e36 -->
# CodeWhale
Một coding agent cho terminal của bạn. Hoạt động với mọi model; ưu tiên model mở.
Bạn đưa cho nó một provider, một model và một nhiệm vụ. Nó đọc code, sửa file,
chạy lệnh, kiểm tra kết quả, và tiếp tục cho đến khi nhiệm vụ hoàn thành hoặc
cần đến bạn. TUI cho công việc tương tác, `codewhale exec` cho script và CI.
Viết bằng Rust, giấy phép MIT, chạy hoàn toàn trên máy của bạn.
Dự án khởi đầu là `deepseek-tui`. Cộng đồng hình thành quanh nó cần nhiều
provider hơn, nên giờ đây DeepSeek, Claude, GPT, Kimi, GLM và hơn 30 model
khác chạy qua cùng một runtime và bộ công cụ.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
![CodeWhale chạy trong terminal](assets/screenshot.png)
## Cài đặt
```bash
npm install -g codewhale
```
Cargo, Docker, Nix, Scoop, archive dựng sẵn, Android/Termux, và một mirror CNB
cho người dùng không truy cập được GitHub đều được hướng dẫn trong
[docs/INSTALL.md](docs/INSTALL.md). Chuyển từ `deepseek-tui` sang? Cấu hình và
session của bạn được giữ nguyên — xem [docs/REBRAND.md](docs/REBRAND.md).
## Sử dụng
```bash
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
```
Trong TUI: `/model` đổi provider và model cùng lúc, `/fleet` chạy một đội
worker, `/restore` hoàn tác một lượt, `Tab` chuyển vòng qua Plan / Act /
Operate, `Shift+Tab` chuyển vòng qua mức phê duyệt Ask / Auto-Review / Full
Access, và `!` chạy một lệnh shell qua đường phê duyệt bình thường.
## Nó làm gì
- Phân giải lựa chọn provider + model của bạn thành một route cụ thể:
endpoint, giao thức wire, giới hạn context, giá. Ngân sách context và phần
hiển thị chi phí lấy từ route thật; giá chưa biết được hiển thị là chưa
biết, không phải $0. ([docs/PROVIDERS.md](docs/PROVIDERS.md))
- Nói chuyện với các provider model mở dạng hosted (`deepseek`, `openrouter`,
`moonshot`, `zai`, `minimax`, `nvidia-nim`, …), với `vllm` / `sglang` /
`ollama` của riêng bạn mà không cần key, và với Anthropic một cách native
qua Messages API với thinking và prompt caching.
- Chạy nhiều worker một cách bền vững: Fleet ghi công việc vào một ledger chỉ
ghi thêm (append-only), nên các lượt chạy sống sót qua restart và
`fleet resume` tiếp tục từ chỗ đã dừng. Workflow lập kế hoạch cho những việc
lớn hơn thành các lane có thể tiếp tục và kiểm chứng được.
([docs/FLEET.md](docs/FLEET.md))
- Chặn rủi ro bằng code, không bằng cảm tính: ba chế độ (Plan chỉ đọc), mức
phê duyệt tách riêng, sandbox cấp hệ điều hành (Seatbelt, Landlock +
seccomp, bwrap), hook có thể allow/deny/ask cho từng lần gọi công cụ, và
snapshot side-git để `/restore` không bao giờ chạm vào lịch sử thật của bạn.
- Cho phép repo tự tuyên bố luật của mình: các bất biến trong
`.codewhale/constitution.json` được biên dịch thành các chốt chặn ghi mà
ngay cả Full Access cũng không thể bỏ qua.
([docs/CONFIGURATION.md](docs/CONFIGURATION.md))
- Nói MCP theo cả hai chiều, nạp các skill tái sử dụng, cung cấp runtime API
HTTP/SSE và ACP, và làm nền cho một
[GUI VS Code](https://github.com/HengQuWorld/CodeWhale-VSCode) của cộng đồng.
- TUI hiển thị công việc dưới dạng những biên nhận bạn có thể kiểm tra, giữ
đúng một dòng live chuyển động, có trình kiểm tra context thực thụ, 12
theme, chế độ giảm chuyển động và chế độ ASCII an toàn, và có sẵn tiếng Anh,
tiếng Trung giản thể, tiếng Nhật, tiếng Việt, tiếng Tây Ban Nha, tiếng Bồ
Đào Nha, tiếng Hàn, và một phần tiếng Trung phồn thể.
Mọi thứ còn lại — cấu hình, phím tắt, chi tiết sandbox, kiến trúc — nằm trong
[docs](docs) và trên [codewhale.net](https://codewhale.net/).
## Đóng góp
Mọi phản hồi đều là một món quà. Issue, PR, các bước tái hiện lỗi, log, yêu
cầu tính năng và những đóng góp đầu tiên đều là công việc thực sự của dự án.
Khi một PR không thể merge nguyên trạng, maintainer sẽ harvest phần dùng được
và tác giả vẫn được ghi công — trong commit, trong changelog và trong
[docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md). Nếu một model hay provider bạn
dùng còn thiếu, hoặc có gì đó hỏng trên máy của bạn, báo cho chúng tôi biết là
điều hữu ích nhất bạn có thể làm.
- [Issue đang mở](https://github.com/Hmbown/CodeWhale/issues) — những đóng góp
đầu tiên phù hợp nằm ở đây
- [CONTRIBUTING.md](CONTRIBUTING.md) — thiết lập môi trường dev và quy trình PR
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — tất cả những người đã góp
phần định hình dự án
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
Cảm ơn [DeepSeek](https://github.com/deepseek-ai) vì các model và sự hỗ trợ đã
khởi đầu dự án, [DataWhale](https://github.com/datawhalechina) 🐋 vì đã chào
đón chúng tôi vào đại gia đình Whale Brother, và
[OpenWarp](https://github.com/zerx-lab/warp) cùng
[Open Design](https://github.com/nexu-io/open-design) vì đã hợp tác xây dựng
trải nghiệm agent trên terminal.
## Giấy phép
[MIT](LICENSE). Dự án cộng đồng độc lập; không trực thuộc bất kỳ nhà cung cấp
model nào.
[![Biểu đồ Star History](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`Hmbown/CodeWhale`
- 原始仓库:https://github.com/Hmbown/CodeWhale
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+63
View File
@@ -0,0 +1,63 @@
<!-- source: README.md sha256:561a074b0e36 -->
# CodeWhale
一个运行在终端里的编程智能体。适配任意模型;开放模型优先。
你给它一个 provider、一个模型和一个任务。它读代码、改文件、跑命令、检查结果,一直做到任务完成或需要你介入为止。交互式工作用 TUI,脚本和 CI 用 `codewhale exec`。Rust 编写,MIT 许可,完全在你自己的机器上运行。
它最初叫 `deepseek-tui`。围绕它形成的社区需要更多 provider,于是现在 DeepSeek、Claude、GPT、Kimi、GLM 以及其他 30 多个模型都跑在同一套运行时和工具之上。
[English](README.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
![CodeWhale 在终端中运行](assets/screenshot.png)
## 安装
```bash
npm install -g codewhale
```
Cargo、Docker、Nix、Scoop、预编译归档、Android/Termux,以及面向无法访问 GitHub 用户的 CNB 镜像,均见 [docs/INSTALL.md](docs/INSTALL.md)。从 `deepseek-tui` 迁移过来?你的配置和会话可以直接沿用——见 [docs/REBRAND.md](docs/REBRAND.md)。
## 使用
```bash
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
```
在 TUI 中:`/model` 同时切换 provider 和模型,`/fleet` 运行一组 worker`/restore` 撤销某一轮,`Tab` 在 Plan / Act / Operate 之间循环切换,`Shift+Tab` 在 Ask / Auto-Review / Full Access 审批姿态之间循环切换,`!` 让 shell 命令经由正常的审批路径运行。
## 它做什么
- 把你选定的 provider + 模型解析为一条具体路由:端点、传输协议、上下文上限、价格。上下文预算与费用显示都取自真实路由;价格未知时就显示未知,而不是 $0。([docs/PROVIDERS.md](docs/PROVIDERS.md)
- 可连接托管的开放模型 provider(`deepseek``openrouter``moonshot``zai``minimax``nvidia-nim` 等),可无 key 直连你自己的 `vllm` / `sglang` / `ollama`,也能通过 Messages API 原生对接 Anthropic,支持 thinking 与 prompt 缓存。
- 以可持久化的方式运行多个 worker:Fleet 把工作记录在只追加的账本里,运行不会因重启而丢失,`fleet resume` 能从中断处继续。Workflow 把更大的任务规划成可恢复、可验证的 lane。([docs/FLEET.md](docs/FLEET.md)
- 风险把关靠代码,不靠感觉:三种模式(Plan 为只读)、独立的审批姿态、操作系统级沙箱(Seatbelt、Landlock + seccomp、bwrap)、可对每次工具调用做 allow/deny/ask 决策的 hooks,以及 side-git 快照——`/restore` 永远不会碰你真正的提交历史。
- 允许仓库声明自己的法律:`.codewhale/constitution.json` 中的不变量会编译成写入拦截,连 Full Access 也无法跳过。([docs/CONFIGURATION.md](docs/CONFIGURATION.md)
- 双向支持 MCP,可加载可复用的 skills,对外提供 HTTP/SSE 与 ACP 运行时 API,并支撑社区维护的 [VS Code GUI](https://github.com/HengQuWorld/CodeWhale-VSCode)。
- TUI 把工作显示为可逐条查验的回执,同一时间只让一行保持动态,内置真实的上下文查看器、12 套主题、减弱动效与 ASCII 安全模式,界面语言覆盖英语、简体中文、日语、越南语、西班牙语、葡萄牙语和韩语,繁体中文为部分翻译。
其余内容——配置、键位绑定、沙箱细节、架构——见 [docs](docs) 与 [codewhale.net](https://codewhale.net/)。
## 贡献
所有反馈都是礼物。Issue、PR、复现步骤、日志、功能请求和第一次贡献,在这里都算真实的项目工作。当一个 PR 无法原样合并时,维护者会吸收其中可用的部分,作者的署名会保留——在提交、更新日志和 [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) 中。如果你在用的某个模型或 provider 还不支持,或者有什么东西在你机器上坏了,告诉我们就是你能做的最有用的事。
- [开放 issue](https://github.com/Hmbown/CodeWhale/issues) —— 适合入门的贡献在这里
- [CONTRIBUTING.md](CONTRIBUTING.md) —— 开发环境搭建与 PR 流程
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) —— 每一位塑造过这个项目的人
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
感谢 [DeepSeek](https://github.com/deepseek-ai) 提供让项目起步的模型与支持,感谢 [DataWhale](https://github.com/datawhalechina) 🐋 欢迎我们加入“鲸兄弟”大家庭,也感谢 [OpenWarp](https://github.com/zerx-lab/warp) 与 [Open Design](https://github.com/nexu-io/open-design) 在终端智能体体验上的协作。
## 许可证
[MIT](LICENSE)。独立的社区项目,与任何模型 provider 均无隶属关系。
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+97
View File
@@ -0,0 +1,97 @@
# Security Policy
codewhale is a coding agent with direct access to file operations, shell execution, and the network. Security disclosures are taken seriously.
## Supported Versions
Only the latest stable release receives security patches. No backports to older versions.
| Version | Supported |
|---|---|
| latest stable | :white_check_mark: |
| < latest | :x: |
Check the [releases page](https://github.com/Hmbown/CodeWhale/releases) for the current version.
## Reporting a Vulnerability
**Do not open a public GitHub issue for security vulnerabilities.**
Report privately via one of:
- **GitHub private advisory**: [github.com/Hmbown/CodeWhale/security/advisories/new](https://github.com/Hmbown/CodeWhale/security/advisories/new)
- **Email**: [hmbown@gmail.com](mailto:hmbown@gmail.com) — include `[SECURITY]` in the subject line
Include in your report:
- A description of the vulnerability and the impact if exploited
- Steps to reproduce or a proof of concept
- Affected versions and configuration details
- Any suggested mitigation (optional)
## Response Timeline
| Phase | Target |
|---|---|
| Acknowledgment | Within 48 hours of receipt |
| Assessment | Within 5 days — triage severity, scope, and fix approach |
| Patch (critical) | Within 14 days from assessment |
| Patch (moderate/low) | Next feature release or per-maintainer timeline |
| Disclosure | After patch is shipped and users have had time to update |
You will receive status updates at each phase. If the timeline slips, we will communicate the reason and the revised estimate.
## Scope
### In scope (what counts)
- Remote code execution through crafted prompts or model responses
- Sandbox escape — breaking out of the YOLO-mode workspace boundary or shell `cwd` confinement
- Credential leak — exfiltration of API keys, tokens, or environment secrets
- Arbitrary file read/write outside the intended workspace (`PathEscape` bypass)
- SSRF via `fetch_url` or `web_search` against internal network endpoints
- Unauthorised MCP server access or tool invocation
### Out of scope
- Social engineering of the maintainer or contributors
- Denial of service / rate-limit exhaustion against the DeepSeek API
- Vulnerabilities in third-party dependencies (report to the upstream project)
- Attacks requiring physical access to the victim's machine
- Theoretical ML-model injection attacks not demonstrated in the codewhale context
If you are unsure whether a bug is in scope, report it anyway. We will triage and respond.
## WeCom Bridge Security
The WeCom Bridge (`integrations/wecom-bridge/`) extends CodeWhale to WeCom
(企业微信) Smart Bot WebSocket sessions. It inherits all standard CodeWhale
security boundaries and adds bridge-specific controls.
### Bridge-specific protections
- **No public port**: The bridge communicates with `codewhale serve --http` on `127.0.0.1` only
- **Token gate**: All runtime API calls carry `CODEWHALE_RUNTIME_TOKEN`
- **Chat allowlist**: Only chats/users listed in `WECOM_CHAT_ALLOWLIST` can interact. First-pairing mode (`WECOM_ALLOW_UNLISTED=true`) is meant for onboarding only
- **Approval required**: Tool calls from WeCom sessions must be approved — either via explicit `/allow <id>` commands or natural-language keywords (`允许`, `yes`, `ok`, etc.)
- **No workspace exposure**: Only prompts, status summaries, and approval requests are sent to WeCom. Workspace contents, shell output, and runtime internals stay on the local machine
### Reporting WeCom Bridge vulnerabilities
Report bridge-specific security issues through the same channels listed above.
Include the bridge version (check `package.json`) and your WeCom deployment configuration
(sensitive values redacted). Bridge logs may be requested for reproduction.
### Bridge environment safety
- `WECOM_BOT_SECRET` and `CODEWHALE_RUNTIME_TOKEN` must never be committed to git
- The `.env` file is gitignored; use `.env.example` as the template
- Rotate secrets periodically, especially after sharing screen captures
- Use `CODEWHALE_APPROVAL_TIMEOUT_MS` (default 5 min) to limit the approval window
## Hall of Fame
We maintain a hall of fame for reporters who submit verified security vulnerabilities. To be credited, include your preferred name / handle in the report.
*No entries yet — be the first.*
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

+13
View File
@@ -0,0 +1,13 @@
# cargo-audit configuration for CodeWhale
# https://github.com/rustsec/rustsec/blob/main/cargo-audit/audit.toml.example
[advisories]
# Ignore specific advisories if needed (add RUSTSEC-XXXX-XXXXX)
# ignore = []
[database]
# Path to a local advisory database (optional)
# path = ""
# URL to fetch advisory database from
# url = ""
@@ -0,0 +1,524 @@
# DeepSeek Anthropic-Compatible Endpoint — Comparison Report & Decision (#2963)
- **Issue:** [#2963](https://github.com/Hmbown/CodeWhale/issues/2963) — *v0.8.65: DeepSeek Anthropic-compatible endpoint wire-protocol spike*
- **Release lane:** v0.8.65
- **Date:** 2026-06-24
- **Status:** Implementation **landed and Experimental**. Keep-vs-promote decision **PENDING live numbers** (Section 4).
- **Scope of this document:** A *report*. It changes no Rust code and makes no live API calls — no DeepSeek credentials are available in this environment, so all live figures below are left as a checklist for a human operator to fill in.
> Do **not** reimplement the route. It already exists on `main` (commit
> `5b8a5ac0b2c478261740f49756d29c4a7f83d89c`, PR
> [#3449](https://github.com/Hmbown/CodeWhale/pull/3449)). This document
> verifies what landed, derives what can be concluded from the code without a
> network, and specifies the exact live procedure to settle the open question.
All file:line citations below are against the tree at this report's commit
(verified ancestry: `5b8a5ac0b` is an ancestor of `HEAD`).
---
## 1. What's landed
The opt-in DeepSeek route that speaks the **Anthropic Messages** wire protocol
is implemented end to end. **It is already in `main`; do not re-implement it.**
### 1.1 Provider descriptor / route selection
- `crates/config/src/provider.rs:140-178``DeepseekAnthropic` provider:
- id `deepseek-anthropic` (`provider.rs:143-145`)
- display name `DeepSeek (Anthropic-compatible)` (`provider.rs:151-153`)
- aliases `deepseek_anthropic`, `deepseek-claude`, `deepseek_claude`
(`provider.rs:171-173`)
- **wire format `WireFormat::AnthropicMessages`** (`provider.rs:175-177`)
- API-key env var: **`DEEPSEEK_API_KEY` only** (`provider.rs:163-165`) — it
does **not** fall back to `ANTHROPIC_API_KEY`.
- `crates/config/src/provider.rs:31-38``WireFormat` enum
(`ChatCompletions` / `Responses` / `AnthropicMessages`).
- Registry wiring: static entry `provider.rs:544`, registered at
`provider.rs:573`.
- Defaults (`crates/config/src/provider_defaults.rs`):
- base URL `https://api.deepseek.com/anthropic`
(`provider_defaults.rs:14`)
- default model `deepseek-v4-pro``DEFAULT_DEEPSEEK_ANTHROPIC_MODEL`
aliases `DEFAULT_DEEPSEEK_MODEL` (`provider_defaults.rs:8-9`)
- The Chat-Completions DeepSeek route, for contrast, defaults to base URL
`https://api.deepseek.com/beta` (`provider_defaults.rs:13`) with the same
default model `deepseek-v4-pro`.
### 1.2 Dispatch
- `crates/tui/src/client.rs:1331-1339` (`create_message`) and
`client.rs:1341-1352` (`create_message_stream`) route to the Anthropic
adapter when `api_provider_uses_anthropic_messages(self.api_provider)` is
true.
- `client.rs:864-869``api_provider_uses_anthropic_messages` returns true for
`ApiProvider::Anthropic | ApiProvider::DeepseekAnthropic`.
- Request payload mode is selected by route, not prompt:
`crates/tui/src/config.rs:526-530` sets
`RequestPayloadMode::AnthropicMessages` for `DeepseekAnthropic`, else
`ChatCompletions`.
### 1.3 Auth dialect
- `crates/tui/src/client.rs:805-838` builds headers:
- injects `anthropic-version: 2023-06-01` for Anthropic-wire providers
(`client.rs:808-815`)
- uses **`x-api-key`** (never `Authorization: Bearer`) for the API key
(`client.rs:817-819`, applied `client.rs:831-837`)
- `client.rs:846-862` strips any caller-supplied `Authorization` / `api-key` /
`x-api-key` extra headers so a stale OpenAI-style auth header cannot leak onto
the Anthropic wire (`is_auth_dialect_header`, `client.rs:858-862`).
- Tests: `deepseek_anthropic_uses_anthropic_header_dialect`
(`client.rs:2216`+) asserts `x-api-key` + `anthropic-version` are present and
that Bearer / MiMo headers are absent.
### 1.4 Request encoding (Messages body)
- `crates/tui/src/client/anthropic.rs:40-143``build_anthropic_body`:
- `model` / `max_tokens` / `stream` (`anthropic.rs:41-45`)
- `system` as text or cache-aware blocks (`anthropic.rs:47-66`)
- `messages` via `message_to_anthropic` (`anthropic.rs:68-74`,
`anthropic.rs:291-301`)
- `tools` with `strict` + `cache_control` (`anthropic.rs:76-98`)
- `tool_choice` mapped from OpenAI-style string/object to Anthropic object
form (`anthropic.rs:100-102`, `anthropic.rs:279-287`)
- reasoning → `thinking: {type: adaptive}` + `output_config.effort`
(low/medium/high/max), gated on `model_supports_reasoning`
(`anthropic.rs:104-128`)
- sampling-parameter rules: send at most one of temperature/top_p, or neither
for models that reject them (`anthropic.rs:130-139`,
`anthropic.rs:269-275`)
- `cache_control` breakpoint placement, capped at 4
(`anthropic.rs:141`, `anthropic.rs:367-446`)
- Endpoint URL builder tolerates a `/v1` suffix
(`anthropic.rs:259-266`); `https://api.deepseek.com/anthropic`
`…/anthropic/v1/messages`.
### 1.5 Response & stream decoding
- Non-streaming: `anthropic.rs:240-254` (`handle_anthropic_message`) parses the
JSON body and normalizes `usage`.
- Streaming: `anthropic.rs:170-237` (`handle_anthropic_stream`) is an SSE
pass-through; `convert_anthropic_sse_data` (`anthropic.rs:450-494`) decodes
`message_start` / `content_block_*` / `message_delta` / `message_stop` /
`ping` / `error`, tolerates unknown event types, and normalizes usage on
`message_start` / `message_delta`.
- Send/error path: `anthropic.rs:145-167` (`send_anthropic_request`) sets
`Accept: text/event-stream`, maps non-2xx into a typed error via
`parse_anthropic_error_envelope` (`anthropic.rs:528-548`).
### 1.6 Usage / cache normalization (#2961 convention)
- `anthropic.rs:499-523` (`parse_anthropic_usage`):
- `prompt_cache_hit_tokens = cache_read_input_tokens`
- `prompt_cache_miss_tokens = input_tokens + cache_creation_input_tokens`
- normalized `input_tokens = input_tokens + cache_creation + cache_read`
(total prompt — the DeepSeek convention)
### 1.7 Operational guardrails added with the route
- Health check **skips the `/anthropic/v1/models` probe** for this route
(`client.rs:871-873`, `api_provider_skips_models_probe`); test
`deepseek_anthropic_health_check_skips_models_probe` (`client.rs:2301`+).
- **FIM is unsupported** on this route and fails locally with a clear message
(`client.rs:1722-1727`); test `deepseek_anthropic_fim_fails_without_http_request`
(`client.rs:2314`+).
- Base-URL env override is route-aware: `CODEWHALE_BASE_URL` / `DEEPSEEK_BASE_URL`
writes into `providers.deepseek_anthropic.base_url`
(`crates/tui/src/config.rs:3928-3939`).
- Translation helper uses the Messages endpoint for this provider
(`client.rs:974-977`); test
`deepseek_anthropic_translate_uses_messages_endpoint` (`client.rs:2251`+).
### 1.8 Docs framing
- `docs/PROVIDERS.md:48-51`, `:81`, `:111-112`, `:237` document the route as
**Anthropic *wire-protocol* compatibility** (not Anthropic model/provider
semantics), list the aliases, and state "Keep `provider = "deepseek"` for the
default Chat Completions path."
### 1.9 Test coverage already present (no live calls)
In `crates/tui/src/client/anthropic.rs` `#[cfg(test)]` (from `anthropic.rs:550`):
body cache-control placement, reasoning→effort mapping, sampling-param dropping,
signed/unsigned thinking replay, breakpoint cap, full SSE fixture decode
(text + thinking + signature + tool_use + usage), error/unknown-event handling,
usage mapping with missing cache fields, error-envelope parsing, URL `/v1`
tolerance. In `crates/tui/src/client.rs`: the auth-dialect, models-probe-skip,
translate-endpoint, and FIM-unsupported tests cited above.
---
## 2. Code-derived findings (no live calls needed)
These are behavioral facts that can be stated **from the code today**, before
any live comparison. They are the deltas a reviewer most needs to know.
### 2.1 Server tools / web search are NOT exercised via this route today
`content_block_to_anthropic` **drops** the server-tool block types on encode:
```
crates/tui/src/client/anthropic.rs:359-364
// Server-tool block types are DeepSeek/internal concepts with no
// Anthropic client-side wire equivalent.
ContentBlock::ServerToolUse { .. }
| ContentBlock::ToolSearchToolResult { .. }
| ContentBlock::CodeExecutionToolResult { .. } => None,
```
Consequence: any server-tool / web-search content the engine holds is filtered
out before the request is sent on this route. There is also no encode-side path
that *injects* an Anthropic-style server-tool definition (e.g. a `web_search`
tool) into the outbound body — `build_anthropic_body` only forwards
caller-supplied client tools (`anthropic.rs:76-98`). So **server-side web
search / code execution is not exercised through the DeepSeek Anthropic route as
implemented.** Whether DeepSeek's endpoint would *accept* such a tool is a
separate, still-open question that only live testing (Section 4, Test E) can
answer; the code neither offers nor depends on it.
### 2.2 Usage telemetry: two real deltas vs the Chat-Completions path
Compare the two usage parsers:
| Field | Anthropic route (`anthropic.rs:499-523`) | Chat-Completions route (`client.rs:1643-1711`) |
|---|---|---|
| `input_tokens` (normalized) | `input + cache_creation + cache_read` | `input_tokens`/`prompt_tokens` as-is |
| `prompt_cache_hit_tokens` | `cache_read_input_tokens` | `prompt_cache_hit_tokens`, else `prompt_tokens_details.cached_tokens` |
| `prompt_cache_miss_tokens` | `input + cache_creation` | `prompt_cache_miss_tokens`, else `input hit` |
| `reasoning_tokens` | **always `None`** (`anthropic.rs:519`) | parsed from `completion_tokens_details.reasoning_tokens` (`client.rs:1658-1685`) |
| `reasoning_replay_tokens` | `None` (`anthropic.rs:520`) | `None` (`client.rs:1708`) |
| `server_tool_use` | **always `None`** (`anthropic.rs:521`) | parsed from `server_tool_use.{code_execution,tool_search}_requests` (`client.rs:1687-1700`) |
| `output_tokens` | Anthropic `output_tokens` | `output_tokens`/`completion_tokens`, with fallbacks to reasoning or `total input` (`client.rs:1648-1670`) |
Two concrete deltas to record honestly in any telemetry comparison:
1. **`reasoning_tokens` is never populated on the Anthropic route.** Reasoning
*content* still flows (thinking blocks decode and signed blocks replay —
`anthropic.rs:315-330`, `anthropic.rs:822-868` fixture), but the **count**
is dropped. On the Chat-Completions route the count is read from
`completion_tokens_details.reasoning_tokens`. This is per the #2961/#3085
"explicit unknown/null for unsupported fields" rule, but it means
reasoning-token *accounting parity* between the two routes cannot be
expected — confirm in Test C.
2. **`server_tool_use` is never populated on the Anthropic route** (consistent
with §2.1: the route doesn't drive server tools).
### 2.3 Thinking / reasoning request shaping differs by design
The Anthropic route maps `reasoning_effort` tiers to
`thinking: {type: adaptive}` + `output_config.effort`
(`anthropic.rs:104-128`), gated on `model_supports_reasoning`. The
Chat-Completions DeepSeek path uses its own reasoning-split / payload
conventions (`config.rs:526-530` selects the payload mode; DeepSeek-family
reasoning handling lives on the Chat path). Equivalent *output* is the bar to
test (Section 3/4), not byte-identical requests.
### 2.4 Caching model differs in shape
The Anthropic route places explicit `cache_control` breakpoints (max 4) on the
prefix and latest user turn (`anthropic.rs:367-446`) and reports cache
hit/miss from Anthropic's `cache_read` / `cache_creation` fields. The
Chat-Completions route relies on DeepSeek's automatic prefix caching and reads
`prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` (or
`prompt_tokens_details.cached_tokens`). Both normalize into the same #2961
fields, so cache *telemetry* is comparable even though the *mechanism* differs.
### 2.5 Capability/operational deltas (route-level, from code)
- **FIM**: supported on Chat-Completions DeepSeek; **unsupported** on the
Anthropic route (`client.rs:1722-1727`).
- **Models probe**: skipped on the Anthropic route (`client.rs:871-873`); the
Chat path probes `/models`.
- **Auth**: `x-api-key` + `anthropic-version` (Anthropic route) vs
`Authorization: Bearer` (Chat route) — `client.rs:817-827`.
- **Endpoint**: `…/anthropic/v1/messages` vs `…/beta` chat completions.
### 2.6 What is *equivalent* by construction
Tool-call and tool-result mapping, image blocks, system prompt, and stop
reasons all have direct encoders (`anthropic.rs:303-358`) and the SSE decoder
reconstructs tool-use input JSON (fixture `anthropic.rs:816-897`). So for an
ordinary "prompt → text / tool_use" exchange, the two routes are expected to be
functionally equivalent; the open questions are the *quantitative* ones
(latency, token counts) and the *server-tool* one.
---
## 3. Comparison methodology
Compare DeepSeek's **Chat-Completions** route (`provider = "deepseek"`) against
its **Anthropic-Messages** route (`provider = "deepseek-anthropic"`) for the
**same model** (`deepseek-v4-pro`, and `deepseek-v4-flash` if the account has
it). Hold everything else constant (same prompt, same `max_tokens`, same
reasoning effort, same temperature where accepted).
Dimensions:
1. **Correctness / output equivalence** — same prompt → semantically equivalent
answer; same tool selection and arguments for a tool-use prompt; valid JSON
for a structured prompt.
2. **Latency** — wall-clock total and (for streaming) time-to-first-token, over
N≥5 runs each; report median + spread, not a single sample.
3. **Token / usage accounting parity** — compare `input_tokens` (normalized),
`output_tokens`, `prompt_cache_hit_tokens`, `prompt_cache_miss_tokens`,
`reasoning_tokens`. **Expect `reasoning_tokens` to be null on the Anthropic
route** (§2.2) — record it, don't treat it as a bug.
4. **Telemetry fields** — which of the #2961/#3085 normalized fields are
populated vs null on each route; note `server_tool_use` is null on the
Anthropic route by construction.
5. **Server-tool / web-search support** — does DeepSeek's Anthropic endpoint
*accept*, *ignore*, or *reject* an Anthropic-style server tool (e.g.
`web_search`)? Capture the raw request/response. (Recall the engine does not
send such a tool today — §2.1 — so this is an endpoint-capability probe with
a hand-built request, not a test of CodeWhale's encoder.)
6. **Error envelopes & rate limiting** — confirm 4xx/5xx map cleanly
(`anthropic.rs:528-548`) and that the route honors the same retry/backoff.
Pass bar for "comparable" (issue Acceptance Criteria): equivalent correctness on
the smoke tasks, latency within a reasonable band, and usage telemetry that maps
into the normalized fields (with explicit nulls where unsupported).
---
## 4. Runnable live checklist (human, with `DEEPSEEK_API_KEY` set)
All commands are copy-pasteable. They assume the repo root and a DeepSeek key.
**No credentials exist in this environment; these are for a human to run.**
### 4.0 One-time setup
```bash
export DEEPSEEK_API_KEY="sk-..." # your DeepSeek key
MODEL="deepseek-v4-pro" # also repeat with deepseek-v4-flash if available
CHAT_BASE="https://api.deepseek.com" # Chat Completions (OpenAI-compatible)
ANTH_BASE="https://api.deepseek.com/anthropic" # Anthropic Messages
mkdir -p benchmark_results/2963-live && cd "$(git rev-parse --show-toplevel)"
```
### Test A — correctness, single turn (text)
Chat Completions:
```bash
curl -sS -w '\n[http %{http_code} | total %{time_total}s | ttfb %{time_starttransfer}s]\n' \
-X POST "$CHAT_BASE/v1/chat/completions" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":64,\"stream\":false,
\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly the word: PONG\"}]}" \
| tee benchmark_results/2963-live/A_chat.json
```
Anthropic Messages (note `x-api-key` + `anthropic-version`, no Bearer):
```bash
curl -sS -w '\n[http %{http_code} | total %{time_total}s | ttfb %{time_starttransfer}s]\n' \
-X POST "$ANTH_BASE/v1/messages" \
-H "x-api-key: $DEEPSEEK_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":64,\"stream\":false,
\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly the word: PONG\"}]}" \
| tee benchmark_results/2963-live/A_anthropic.json
```
Record: does each return "PONG"? HTTP status, total time.
### Test B — usage / token accounting (read the `usage` object on both)
```bash
echo "Chat usage:"; jq '.usage' benchmark_results/2963-live/A_chat.json
echo "Anthropic usage:"; jq '.usage' benchmark_results/2963-live/A_anthropic.json
```
Fill in the table:
| Field | Chat Completions | Anthropic Messages |
|---|---|---|
| prompt/input tokens | | |
| completion/output tokens | | |
| cache hit (`prompt_cache_hit_tokens` / `cache_read_input_tokens`) | | |
| cache miss (`prompt_cache_miss_tokens` / `cache_creation_input_tokens`) | | |
| reasoning tokens (`completion_tokens_details.reasoning_tokens`) | | (expected absent) |
### Test C — reasoning / thinking
Chat Completions (DeepSeek reasoner-style):
```bash
curl -sS -X POST "$CHAT_BASE/v1/chat/completions" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" -H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":512,\"stream\":false,
\"messages\":[{\"role\":\"user\",\"content\":\"A bat and ball cost \$1.10. The bat costs \$1 more than the ball. How much is the ball? Think, then answer.\"}]}" \
| tee benchmark_results/2963-live/C_chat.json | jq '{content:.choices[0].message, usage:.usage}'
```
Anthropic Messages with adaptive thinking:
```bash
curl -sS -X POST "$ANTH_BASE/v1/messages" \
-H "x-api-key: $DEEPSEEK_API_KEY" -H "anthropic-version: 2023-06-01" -H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":512,\"stream\":false,
\"thinking\":{\"type\":\"adaptive\"},\"output_config\":{\"effort\":\"high\"},
\"messages\":[{\"role\":\"user\",\"content\":\"A bat and ball cost \$1.10. The bat costs \$1 more than the ball. How much is the ball? Think, then answer.\"}]}" \
| tee benchmark_results/2963-live/C_anthropic.json | jq '{content:.content, usage:.usage}'
```
Record: both should answer **\$0.05**. Note whether a `thinking` block is
returned by the Anthropic route and whether reasoning tokens appear anywhere.
### Test D — tool use (same tool both routes)
Chat Completions:
```bash
curl -sS -X POST "$CHAT_BASE/v1/chat/completions" \
-H "Authorization: Bearer $DEEPSEEK_API_KEY" -H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":256,
\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",
\"description\":\"Get weather for a city\",
\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}}],
\"messages\":[{\"role\":\"user\",\"content\":\"What's the weather in Paris? Use the tool.\"}]}" \
| tee benchmark_results/2963-live/D_chat.json | jq '.choices[0].message.tool_calls'
```
Anthropic Messages:
```bash
curl -sS -X POST "$ANTH_BASE/v1/messages" \
-H "x-api-key: $DEEPSEEK_API_KEY" -H "anthropic-version: 2023-06-01" -H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":256,
\"tools\":[{\"name\":\"get_weather\",\"description\":\"Get weather for a city\",
\"input_schema\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}}],
\"messages\":[{\"role\":\"user\",\"content\":\"What's the weather in Paris? Use the tool.\"}]}" \
| tee benchmark_results/2963-live/D_anthropic.json | jq '.content'
```
Record: does each emit a `get_weather` call with `city = "Paris"`?
### Test E — server-tool / web-search capability probe (the open question)
Send an Anthropic-style server tool and **record whether DeepSeek accepts,
ignores, or rejects it** (capture the full body). The engine does not send this
today (§2.1); this is a raw endpoint probe.
```bash
curl -sS -w '\n[http %{http_code}]\n' -X POST "$ANTH_BASE/v1/messages" \
-H "x-api-key: $DEEPSEEK_API_KEY" -H "anthropic-version: 2023-06-01" -H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":256,
\"tools\":[{\"type\":\"web_search_20250305\",\"name\":\"web_search\",\"max_uses\":2}],
\"messages\":[{\"role\":\"user\",\"content\":\"Search the web: what is the latest stable Rust version? Cite a source.\"}]}" \
| tee benchmark_results/2963-live/E_websearch.json
```
Classify the outcome:
- **Accepted + worked** — response contains server-tool-use / search results.
- **Ignored** — 200 OK, plain answer, no tool activity.
- **Rejected** — 4xx with an error envelope (record `error.type` / message).
### Test F — streaming smoke (both routes)
```bash
# Anthropic SSE
curl -N -sS -X POST "$ANTH_BASE/v1/messages" \
-H "x-api-key: $DEEPSEEK_API_KEY" -H "anthropic-version: 2023-06-01" \
-H "Accept: text/event-stream" -H "Content-Type: application/json" \
-d "{\"model\":\"$MODEL\",\"max_tokens\":64,\"stream\":true,
\"messages\":[{\"role\":\"user\",\"content\":\"Count: one two three\"}]}" \
| tee benchmark_results/2963-live/F_anthropic.sse | head -40
```
Confirm `message_start``content_block_*``message_delta``message_stop`
arrive (the shapes `convert_anthropic_sse_data` decodes, `anthropic.rs:450-494`).
### Test G — end-to-end through CodeWhale (optional, exercises the real adapter)
```bash
# Anthropic route through the built binary
cargo run -q -p codewhale -- --provider deepseek-anthropic --model "$MODEL" \
--print "Reply with exactly: PONG"
# Chat route for comparison
cargo run -q -p codewhale -- --provider deepseek --model "$MODEL" \
--print "Reply with exactly: PONG"
```
(Adjust the binary/flag names to the project's actual non-interactive entry
point if different; the point is to run one prompt through each resolved route.)
### 4.1 Results table to fill in
| Dimension | Chat Completions | Anthropic Messages | Verdict |
|---|---|---|---|
| Correctness (A/C/D) | | | |
| Latency median (N=…) | | | |
| TTFT (streaming) | | | |
| Token accounting (B) | | | |
| reasoning_tokens present | | (expected no) | |
| Tool use (D) | | | |
| Web search (E) | n/a | accept / ignore / reject | |
| Streaming (F) | | | |
---
## 5. Decision
**Recommendation: KEEP as Experimental. The keep-vs-promote decision is PENDING
the live numbers in Section 4. This report does not assert a "verified" verdict
because no live calls were made.**
Rationale, grounded in code:
- **Keep (not reject):** the route is fully implemented, isolated behind opt-in
provider selection (`deepseek-anthropic` / `deepseek-claude`), guarded
(FIM-unsupported message, models-probe skip, auth-header hygiene), and covered
by unit + SSE-fixture tests. It does not touch or regress the default
Chat-Completions DeepSeek path (separate dispatch at `client.rs:1331-1352`;
docs say keep `provider = "deepseek"` for the default). Nothing in the code
argues for ripping it out.
- **Do not promote yet:** the issue's promotion bar requires the Anthropic route
to be *at least comparable* on a live A/B, plus explicit server-tool evidence.
That evidence does not exist here. Two code-derived caveats that promotion
must weigh: (a) `reasoning_tokens` accounting is dropped on this route
(§2.2 #1), and (b) server tools / web search are not exercised through it
(§2.1) — so if web search is a requirement for "preferred," this route does
not satisfy it today regardless of what Test E shows about the endpoint.
- **Gate to flip the decision:** complete Section 4 (especially Tests AE),
fill the §4.1 table, and confirm equivalent correctness + comparable latency +
clean telemetry mapping. If all green and web search is not a blocker →
candidate to promote to preferred for DeepSeek V4. Otherwise → remain
Experimental, or reject the *promotion* (not the route) if telemetry/latency
regress.
### Suggested issue note (after live numbers are in)
> Implementation verified landed (#3449 / `5b8a5ac0b`); see
> `benchmark_results/deepseek-anthropic-comparison-2026-06-24.md`. Live A/B
> results: [fill in]. Server-tool/web-search probe (Test E): [accept/ignore/
> reject + evidence]. Decision: [keep experimental | promote to preferred].
---
## Appendix — citation index
| Topic | Location |
|---|---|
| `WireFormat` enum | `crates/config/src/provider.rs:31-38` |
| `DeepseekAnthropic` descriptor | `crates/config/src/provider.rs:140-178` |
| Registry entry | `crates/config/src/provider.rs:544`, `:573` |
| Base URL / model defaults | `crates/config/src/provider_defaults.rs:8-9,13-14` |
| Dispatch to Anthropic adapter | `crates/tui/src/client.rs:1331-1352` |
| `api_provider_uses_anthropic_messages` | `crates/tui/src/client.rs:864-869` |
| Auth header build (`x-api-key`/`anthropic-version`) | `crates/tui/src/client.rs:805-862` |
| Models-probe skip | `crates/tui/src/client.rs:871-873` |
| FIM unsupported | `crates/tui/src/client.rs:1722-1727` |
| Chat-Completions usage parser | `crates/tui/src/client.rs:1643-1711` |
| Base-URL env override (route-aware) | `crates/tui/src/config.rs:3928-3939` |
| Payload-mode selection | `crates/tui/src/config.rs:526-530` |
| `build_anthropic_body` | `crates/tui/src/client/anthropic.rs:40-143` |
| Messages URL builder | `crates/tui/src/client/anthropic.rs:259-266` |
| **Server-tool blocks dropped on encode** | `crates/tui/src/client/anthropic.rs:359-364` |
| Anthropic usage normalizer | `crates/tui/src/client/anthropic.rs:499-523` |
| Error-envelope parser | `crates/tui/src/client/anthropic.rs:528-548` |
| Docs framing | `docs/PROVIDERS.md:48-51,81,111-112,237` |
| Landed commit / PR | `5b8a5ac0b2c478261740f49756d29c4a7f83d89c` / [#3449](https://github.com/Hmbown/CodeWhale/pull/3449) |
+1234
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "codewhale-agent"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Model/provider registry and fallback strategy for CodeWhale"
[dependencies]
codewhale-config = { path = "../config", version = "0.8.68" }
serde.workspace = true
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "codewhale-app-server"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "App-server transport for CodeWhale runtime integrations"
# `codewhale app-server` is owned by codewhale-cli; this crate is library-only.
autobins = false
[dependencies]
anyhow.workspace = true
axum.workspace = true
clap.workspace = true
codewhale-agent = { path = "../agent", version = "0.8.68" }
codewhale-config = { path = "../config", version = "0.8.68" }
codewhale-core = { path = "../core", version = "0.8.68" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.68" }
codewhale-hooks = { path = "../hooks", version = "0.8.68" }
codewhale-mcp = { path = "../mcp", version = "0.8.68" }
codewhale-protocol = { path = "../protocol", version = "0.8.68" }
codewhale-release = { path = "../release", version = "0.8.68" }
codewhale-state = { path = "../state", version = "0.8.68" }
codewhale-tools = { path = "../tools", version = "0.8.68" }
serde.workspace = true
serde_json.workspace = true
rustls.workspace = true
tokio.workspace = true
tower-http.workspace = true
tracing.workspace = true
reqwest = { workspace = true, features = ["json"] }
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
tower = "0.5"
+829
View File
@@ -0,0 +1,829 @@
//! Provider-neutral `/v1/chat/completions` pass-through endpoint.
//!
//! This module resolves a model through the [`ModelRegistry`], looks up the
//! matching provider configuration, and forwards an OpenAI-compatible request
//! body upstream. It does **not** import or call any DeepSeek-named client
//! APIs — routing stays in neutral config/provider types.
//!
//! Only providers whose [`WireFormat`] is [`WireFormat::ChatCompletions`] are
//! served. Streaming requests are explicitly rejected for now.
use std::collections::BTreeMap;
use axum::Json;
use axum::extract::State;
use axum::http::{HeaderMap, HeaderName, StatusCode};
use axum::response::IntoResponse;
use codewhale_agent::ModelRegistry;
use codewhale_config::{ConfigToml, ProviderKind, provider::WireFormat};
use serde_json::Value;
use super::AppState;
// ── Resolved endpoint ──────────────────────────────────────────────────
/// Everything needed to forward a single chat-completions request upstream.
#[derive(Debug, Clone)]
struct ResolvedModelEndpoint {
provider: ProviderKind,
base_url: String,
model: String,
api_key: Option<String>,
http_headers: BTreeMap<String, String>,
path_suffix: Option<String>,
insecure_skip_tls_verify: bool,
wire_format: WireFormat,
}
// ── Resolution ─────────────────────────────────────────────────────────
/// Resolve a provider endpoint from the app configuration + an optional
/// `model` field pulled out of the incoming request body.
fn resolve_endpoint(
config: &ConfigToml,
registry: &ModelRegistry,
request_model: Option<&str>,
) -> ResolvedModelEndpoint {
let provider_kind = provider_for_request(config, registry, request_model);
let provider_cfg = config.providers.for_provider(provider_kind);
let provider_meta = provider_kind.provider();
// Base URL: configured → default
let base_url = provider_cfg
.base_url
.clone()
.unwrap_or_else(|| provider_meta.default_base_url().to_string());
// Model: request → configured → provider-level configured → default
let model = request_model
.filter(|m| !m.trim().is_empty())
.map(str::to_string)
.or_else(|| provider_cfg.model.clone())
.unwrap_or_else(|| provider_meta.default_model().to_string());
// API key: configured → environment
let api_key = provider_cfg.api_key.clone().or_else(|| {
provider_meta
.env_vars()
.iter()
.find_map(|var| std::env::var(var).ok())
});
let http_headers = provider_cfg.http_headers.clone();
let path_suffix = provider_cfg.path_suffix.clone();
let insecure_skip_tls_verify = provider_cfg.insecure_skip_tls_verify.unwrap_or(false);
let wire_format = provider_meta.wire();
ResolvedModelEndpoint {
provider: provider_kind,
base_url,
model,
api_key,
http_headers,
path_suffix,
insecure_skip_tls_verify,
wire_format,
}
}
/// Determine which provider to use for a chat-completions request.
///
/// 1. If the request includes a `model` name, resolve it through the registry.
/// When the registry finds a match (not a fallback), use that provider.
/// 2. Otherwise fall back to the configured default provider.
fn provider_for_request(
config: &ConfigToml,
registry: &ModelRegistry,
request_model: Option<&str>,
) -> ProviderKind {
if let Some(model_name) = request_model {
let resolved = registry.resolve(Some(model_name), None);
// Only use the registry's provider hint when the model was actually
// matched; otherwise the registry's fallback is noise and we should
// respect the configured default provider.
if !resolved.used_fallback {
return resolved.resolved.provider;
}
}
// Fall back to configured provider.
config.provider
}
/// Build the upstream URL.
fn upstream_url(endpoint: &ResolvedModelEndpoint) -> String {
let base = endpoint.base_url.trim_end_matches('/');
match endpoint.path_suffix.as_deref() {
Some(suffix) if !suffix.trim().is_empty() => format!(
"{}/{}",
unversioned_base_url(base),
suffix.trim_start_matches('/')
),
_ => {
let mut versioned = versioned_base_url(base);
if versioned
.rsplit('/')
.next()
.is_some_and(|segment| segment.eq_ignore_ascii_case("beta"))
{
versioned = format!("{}/v1", unversioned_base_url(base));
}
format!("{}/chat/completions", versioned.trim_end_matches('/'))
}
}
}
fn versioned_base_url(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
if base_url_has_version_suffix(trimmed) {
trimmed.to_string()
} else {
format!("{trimmed}/v1")
}
}
fn unversioned_base_url(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
trimmed
.rsplit_once('/')
.filter(|(_, segment)| is_version_segment(segment))
.map(|(base, _)| base)
.unwrap_or(trimmed)
.to_string()
}
fn base_url_has_version_suffix(trimmed: &str) -> bool {
trimmed.rsplit('/').next().is_some_and(is_version_segment)
}
fn is_version_segment(segment: &str) -> bool {
segment.eq_ignore_ascii_case("beta")
|| segment
.strip_prefix('v')
.or_else(|| segment.strip_prefix('V'))
.is_some_and(|rest| !rest.is_empty() && rest.chars().all(|ch| ch.is_ascii_digit()))
}
// ── Route handler ──────────────────────────────────────────────────────
pub(crate) async fn chat_completions_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(mut body): Json<Value>,
) -> impl IntoResponse {
// Reject streaming early.
if body
.get("stream")
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": "streaming is not supported on this endpoint",
"type": "unsupported_parameter",
"code": "streaming_unsupported"
}
})),
)
.into_response();
}
// Extract model from body.
let request_model = body.get("model").and_then(|v| v.as_str());
// Resolve endpoint.
let config = state.config.read().await;
let endpoint = resolve_endpoint(&config, &state.registry, request_model);
// Only ChatCompletions providers are supported.
if endpoint.wire_format != WireFormat::ChatCompletions {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": format!(
"provider {:?} uses {:?} wire format, only ChatCompletions is supported",
endpoint.provider, endpoint.wire_format
),
"type": "unsupported_provider",
"code": "provider_wire_format_unsupported"
}
})),
)
.into_response();
}
// Inject default model if the request didn't include one.
if request_model.is_none() || request_model.is_some_and(|m| m.trim().is_empty()) {
body["model"] = serde_json::Value::String(endpoint.model.clone());
}
let url = upstream_url(&endpoint);
if endpoint.insecure_skip_tls_verify {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": {
"message": format!(
"TLS certificate verification cannot be disabled for provider {:?}; use SSL_CERT_FILE with a trusted custom CA bundle",
endpoint.provider
),
"type": "invalid_request_error",
"code": "tls_verification_required"
}
})),
)
.into_response();
}
// Build upstream request.
let upstream_req = codewhale_release::platform_http_client_builder()
.build()
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": {
"message": format!("failed to build upstream client: {e}"),
"type": "internal_error"
}
})),
)
.into_response()
})
.map(|client| {
let mut req = client.post(&url).json(&body);
// Auth: configured API key takes priority (the proxy owns credentials).
// Incoming Bearer header is only used as a fallback when no configured key exists.
let auth_from_header = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|raw| raw.strip_prefix("Bearer "));
let api_key = endpoint.api_key.as_deref().or(auth_from_header);
if let Some(key) = api_key {
req = req.bearer_auth(key);
}
// Forward configured provider headers.
for (name, value) in &endpoint.http_headers {
if let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) {
req = req.header(header_name, value.as_str());
}
}
req
});
let client = match upstream_req {
Ok(client) => client,
Err(resp) => return resp,
};
// Execute upstream request.
match client.send().await {
Ok(upstream_resp) => {
let status = upstream_resp.status();
let headers = upstream_resp.headers().clone();
match upstream_resp.text().await {
Ok(body_text) => {
let mut response =
axum::response::Response::new(axum::body::Body::from(body_text));
*response.status_mut() = status;
// Forward relevant upstream headers.
if let Some(ct) = headers.get("content-type") {
response.headers_mut().insert("content-type", ct.clone());
}
response
}
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({
"error": {
"message": format!("failed to read upstream response: {e}"),
"type": "upstream_error"
}
})),
)
.into_response(),
}
}
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({
"error": {
"message": format!("upstream request failed: {e}"),
"type": "upstream_error"
}
})),
)
.into_response(),
}
}
// ── Tests ──────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Method, Request};
use codewhale_config::provider::WireFormat;
use std::fs;
use std::sync::OnceLock;
use tower::ServiceExt;
use super::super::{app_router, build_state};
fn install_crypto_provider() {
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
/// Start a minimal upstream mock server that echoes back what it received.
async fn start_mock_upstream() -> (String, tokio::task::JoinHandle<()>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let base_url = format!("http://{}:{}", addr.ip(), addr.port());
let handle = tokio::spawn(async move {
let app = axum::Router::new()
.route("/v1/chat/completions", axum::routing::post(mock_handler));
axum::serve(listener, app).await.unwrap();
});
// Give the server a moment to start.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(base_url, handle)
}
async fn mock_handler(
headers: axum::http::HeaderMap,
Json(body): Json<Value>,
) -> impl axum::response::IntoResponse {
let auth = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("none");
let response_body = serde_json::json!({
"id": "chatcmpl-mock",
"object": "chat.completion",
"created": 1234567890,
"model": body.get("model").and_then(|v| v.as_str()).unwrap_or("unknown"),
"choices": [{
"index": 0,
"message": {
"role": "assistant",
"content": format!("echo: received {} messages, auth={auth}",
body.get("messages").and_then(|m| m.as_array()).map(|a| a.len()).unwrap_or(0))
},
"finish_reason": "stop"
}],
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
});
(StatusCode::OK, Json(response_body))
}
fn app_with_mock_upstream(
auth_token: Option<&str>,
mock_base_url: &str,
) -> (axum::Router, tempfile::TempDir) {
app_with_mock_upstream_with_provider_extra(auth_token, mock_base_url, "")
}
fn app_with_mock_upstream_with_provider_extra(
auth_token: Option<&str>,
mock_base_url: &str,
provider_extra: &str,
) -> (axum::Router, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("tempdir");
let config_path = tmp.path().join("config.toml");
let config_content = format!(
r#"
provider = "arcee"
api_key = "sk-deepseek-secret"
[providers.arcee]
base_url = "{mock_base_url}"
model = "trinity-large-thinking"
api_key = "arcee-configured-key"
{provider_extra}
"#
);
fs::write(&config_path, config_content).expect("write config");
let state = build_state(
Some(config_path),
auth_token.map(std::string::ToString::to_string),
)
.expect("state");
(app_router(state, &[]), tmp)
}
async fn response_body_json(response: axum::response::Response) -> Value {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes");
serde_json::from_slice(&bytes).expect("json response")
}
#[tokio::test]
async fn forwards_messages_and_tools() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
let body = serde_json::json!({
"model": "trinity-large-thinking",
"messages": [
{"role": "user", "content": "hello"}
],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}}
}
}],
"tool_choice": "auto"
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_body = response_body_json(response).await;
assert_eq!(resp_body["model"], "trinity-large-thinking");
assert!(
resp_body["choices"][0]["message"]["content"]
.as_str()
.unwrap()
.contains("1 messages")
);
}
#[tokio::test]
async fn default_model_injected_when_omitted() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
let body = serde_json::json!({
"messages": [
{"role": "user", "content": "hello"}
]
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_body = response_body_json(response).await;
// The mock echoes the model it received; should be the configured default.
assert_eq!(resp_body["model"], "trinity-large-thinking");
}
#[tokio::test]
async fn configured_model_preserved_when_provided() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
let body = serde_json::json!({
"model": "custom-model-v2",
"messages": [
{"role": "user", "content": "hello"}
]
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_body = response_body_json(response).await;
assert_eq!(resp_body["model"], "custom-model-v2");
}
#[tokio::test]
async fn configured_api_key_takes_priority_over_incoming_bearer() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
let body = serde_json::json!({
"model": "trinity-large-thinking",
"messages": [
{"role": "user", "content": "hello"}
]
});
// Send with an explicit bearer token, but the configured key should win.
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.header("authorization", "Bearer user-provided-secret-key")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_body = response_body_json(response).await;
let content = resp_body["choices"][0]["message"]["content"]
.as_str()
.unwrap();
// The configured key takes priority, not the incoming Bearer.
assert!(
content.contains("auth=Bearer arcee-configured-key"),
"expected configured auth in mock echo, got: {content}"
);
}
#[tokio::test]
async fn configured_api_key_used_when_no_bearer_in_request() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
let body = serde_json::json!({
"model": "trinity-large-thinking",
"messages": [
{"role": "user", "content": "hello"}
]
});
// No Authorization header; the configured key should be used.
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let resp_body = response_body_json(response).await;
let content = resp_body["choices"][0]["message"]["content"]
.as_str()
.unwrap();
assert!(
content.contains("auth=Bearer arcee-configured-key"),
"expected configured auth in mock echo, got: {content}"
);
}
#[tokio::test]
async fn insecure_tls_skip_verify_is_rejected() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream_with_provider_extra(
None,
&mock_url,
"insecure_skip_tls_verify = true",
);
let body = serde_json::json!({
"model": "trinity-large-thinking",
"messages": [
{"role": "user", "content": "hello"}
]
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let resp_body = response_body_json(response).await;
assert_eq!(resp_body["error"]["code"], "tls_verification_required");
assert!(
resp_body["error"]["message"]
.as_str()
.unwrap()
.contains("SSL_CERT_FILE")
);
}
#[tokio::test]
async fn streaming_request_rejected() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream(None, &mock_url);
let body = serde_json::json!({
"model": "trinity-large-thinking",
"messages": [
{"role": "user", "content": "hello"}
],
"stream": true
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let resp_body = response_body_json(response).await;
assert_eq!(resp_body["error"]["code"], "streaming_unsupported");
}
#[tokio::test]
async fn requires_bearer_token_when_auth_enabled() {
install_crypto_provider();
let (mock_url, _mock) = start_mock_upstream().await;
let (app, _tmp) = app_with_mock_upstream(Some("test-token"), &mock_url);
let body = serde_json::json!({
"messages": [{"role": "user", "content": "hello"}]
});
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri("/v1/chat/completions")
.header("content-type", "application/json")
.body(Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn non_chat_completions_provider_rejected() {
// Use the test to verify WireFormat checks work for non-ChatCompletions providers.
// Anthropic's wire format is AnthropicMessages; OpenaiCodex is Responses.
let endpoint = ResolvedModelEndpoint {
provider: ProviderKind::Anthropic,
base_url: "https://api.anthropic.com".to_string(),
model: "claude-sonnet-4-20250514".to_string(),
api_key: Some("sk-ant-test".to_string()),
http_headers: BTreeMap::new(),
path_suffix: None,
insecure_skip_tls_verify: false,
wire_format: WireFormat::AnthropicMessages,
};
assert_ne!(endpoint.wire_format, WireFormat::ChatCompletions);
// The handler would reject this; we verify the wire format here.
assert_eq!(endpoint.wire_format, WireFormat::AnthropicMessages);
}
#[test]
fn upstream_url_defaults_to_v1_chat_completions() {
let endpoint = ResolvedModelEndpoint {
provider: ProviderKind::Arcee,
base_url: "https://api.arcee.ai".to_string(),
model: "trinity".to_string(),
api_key: None,
http_headers: BTreeMap::new(),
path_suffix: None,
insecure_skip_tls_verify: false,
wire_format: WireFormat::ChatCompletions,
};
assert_eq!(
upstream_url(&endpoint),
"https://api.arcee.ai/v1/chat/completions"
);
}
#[test]
fn upstream_url_preserves_arcee_api_v1_base() {
let endpoint = ResolvedModelEndpoint {
provider: ProviderKind::Arcee,
base_url: "https://api.arcee.ai/api/v1".to_string(),
model: "trinity".to_string(),
api_key: None,
http_headers: BTreeMap::new(),
path_suffix: None,
insecure_skip_tls_verify: false,
wire_format: WireFormat::ChatCompletions,
};
assert_eq!(
upstream_url(&endpoint),
"https://api.arcee.ai/api/v1/chat/completions"
);
}
#[test]
fn upstream_url_respects_path_suffix() {
let endpoint = ResolvedModelEndpoint {
provider: ProviderKind::Openrouter,
base_url: "https://openrouter.ai/api/v1".to_string(),
model: "deepseek/deepseek-v4-pro".to_string(),
api_key: None,
http_headers: BTreeMap::new(),
path_suffix: Some("/chat/completions".to_string()),
insecure_skip_tls_verify: false,
wire_format: WireFormat::ChatCompletions,
};
assert_eq!(
upstream_url(&endpoint),
"https://openrouter.ai/api/chat/completions"
);
}
#[test]
fn upstream_url_beta_base_uses_standard_v1_chat_completions() {
let endpoint = ResolvedModelEndpoint {
provider: ProviderKind::Deepseek,
base_url: "https://api.deepseek.com/beta".to_string(),
model: "deepseek-chat".to_string(),
api_key: None,
http_headers: BTreeMap::new(),
path_suffix: None,
insecure_skip_tls_verify: false,
wire_format: WireFormat::ChatCompletions,
};
assert_eq!(
upstream_url(&endpoint),
"https://api.deepseek.com/v1/chat/completions"
);
}
#[test]
fn upstream_url_strips_trailing_slash() {
let endpoint = ResolvedModelEndpoint {
provider: ProviderKind::Deepseek,
base_url: "https://api.deepseek.com/".to_string(),
model: "deepseek-chat".to_string(),
api_key: None,
http_headers: BTreeMap::new(),
path_suffix: None,
insecure_skip_tls_verify: false,
wire_format: WireFormat::ChatCompletions,
};
assert_eq!(
upstream_url(&endpoint),
"https://api.deepseek.com/v1/chat/completions"
);
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "codewhale-build-support"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Shared build-script helpers for embedding CodeWhale build metadata"
[dependencies]
+173
View File
@@ -0,0 +1,173 @@
//! Shared build-script helpers for the `codewhale-cli` and `codewhale-tui`
//! build scripts: rerun-condition declarations and the embedded
//! `DEEPSEEK_BUILD_VERSION` metadata. Only call these functions from a build
//! script — they emit `cargo:` directives on stdout.
use std::{
path::{Path, PathBuf},
process::Command,
};
/// Declare the rerun conditions for the build-metadata directives: the
/// SHA-override environment variables plus the git files that track `HEAD`.
///
/// `manifest_dir` is the calling build script's `CARGO_MANIFEST_DIR`.
pub fn declare_rerun_conditions(manifest_dir: &Path) {
println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
declare_git_head_rerun(manifest_dir);
}
/// Emit `cargo:rustc-env=DEEPSEEK_BUILD_VERSION=...` — the package version,
/// suffixed with the short build SHA when one can be determined.
///
/// `manifest_dir` and `package_version` are the calling build script's
/// `CARGO_MANIFEST_DIR` and `CARGO_PKG_VERSION`.
pub fn emit_build_version(manifest_dir: &Path, package_version: &str) {
let build_version = build_sha(manifest_dir)
.map(|sha| format!("{package_version} ({sha})"))
.unwrap_or_else(|| package_version.to_string());
println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
}
/// Tell Cargo to invalidate the cached build script output when `HEAD`
/// moves, so the embedded short-SHA stays in sync with the checkout.
///
/// `.git/HEAD` only changes on branch switches and detached-HEAD moves —
/// `git commit` on the current branch updates the underlying ref file
/// (loose `refs/heads/<name>`, or `packed-refs` after `git pack-refs`)
/// without touching `HEAD` itself. So when `HEAD` is a symbolic ref we
/// also watch the resolved target and `packed-refs`. A non-existent
/// `rerun-if-changed` path is treated as "always changed" by Cargo, which
/// covers the loose→packed transition.
fn declare_git_head_rerun(manifest_dir: &Path) {
let workspace_root = manifest_dir.join("..").join("..");
let git_meta = workspace_root.join(".git");
let gitdir = if git_meta.is_dir() {
git_meta
} else if git_meta.is_file() {
// Worktree pointer file: watch it directly, then follow `gitdir:`.
println!("cargo:rerun-if-changed={}", git_meta.display());
let Ok(contents) = std::fs::read_to_string(&git_meta) else {
return;
};
let Some(rest) = contents.lines().find_map(|l| l.strip_prefix("gitdir:")) else {
return;
};
let trimmed = rest.trim();
if Path::new(trimmed).is_absolute() {
PathBuf::from(trimmed)
} else {
workspace_root.join(trimmed)
}
} else {
return;
};
let head = gitdir.join("HEAD");
println!("cargo:rerun-if-changed={}", head.display());
if let Ok(contents) = std::fs::read_to_string(&head)
&& let Some(target) = parse_symbolic_ref(&contents)
{
println!("cargo:rerun-if-changed={}", gitdir.join(target).display());
println!(
"cargo:rerun-if-changed={}",
gitdir.join("packed-refs").display()
);
}
}
/// If `.git/HEAD` is a symbolic ref (`ref: refs/heads/...`) return the
/// target ref path. Returns `None` for a detached HEAD (raw SHA).
fn parse_symbolic_ref(head_contents: &str) -> Option<&str> {
head_contents
.lines()
.next()
.and_then(|line| line.strip_prefix("ref:"))
.map(str::trim)
.filter(|s| !s.is_empty())
}
fn build_sha(manifest_dir: &Path) -> Option<String> {
env_sha("DEEPSEEK_BUILD_SHA")
.or_else(|| env_sha("GITHUB_SHA"))
.or_else(|| git_sha(manifest_dir))
}
fn env_sha(name: &str) -> Option<String> {
std::env::var(name).ok().and_then(short_sha)
}
fn git_sha(manifest_dir: &Path) -> Option<String> {
let top_level_output = Command::new("git")
.args(["-C"])
.arg(manifest_dir)
.args(["rev-parse", "--show-toplevel"])
.output()
.ok()?;
if !top_level_output.status.success() {
return None;
}
let top_level = PathBuf::from(String::from_utf8_lossy(&top_level_output.stdout).trim());
if !top_level.join("Cargo.toml").is_file() || !top_level.join("crates/tui").is_dir() {
return None;
}
let output = Command::new("git")
.args(["-C"])
.arg(top_level)
.args(["rev-parse", "--short=12", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
short_sha(String::from_utf8_lossy(&output.stdout).to_string())
}
fn short_sha(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.chars().take(12).collect())
}
#[cfg(test)]
mod tests {
use super::parse_symbolic_ref;
#[test]
fn symbolic_ref_strips_prefix_and_whitespace() {
assert_eq!(
parse_symbolic_ref("ref: refs/heads/main\n"),
Some("refs/heads/main")
);
}
#[test]
fn symbolic_ref_handles_no_trailing_newline() {
assert_eq!(
parse_symbolic_ref("ref: refs/heads/work/v0.8.26-security"),
Some("refs/heads/work/v0.8.26-security")
);
}
#[test]
fn detached_head_is_not_a_symbolic_ref() {
assert_eq!(
parse_symbolic_ref("506343f44e48b9c2c8d6b2d3e8e8e8e8e8e8e8e8\n"),
None
);
}
#[test]
fn empty_input_returns_none() {
assert_eq!(parse_symbolic_ref(""), None);
assert_eq!(parse_symbolic_ref("ref: \n"), None);
}
}
+59
View File
@@ -0,0 +1,59 @@
[package]
name = "codewhale-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Agentic terminal facade for open-source and open-weight coding models"
[[bin]]
name = "codewhale"
path = "src/main.rs"
# Short-form convenience alias — forwards to `codewhale` silently.
[[bin]]
name = "codew"
path = "src/bin/codew_legacy_shim.rs"
[dependencies]
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
codewhale-agent = { path = "../agent", version = "0.8.68" }
codewhale-app-server = { path = "../app-server", version = "0.8.68" }
codewhale-config = { path = "../config", version = "0.8.68" }
codewhale-lane = { path = "../lane", version = "0.8.68" }
codewhale-workflow = { path = "../workflow", version = "0.8.68" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.68" }
codewhale-mcp = { path = "../mcp", version = "0.8.68" }
codewhale-release = { path = "../release", version = "0.8.68" }
codewhale-secrets = { path = "../secrets", version = "0.8.68" }
codewhale-state = { path = "../state", version = "0.8.68" }
chrono.workspace = true
dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
reqwest = { workspace = true, features = ["blocking"] }
rustls.workspace = true
semver.workspace = true
tokio.workspace = true
mimalloc.workspace = true
sha2.workspace = true
tempfile.workspace = true
tracing.workspace = true
[build-dependencies]
codewhale-build-support = { path = "../build-support", version = "0.8.68" }
# Parent-death cleanup for delegated server children (#3259): on Linux the
# dispatcher sets PR_SET_PDEATHSIG so the child is signalled if the dispatcher
# dies uncatchably; on Windows it assigns the child to a kill-on-job-close Job
# Object. Also used in main.rs to reset SIGPIPE to SIG_DFL on any Unix target
# (#4030), so libc must be available across all Unix, not just Linux.
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_System_JobObjects", "Win32_System_Threading"] }
[dev-dependencies]
+7
View File
@@ -0,0 +1,7 @@
use std::path::PathBuf;
fn main() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
codewhale_build_support::declare_rerun_conditions(&manifest_dir);
codewhale_build_support::emit_build_version(&manifest_dir, env!("CARGO_PKG_VERSION"));
}
+74
View File
@@ -0,0 +1,74 @@
//! Convenience `codew` alias.
//!
//! Forwards argv to the `codewhale` dispatcher silently. This is a
//! permanent short-form alias — six fewer keystrokes, same binary.
use std::env;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
let args: Vec<String> = env::args_os()
.skip(1)
.map(|a| a.to_string_lossy().into_owned())
.collect();
let status = match spawn_codewhale(&args) {
Ok(s) => s,
Err(e) => {
eprintln!(
"error: failed to spawn `codewhale`: {e}. Is it on PATH? \
Install with `cargo install codewhale-cli` or via npm/Homebrew."
);
std::process::exit(127);
}
};
std::process::exit(status.code().unwrap_or(1));
}
fn spawn_codewhale(args: &[String]) -> std::io::Result<std::process::ExitStatus> {
// Prefer the dispatcher installed next to this shim. Falling back to PATH
// first can silently run an older global `codewhale` after a fresh install.
if let Ok(exe_path) = env::current_exe()
&& let Some(sibling) = sibling_codewhale_path(&exe_path)
&& sibling.is_file()
{
return Command::new(sibling).args(args).status();
}
// Fall back to PATH for unusual installs that ship only the shim.
match Command::new("codewhale").args(args).status() {
Ok(s) => return Ok(s),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e),
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"codewhale not found on PATH or in sibling directory",
))
}
fn sibling_codewhale_path(exe_path: &Path) -> Option<PathBuf> {
exe_path
.parent()
.map(|dir| dir.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX)))
}
#[cfg(test)]
mod tests {
use super::sibling_codewhale_path;
use std::path::Path;
#[test]
fn sibling_dispatcher_uses_platform_executable_suffix() {
let path = Path::new("/tmp/codewhale-bin/codew");
let sibling = sibling_codewhale_path(path).expect("sibling");
assert_eq!(
sibling,
Path::new("/tmp/codewhale-bin")
.join(format!("codewhale{}", std::env::consts::EXE_SUFFIX))
);
}
}
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;
fn main() -> std::process::ExitCode {
// Reset SIGPIPE to SIG_DFL so piping codewhale output into a command that
// exits early (e.g. `codewhale doctor | head`) terminates the process
// cleanly with exit code 141 instead of panicking on the broken-pipe
// write. Many execution environments (systemd, Docker, some shells)
// inherit SIGPIPE set to SIG_IGN, which makes write(2) return EPIPE;
// Rust's `println!` then treats that io::Error as fatal and panics.
// See issue #4030.
#[cfg(unix)]
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
codewhale_cli::run_cli()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "codewhale-config"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "Config schema and precedence model for CodeWhale"
[dependencies]
anyhow.workspace = true
codewhale-execpolicy = { path = "../execpolicy", version = "0.8.68" }
codewhale-secrets = { path = "../secrets", version = "0.8.68" }
dirs.workspace = true
libc = "0.2"
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
toml.workspace = true
toml_edit.workspace = true
tracing.workspace = true
@@ -0,0 +1,605 @@
{
"_meta": {
"about": "Offline/stale fallback Models.dev-shaped catalog snapshot for CodeWhale (#3385, demoted by #4188).",
"schema": "Matches crates/config/src/models_dev.rs ModelsDevCatalog ({ models, providers }).",
"role": "NOT a competing source of truth. Preferred metadata is the live Models.dev catalog published into ProviderLake (#4187). This asset is used only when live/cache rows are unavailable (offline startup, failed refresh, or empty cache).",
"source": "Compact offline seed of verified in-repo defaults (context/output from crates/tui/src/models.rs; USD pricing from crates/tui/src/pricing.rs) for providers CodeWhale ships with. It is intentionally smaller than a full Models.dev dump; live refresh supersedes these rows on (provider, wire_model_id) identity.",
"honesty": "Pricing is intentionally OMITTED where the repo does not publish a trustworthy per-token rate: DeepSeek-native rows (priced via the time-aware DeepSeek table elsewhere, kept UnknownOrStale at the route layer), aggregator-hosted DeepSeek rows (aggregator account terms, not DeepSeek Platform pricing), and Xiaomi MiMo rows (published PAYG rates apply only to sk- pay-as-you-go keys; the catalog cannot distinguish that billing surface from credit/quota Token Plan keys, so MiMo stays unpriced). Absent pricing surfaces as PricingSku::UnknownOrStale, never a fabricated zero.",
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.",
"coverage": "14 providers, 42 chat offerings (offline seed only)."
},
"models": {
"deepseek-v4-pro": {
"id": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 },
"open_weights": true
},
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 },
"open_weights": true
}
},
"providers": {
"deepseek": {
"id": "deepseek",
"name": "DeepSeek",
"api": "https://api.deepseek.com",
"npm": "@ai-sdk/openai-compatible",
"env": ["DEEPSEEK_API_KEY"],
"models": {
"deepseek-v4-pro": {
"id": "deepseek-v4-pro",
"base_model": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro",
"family": "deepseek",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"base_model": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
}
}
},
"zai": {
"id": "zai",
"name": "Zhipu AI / Z.ai",
"api": "https://api.z.ai/api/paas/v4",
"npm": "@ai-sdk/openai-compatible",
"env": ["ZAI_API_KEY", "ZHIPU_API_KEY", "GLM_API_KEY"],
"models": {
"GLM-5.2": {
"id": "GLM-5.2",
"name": "GLM-5.2",
"family": "glm",
"default": true,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 },
"cost": { "input": 1.40, "output": 4.40, "cache_read": 0.26 }
},
"glm-5.1": {
"id": "glm-5.1",
"name": "GLM-5.1",
"family": "glm",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 202752, "output": 131072 },
"cost": { "input": 1.40, "output": 4.40, "cache_read": 0.26 }
},
"GLM-5-Turbo": {
"id": "GLM-5-Turbo",
"name": "GLM-5 Turbo",
"family": "glm",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 202752, "output": 131072 },
"cost": { "input": 1.20, "output": 4.00, "cache_read": 0.24 }
}
}
},
"moonshot": {
"id": "moonshot",
"name": "Moonshot / Kimi",
"api": "https://api.moonshot.ai/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["MOONSHOT_API_KEY", "KIMI_API_KEY"],
"models": {
"kimi-k2.7-code": {
"id": "kimi-k2.7-code",
"name": "Kimi K2.7 Code",
"family": "kimi",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 262144, "output": 262144 },
"cost": { "input": 0.95, "output": 4.00, "cache_read": 0.19 }
},
"kimi-k2.6": {
"id": "kimi-k2.6",
"name": "Kimi K2.6",
"family": "kimi",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 262144, "output": 262144 },
"cost": { "input": 0.95, "output": 4.00, "cache_read": 0.16 }
}
}
},
"minimax": {
"id": "minimax",
"name": "MiniMax",
"api": "https://api.minimax.io/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["MINIMAX_API_KEY"],
"models": {
"MiniMax-M3": {
"id": "MiniMax-M3",
"name": "MiniMax M3",
"family": "minimax",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 524288 },
"cost": { "input": 0.30, "output": 1.20, "cache_read": 0.06 }
},
"minimax-m2.7": {
"id": "minimax-m2.7",
"name": "MiniMax M2.7",
"family": "minimax",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 204800, "output": 204800 }
}
}
},
"meta": {
"id": "meta",
"name": "Meta Model API",
"api": "https://api.meta.ai/v1",
"npm": "@ai-sdk/openai",
"env": ["META_MODEL_API_KEY", "MODEL_API_KEY"],
"models": {
"muse-spark-1.1": {
"id": "muse-spark-1.1",
"name": "Muse Spark 1.1",
"family": "muse",
"default": true,
"attachment": true,
"reasoning": true,
"reasoning_options": [
{
"type": "effort",
"values": ["none", "minimal", "low", "medium", "high", "xhigh"]
}
],
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image", "pdf", "video"], "output": ["text"] },
"limit": { "context": 1000000, "output": 32000 },
"cost": { "input": 1.25, "output": 4.25 }
}
}
},
"openai": {
"id": "openai",
"name": "OpenAI-compatible",
"api": "https://api.openai.com/v1",
"npm": "@ai-sdk/openai",
"env": ["OPENAI_API_KEY"],
"models": {
"deepseek-v4-pro": {
"id": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro (OpenAI-compatible default)",
"family": "deepseek",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"gpt-5.5": {
"id": "gpt-5.5",
"name": "GPT-5.5",
"family": "gpt",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1050000, "output": 128000 },
"cost": { "input": 5.00, "output": 30.00, "cache_read": 0.50 }
},
"gpt-5.5-pro": {
"id": "gpt-5.5-pro",
"name": "GPT-5.5 Pro",
"family": "gpt",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1050000, "output": 128000 },
"cost": { "input": 30.00, "output": 180.00 }
},
"gpt-5.6": {
"id": "gpt-5.6",
"name": "GPT-5.6 Sol",
"family": "gpt",
"attachment": true,
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"limit": { "context": 1050000, "input": 922000, "output": 128000 },
"cost": { "input": 5.00, "output": 30.00, "cache_read": 0.50, "cache_write": 6.25 }
},
"gpt-5.6-sol": {
"id": "gpt-5.6-sol",
"name": "GPT-5.6 Sol",
"family": "gpt",
"attachment": true,
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"limit": { "context": 1050000, "input": 922000, "output": 128000 },
"cost": { "input": 5.00, "output": 30.00, "cache_read": 0.50, "cache_write": 6.25 }
},
"gpt-5.6-terra": {
"id": "gpt-5.6-terra",
"name": "GPT-5.6 Terra",
"family": "gpt-mini",
"attachment": true,
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"limit": { "context": 1050000, "input": 922000, "output": 128000 },
"cost": { "input": 2.50, "output": 15.00, "cache_read": 0.25, "cache_write": 3.125 }
},
"gpt-5.6-luna": {
"id": "gpt-5.6-luna",
"name": "GPT-5.6 Luna",
"family": "gpt-nano",
"attachment": true,
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"limit": { "context": 1050000, "input": 922000, "output": 128000 },
"cost": { "input": 1.00, "output": 6.00, "cache_read": 0.10, "cache_write": 1.25 }
},
"gpt-5.3-codex": {
"id": "gpt-5.3-codex",
"name": "GPT-5.3 Codex",
"family": "gpt-codex",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 400000, "output": 128000 },
"cost": { "input": 1.75, "output": 14.00, "cache_read": 0.175 }
}
}
},
"anthropic": {
"id": "anthropic",
"name": "Anthropic",
"api": "https://api.anthropic.com",
"npm": "@ai-sdk/anthropic",
"env": ["ANTHROPIC_API_KEY"],
"models": {
"claude-sonnet-4-6": {
"id": "claude-sonnet-4-6",
"name": "Claude Sonnet 4.6",
"family": "claude",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 128000 },
"cost": { "input": 3.00, "output": 15.00, "cache_read": 0.30, "cache_write": 3.75 }
},
"claude-opus-4-8": {
"id": "claude-opus-4-8",
"name": "Claude Opus 4.8",
"family": "claude",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 128000 },
"cost": { "input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25 }
},
"claude-haiku-4-5": {
"id": "claude-haiku-4-5",
"name": "Claude Haiku 4.5",
"family": "claude",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 200000, "output": 64000 },
"cost": { "input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25 }
},
"claude-sonnet-5": {
"id": "claude-sonnet-5",
"name": "Claude Sonnet 5",
"family": "claude",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 128000 },
"cost": { "input": 3.00, "output": 15.00, "cache_read": 0.30 }
},
"claude-fable-5": {
"id": "claude-fable-5",
"name": "Claude Fable 5",
"family": "claude",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 128000 },
"cost": { "input": 10.00, "output": 50.00, "cache_read": 1.00, "cache_write": 12.50 }
}
}
},
"openrouter": {
"id": "openrouter",
"name": "OpenRouter",
"api": "https://openrouter.ai/api/v1",
"npm": "@openrouter/ai-sdk-provider",
"env": ["OPENROUTER_API_KEY"],
"models": {
"deepseek/deepseek-v4-pro": {
"id": "deepseek/deepseek-v4-pro",
"base_model": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro (OpenRouter)",
"family": "deepseek",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"deepseek/deepseek-v4-flash": {
"id": "deepseek/deepseek-v4-flash",
"base_model": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash (OpenRouter)",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"qwen/qwen3.6-flash": {
"id": "qwen/qwen3.6-flash",
"name": "Qwen3.6 Flash",
"family": "qwen",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 65536 },
"cost": { "input": 0.1875, "output": 1.125 }
},
"qwen/qwen3.6-plus": {
"id": "qwen/qwen3.6-plus",
"name": "Qwen3.6 Plus",
"family": "qwen",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 65536 },
"cost": { "input": 0.325, "output": 1.95 }
},
"qwen/qwen3.6-35b-a3b": {
"id": "qwen/qwen3.6-35b-a3b",
"name": "Qwen3.6 35B-A3B",
"family": "qwen",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 262144, "output": 262140 },
"cost": { "input": 0.14, "output": 1.00, "cache_read": 0.05 }
},
"qwen/qwen3.7-plus": {
"id": "qwen/qwen3.7-plus",
"name": "Qwen3.7 Plus",
"family": "qwen",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"cost": { "input": 0.32, "output": 1.28, "cache_read": 0.064, "cache_write": 0.40 }
},
"minimax/minimax-m3": {
"id": "minimax/minimax-m3",
"name": "MiniMax M3 (OpenRouter)",
"family": "minimax",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 524288 },
"cost": { "input": 0.30, "output": 1.20, "cache_read": 0.06 }
},
"z-ai/glm-5.2": {
"id": "z-ai/glm-5.2",
"name": "GLM-5.2 (OpenRouter)",
"family": "glm",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 }
}
}
},
"together": {
"id": "together",
"name": "Together AI",
"api": "https://api.together.xyz/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["TOGETHER_API_KEY"],
"models": {
"deepseek-ai/DeepSeek-V4-Pro": {
"id": "deepseek-ai/DeepSeek-V4-Pro",
"base_model": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro (Together)",
"family": "deepseek",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"deepseek-ai/DeepSeek-V4-Flash": {
"id": "deepseek-ai/DeepSeek-V4-Flash",
"base_model": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash (Together)",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
}
}
},
"fireworks": {
"id": "fireworks",
"name": "Fireworks AI",
"api": "https://api.fireworks.ai/inference/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["FIREWORKS_API_KEY"],
"models": {
"accounts/fireworks/models/deepseek-v4-pro": {
"id": "accounts/fireworks/models/deepseek-v4-pro",
"base_model": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro (Fireworks)",
"family": "deepseek",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
}
}
},
"novita": {
"id": "novita",
"name": "Novita AI",
"api": "https://api.novita.ai/openai/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["NOVITA_API_KEY"],
"models": {
"deepseek/deepseek-v4-pro": {
"id": "deepseek/deepseek-v4-pro",
"base_model": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro (Novita)",
"family": "deepseek",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"deepseek/deepseek-v4-flash": {
"id": "deepseek/deepseek-v4-flash",
"base_model": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash (Novita)",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
}
}
},
"siliconflow": {
"id": "siliconflow",
"name": "SiliconFlow",
"api": "https://api.siliconflow.com/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["SILICONFLOW_API_KEY"],
"models": {
"deepseek-ai/DeepSeek-V4-Pro": {
"id": "deepseek-ai/DeepSeek-V4-Pro",
"base_model": "deepseek-v4-pro",
"name": "DeepSeek V4 Pro (SiliconFlow)",
"family": "deepseek",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"deepseek-ai/DeepSeek-V4-Flash": {
"id": "deepseek-ai/DeepSeek-V4-Flash",
"base_model": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash (SiliconFlow)",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
}
}
},
"arcee": {
"id": "arcee",
"name": "Arcee AI",
"api": "https://api.arcee.ai/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["ARCEE_API_KEY"],
"models": {
"trinity-large-thinking": {
"id": "trinity-large-thinking",
"name": "Trinity Large Thinking",
"family": "trinity",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 262144, "output": 262144 },
"cost": { "input": 0.25, "output": 0.80 }
},
"trinity-mini": {
"id": "trinity-mini",
"name": "Trinity Mini",
"family": "trinity",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 128000 }
}
}
},
"xiaomi-mimo": {
"id": "xiaomi-mimo",
"name": "Xiaomi MiMo",
"api": "https://api-mimo.xiaomi.com/v1",
"npm": "@ai-sdk/openai-compatible",
"env": ["XIAOMI_MIMO_API_KEY", "MIMO_API_KEY"],
"models": {
"mimo-v2.5-pro": {
"id": "mimo-v2.5-pro",
"name": "MiMo v2.5 Pro",
"family": "mimo",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 }
},
"mimo-v2.5": {
"id": "mimo-v2.5",
"name": "MiMo v2.5",
"family": "mimo",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 }
}
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthSourceKind {
Command,
Secret,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ProviderAuthSourceToml {
#[serde(alias = "type")]
pub source: AuthSourceKind,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub command: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub secret_id: Option<String>,
}
impl ProviderAuthSourceToml {
pub fn validate(&self) -> Result<()> {
match self.source {
AuthSourceKind::Command => {
if self.command.is_empty() || self.command.iter().all(|part| part.trim().is_empty())
{
bail!(
"provider auth source command must include at least one non-empty argv item"
);
}
}
AuthSourceKind::Secret => {
if self
.secret_id
.as_deref()
.is_none_or(|secret_id| secret_id.trim().is_empty())
{
bail!("provider auth source secret must include secret_id");
}
}
}
Ok(())
}
#[must_use]
pub fn source_class(&self) -> &'static str {
match self.source {
AuthSourceKind::Command => "command",
AuthSourceKind::Secret => "secret",
}
}
}
+709
View File
@@ -0,0 +1,709 @@
//! Models.dev-backed provider catalog snapshots and a secret-free live cache
//! (#3385, feeding EPIC #2608 and #3383).
//!
//! This module is **network-free** by construction. Callers supply parsed
//! [`crate::models_dev::ModelsDevCatalog`] JSON (bundled snapshot or live
//! refresh) and live [`ProviderCatalogDelta`]s; the HTTP `/models` fetch layer
//! lives above this module. Nothing here performs I/O or reads credentials.
//!
//! Layering (lowest precedence first; #4188):
//!
//! ```text
//! bundled Models.dev snapshot (offline/stale fallback only — not competing truth)
//! < live Models.dev / provider `/models` cache
//! < user / custom overrides (custom endpoints, pinned models, explicit facts)
//! ```
//!
//! After #4187, live Models.dev rows are preferred whenever present. The bundled
//! asset remains so offline startup and failed refreshes still resolve defaults.
//!
//! Invariants preserved from #2608 / #3497:
//! - A catalog row is **not** an executable route. Rows still compile through
//! `RouteResolver` into a `ReadyRouteCandidate` before execution.
//! - `wire_model_id` is kept separate from `canonical_model`; a provider row may
//! not expose a canonical `base_model` join, and a prefix never proves
//! canonical ownership.
//! - Unknown / custom / local rows are supported with explicit provenance and a
//! `None` canonical model.
//!
//! The on-disk cache format intentionally uses plain `String` identity fields
//! rather than the internal route newtypes, so the persisted shape is decoupled
//! from internal types and trivially auditable for "no secrets" (see
//! [`ProviderCatalogCache`] tests).
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::models_dev::{ModelsDevCatalog, ModelsDevCost, ModelsDevLimit, ModelsDevModalities};
use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
/// Provenance of a catalog row. Drives layer precedence and UI provenance.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CatalogSource {
/// Offline/stale bundled seed (Models.dev-shaped snapshot). Not competing
/// truth — live Models.dev rows override this layer (#4188).
#[default]
Bundled,
/// A provider live `/models` row, scoped to a base-URL fingerprint and the
/// unix timestamp it was fetched at.
Live {
base_url_fingerprint: String,
fetched_at: u64,
},
/// A user / custom override (custom endpoint, pinned model, explicit facts).
UserOverride,
}
/// One catalog-layer offering row.
///
/// This carries the routing identity (provider + wire id + optional canonical
/// model + endpoint) plus the offering-owned Models.dev facts CodeWhale wants to
/// preserve (family, limits, cost, reasoning support/options). It is a superset
/// of [`ProviderModelOffering`]; use [`CatalogOffering::to_offering`] to project
/// the minimal routing identity the resolver consumes.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct CatalogOffering {
/// Provider id serving this offering.
pub provider: String,
/// Provider-owned wire id sent on the request (verbatim).
pub wire_model_id: String,
/// Canonical model identity, only when an explicit join exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_model: Option<String>,
/// Endpoint key the offering is served on (e.g. `chat`).
pub endpoint_key: String,
/// Whether this is the provider's default offering.
#[serde(default)]
pub default_for_provider: bool,
/// Model family/series as exposed for this offering (e.g. `glm`, `deepseek`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
/// Token limits for this offering, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<ModelsDevLimit>,
/// Provider-scoped pricing, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<ModelsDevCost>,
/// Input/output modalities for this offering, when known. Carried as the
/// raw Models.dev shape so a factual `text` vs `multimodal` label can be
/// derived without guessing; `None` means the layer did not state it (an
/// unknown, not "text-only").
#[serde(default, skip_serializing_if = "Option::is_none")]
pub modalities: Option<ModelsDevModalities>,
/// Whether this offering supports reasoning, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<bool>,
/// Whether tool calling is supported, when known (#4115).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_call: Option<bool>,
/// Provider-scoped reasoning controls / accepted effort metadata. Kept as
/// raw JSON so the same model family served through different gateways can
/// expose different effort vocabularies without lossy collapsing.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reasoning_options: Vec<Value>,
/// Where this row came from.
pub source: CatalogSource,
}
impl CatalogOffering {
/// The provider id as a route newtype.
#[must_use]
pub fn provider_id(&self) -> ProviderId {
ProviderId::from(self.provider.clone())
}
/// The wire model id as a route newtype.
#[must_use]
pub fn wire_id(&self) -> WireModelId {
WireModelId::from(self.wire_model_id.clone())
}
/// Project the minimal routing identity the resolver consumes.
///
/// The catalog deliberately carries richer facts than routing needs; this
/// drops most of them so `RouteResolver::from_offerings` stays the single
/// seam. The route-facing pricing meter is the exception: it is projected
/// here (where the offering's sourced `cost` is in scope) via
/// [`crate::pricing::route_pricing_sku`] so a resolved candidate can carry
/// honest pricing without the route layer ever seeing raw cost (#3085).
#[must_use]
pub fn to_offering(&self) -> ProviderModelOffering {
ProviderModelOffering {
provider: self.provider_id(),
canonical_model: self.canonical_model.clone().map(ModelId::from),
wire_model_id: self.wire_id(),
endpoint_key: self.endpoint_key.clone(),
default_for_provider: self.default_for_provider,
limits: self
.limit
.as_ref()
.map(RouteLimits::from)
.unwrap_or_default(),
pricing: crate::pricing::route_pricing_sku(self),
}
}
/// Stable identity key for de-duplication and layer merging.
fn merge_key(&self) -> (String, String) {
(self.provider.clone(), self.wire_model_id.clone())
}
}
/// Committed offline/stale Models.dev-shaped catalog snapshot (#3385 / #4188).
///
/// This is **not** a competing curated source of truth. Preferred metadata comes
/// from the live Models.dev catalog (#4187). The bundled asset is a compact
/// network-free seed of verified in-repo defaults (context/output from
/// `crates/tui/src/models.rs`, USD pricing from `crates/tui/src/pricing.rs`) so
/// [`crate::route::RouteResolver::new`] and pickers still work offline or after
/// a failed refresh. See the asset's `_meta.role` / `_meta.source` and the
/// honesty rule on omitted pricing (`UnknownOrStale`, never a fabricated zero).
pub const BUNDLED_MODELS_DEV_JSON: &str = include_str!("../assets/models_dev.bundled.json");
/// Parse the committed bundled Models.dev snapshot.
///
/// # Panics
/// Panics only if the committed asset is not valid Models.dev JSON. The
/// `tests::bundled_asset_parses` guard makes that a build-time failure, so this
/// never panics in shipped builds.
#[must_use]
pub fn bundled_models_dev_catalog() -> ModelsDevCatalog {
ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
.expect("committed bundled Models.dev asset must be valid JSON")
}
/// Bundled-layer [`CatalogOffering`] rows from the offline snapshot (#4188).
///
/// Lowest-precedence catalog layer: every text-chat row from
/// [`BUNDLED_MODELS_DEV_JSON`], tagged [`CatalogSource::Bundled`]. Live Models.dev
/// rows override these on `(provider, wire_model_id)` when available.
#[must_use]
pub fn bundled_catalog_offerings() -> Vec<CatalogOffering> {
bundled_offerings_from_models_dev(&bundled_models_dev_catalog())
}
/// Hydrate bundled [`CatalogOffering`] rows from a parsed Models.dev catalog.
///
/// Only text-chat offerings are emitted (TTS/audio-only rows stay in the parsed
/// catalog but are excluded from route candidates, matching
/// [`ModelsDevCatalog::provider_offerings`]). Each row is tagged
/// [`CatalogSource::Bundled`]. No canonical model is inferred from a prefix; the
/// canonical link is set only from an explicit `base_model`.
///
/// Provider ids are kept verbatim from the Models.dev payload (the committed
/// bundled asset already uses CodeWhale ids). Live refresh normalizes aliases
/// via [`live_offerings_from_models_dev`].
#[must_use]
pub fn bundled_offerings_from_models_dev(catalog: &ModelsDevCatalog) -> Vec<CatalogOffering> {
offerings_from_models_dev(catalog, CatalogSource::Bundled, false)
}
/// Hydrate live [`CatalogOffering`] rows from a fetched Models.dev catalog (#4187).
///
/// Same text-chat filter as [`bundled_offerings_from_models_dev`], but each row is
/// tagged [`CatalogSource::Live`] with the Models.dev URL fingerprint and fetch
/// timestamp. Provider keys are normalized onto CodeWhale [`crate::ProviderKind`]
/// ids when an alias match exists (`moonshotai` → `moonshot`, `togetherai` →
/// `together`, `zhipuai` → `zai`, …); unknown Models.dev providers keep their
/// upstream id so they stay discoverable without becoming executable routes.
#[must_use]
pub fn live_offerings_from_models_dev(
catalog: &ModelsDevCatalog,
base_url_fingerprint: &str,
fetched_at: u64,
) -> Vec<CatalogOffering> {
offerings_from_models_dev(
catalog,
CatalogSource::Live {
base_url_fingerprint: base_url_fingerprint.to_string(),
fetched_at,
},
true,
)
}
fn offerings_from_models_dev(
catalog: &ModelsDevCatalog,
source: CatalogSource,
normalize_provider_ids: bool,
) -> Vec<CatalogOffering> {
let mut out = Vec::new();
for (provider_key, provider) in &catalog.providers {
let raw_id = if provider.id.trim().is_empty() {
provider_key.trim()
} else {
provider.id.trim()
};
if raw_id.is_empty() {
continue;
}
let provider_id = if normalize_provider_ids {
// Normalize Models.dev provider ids onto CodeWhale kinds when known
// (#4186). Unknown upstream ids are kept verbatim for catalog browsing.
crate::ProviderKind::parse(raw_id)
.map(|kind| kind.as_str().to_string())
.unwrap_or_else(|| raw_id.to_string())
} else {
raw_id.to_string()
};
for model in provider.models.values() {
if !model.supports_text_chat() {
continue;
}
out.push(CatalogOffering {
provider: provider_id.clone(),
wire_model_id: model.id.clone(),
canonical_model: model.base_model.clone(),
endpoint_key: "chat".to_string(),
default_for_provider: model.default_for_provider,
family: model.family.clone(),
limit: model.limit.clone(),
cost: model.cost.clone(),
modalities: model.modalities.clone(),
reasoning: model.reasoning,
tool_call: model.tool_call,
reasoning_options: model.reasoning_options.clone(),
source: source.clone(),
});
}
}
out
}
/// A provider's live `/models` refresh result, scoped to a base-URL fingerprint.
///
/// Returned as a delta rather than mutating any global model state directly, per
/// the #3385 architecture contract.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProviderCatalogDelta {
/// Provider this delta belongs to.
pub provider: String,
/// Fingerprint of the base URL the rows were fetched from.
pub base_url_fingerprint: String,
/// Unix seconds the rows were fetched at.
pub fetched_at: u64,
/// Live offering rows. Sources are normalized to `Live` on ingest.
pub offerings: Vec<CatalogOffering>,
}
/// Why a provider live catalog refresh did not produce usable rows.
///
/// Every variant must leave previously cached / bundled / configured rows
/// available; a refresh failure is never fatal to model selection.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CatalogRefreshError {
/// 401 — auth missing or invalid.
Unauthorized,
/// 403 — auth present but not permitted.
Forbidden,
/// 404 — provider does not expose `/models` at this base URL.
NotFound,
/// 429 — rate limited.
RateLimited,
/// Response was not parseable as a model listing.
InvalidResponse,
/// Provider returned an empty model list.
EmptyList,
/// Transport / network failure.
Network,
}
/// Freshness / health of a provider's cached live catalog.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum CatalogStatus {
/// Cached rows are within their TTL.
Fresh,
/// Cached rows exist but are past their TTL.
Stale { age_secs: u64 },
/// The last refresh failed; any rows present are from an earlier success.
Failed { reason: CatalogRefreshError },
/// No refresh has been attempted for this provider + base URL.
Unknown,
}
/// A secret-free cached provider catalog for one provider + base-URL fingerprint.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CachedProviderCatalog {
/// Provider id.
pub provider: String,
/// Base-URL fingerprint the rows were fetched from.
pub base_url_fingerprint: String,
/// Unix seconds of the last successful fetch (unchanged on failure).
pub fetched_at: u64,
/// Time-to-live, in seconds, after which rows are considered stale.
pub ttl_secs: u64,
/// Cached live offering rows (possibly empty after a failure with no prior).
pub offerings: Vec<CatalogOffering>,
/// Last known status of this entry.
pub status: CatalogStatus,
}
impl CachedProviderCatalog {
/// Age in seconds relative to `now_unix`, saturating at zero for clock skew.
#[must_use]
pub fn age_secs(&self, now_unix: u64) -> u64 {
now_unix.saturating_sub(self.fetched_at)
}
/// Whether the cached rows are past their TTL at `now_unix`.
///
/// A `ttl_secs` of zero means "always stale" (never serve as fresh).
#[must_use]
pub fn is_stale(&self, now_unix: u64) -> bool {
self.age_secs(now_unix) >= self.ttl_secs
}
/// Whether this entry may contribute live offerings at `now_unix`.
///
/// An entry is fresh only when it is within its TTL **and** its last
/// recorded refresh succeeded. A `Failed` entry is never fresh even inside
/// its TTL window — its rows survive a failed refresh for explicit fallback
/// display via [`ProviderCatalogCache::get`], but they are not served as
/// current live data.
#[must_use]
pub fn is_fresh(&self, now_unix: u64) -> bool {
!self.is_stale(now_unix) && !matches!(self.status, CatalogStatus::Failed { .. })
}
}
/// A secret-free store of cached provider catalogs, keyed by provider + base-URL
/// fingerprint.
///
/// Scoping rule (#3385): the SAME provider on DIFFERENT base URLs must not share
/// rows, and DIFFERENT providers on the same base URL must not share rows.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ProviderCatalogCache {
/// Entries keyed by [`ProviderCatalogCache::cache_key`].
#[serde(default)]
pub entries: BTreeMap<String, CachedProviderCatalog>,
}
impl ProviderCatalogCache {
/// Construct an empty cache.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Compute the composite cache key for a provider + base-URL fingerprint.
#[must_use]
pub fn cache_key(provider: &str, base_url_fingerprint: &str) -> String {
// Unit separator avoids ambiguity between provider and fingerprint.
format!("{}\u{1f}{}", provider.trim(), base_url_fingerprint.trim())
}
/// Look up a cached entry by provider + base-URL fingerprint.
#[must_use]
pub fn get(
&self,
provider: &str,
base_url_fingerprint: &str,
) -> Option<&CachedProviderCatalog> {
self.entries
.get(&Self::cache_key(provider, base_url_fingerprint))
}
/// Record a successful refresh, replacing any prior entry for this scope.
///
/// Offering sources are normalized to [`CatalogSource::Live`] with the
/// delta's fingerprint and `fetched_at`, so cached rows always carry honest
/// provenance regardless of how the delta was assembled.
pub fn record_success(&mut self, delta: ProviderCatalogDelta, ttl_secs: u64) {
let ProviderCatalogDelta {
provider,
base_url_fingerprint,
fetched_at,
offerings,
} = delta;
let offerings = offerings
.into_iter()
.map(|mut row| {
row.source = CatalogSource::Live {
base_url_fingerprint: base_url_fingerprint.clone(),
fetched_at,
};
row
})
.collect();
let key = Self::cache_key(&provider, &base_url_fingerprint);
self.entries.insert(
key,
CachedProviderCatalog {
provider,
base_url_fingerprint,
fetched_at,
ttl_secs,
offerings,
status: CatalogStatus::Fresh,
},
);
}
/// Record a refresh failure.
///
/// Previously cached rows for this scope are preserved (so the UI can still
/// offer them with a visible "stale/failed" status); only the status is
/// updated. When no prior entry exists, an empty `Failed` entry is created so
/// the failure is observable.
pub fn record_failure(
&mut self,
provider: &str,
base_url_fingerprint: &str,
reason: CatalogRefreshError,
) {
let key = Self::cache_key(provider, base_url_fingerprint);
match self.entries.get_mut(&key) {
Some(entry) => entry.status = CatalogStatus::Failed { reason },
None => {
self.entries.insert(
key,
CachedProviderCatalog {
provider: provider.trim().to_string(),
base_url_fingerprint: base_url_fingerprint.trim().to_string(),
fetched_at: 0,
ttl_secs: 0,
offerings: Vec::new(),
status: CatalogStatus::Failed { reason },
},
);
}
}
}
/// The resolved status of an entry at `now_unix`.
///
/// A `Fresh`-recorded entry that has since aged past its TTL reports
/// `Stale`; `Failed`/`Unknown` are returned as stored.
#[must_use]
pub fn status(
&self,
provider: &str,
base_url_fingerprint: &str,
now_unix: u64,
) -> CatalogStatus {
match self.get(provider, base_url_fingerprint) {
None => CatalogStatus::Unknown,
Some(entry) => match &entry.status {
CatalogStatus::Failed { reason } => CatalogStatus::Failed { reason: *reason },
CatalogStatus::Unknown => CatalogStatus::Unknown,
CatalogStatus::Fresh | CatalogStatus::Stale { .. } => {
if entry.is_stale(now_unix) {
CatalogStatus::Stale {
age_secs: entry.age_secs(now_unix),
}
} else {
CatalogStatus::Fresh
}
}
},
}
}
/// Fresh (within-TTL) live offerings for one provider + base URL at
/// `now_unix`. Stale or failed entries contribute nothing here; callers fall
/// back to bundled/configured rows and surface the status separately.
#[must_use]
pub fn fresh_offerings(
&self,
provider: &str,
base_url_fingerprint: &str,
now_unix: u64,
) -> Vec<CatalogOffering> {
match self.get(provider, base_url_fingerprint) {
Some(entry) if entry.is_fresh(now_unix) => entry.offerings.clone(),
_ => Vec::new(),
}
}
/// All fresh live offerings across every cached provider + base URL.
#[must_use]
pub fn all_fresh_offerings(&self, now_unix: u64) -> Vec<CatalogOffering> {
self.entries
.values()
.filter(|entry| entry.is_fresh(now_unix))
.flat_map(|entry| entry.offerings.clone())
.collect()
}
/// Live offerings that pickers may still show: fresh rows plus stale / prior
/// rows that survived a failed refresh (#4139).
///
/// Unlike [`Self::all_fresh_offerings`], this keeps past-TTL and
/// `Failed`-status entries as long as they still hold offering rows. Empty
/// entries contribute nothing; callers fall back to the bundled snapshot.
/// `now_unix` is accepted for API symmetry with the fresh helper (age chips
/// live above this layer).
#[must_use]
pub fn all_visible_offerings(&self, _now_unix: u64) -> Vec<CatalogOffering> {
self.entries
.values()
.filter(|entry| !entry.offerings.is_empty())
.flat_map(|entry| entry.offerings.clone())
.collect()
}
}
/// A compiled, layer-merged catalog snapshot.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CatalogSnapshot {
/// Merged offerings, de-duplicated by (provider, wire id), in stable order.
pub offerings: Vec<CatalogOffering>,
}
impl CatalogSnapshot {
/// Project routing offerings for `RouteResolver::from_offerings`.
#[must_use]
pub fn to_offerings(&self) -> Vec<ProviderModelOffering> {
self.offerings
.iter()
.map(CatalogOffering::to_offering)
.collect()
}
/// All offerings for one provider id.
#[must_use]
pub fn offerings_for_provider(&self, provider: &str) -> Vec<&CatalogOffering> {
self.offerings
.iter()
.filter(|row| row.provider == provider)
.collect()
}
}
/// Builds a [`CatalogSnapshot`] by merging layers in precedence order:
/// bundled < live < user overrides. Later layers override earlier rows that
/// share a (provider, wire id) identity.
#[derive(Debug, Clone, Default)]
pub struct CatalogCompiler {
bundled: Vec<CatalogOffering>,
live: Vec<CatalogOffering>,
overrides: Vec<CatalogOffering>,
}
impl CatalogCompiler {
/// Start an empty compiler.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Add bundled (lowest-precedence) rows.
#[must_use]
pub fn with_bundled(mut self, rows: Vec<CatalogOffering>) -> Self {
self.bundled.extend(rows);
self
}
/// Seed bundled rows from a parsed Models.dev catalog.
#[must_use]
pub fn with_models_dev(mut self, catalog: &ModelsDevCatalog) -> Self {
self.bundled
.extend(bundled_offerings_from_models_dev(catalog));
self
}
/// Add live (middle-precedence) rows.
#[must_use]
pub fn with_live(mut self, rows: Vec<CatalogOffering>) -> Self {
self.live.extend(rows);
self
}
/// Add user/custom override (highest-precedence) rows.
#[must_use]
pub fn with_overrides(mut self, rows: Vec<CatalogOffering>) -> Self {
self.overrides.extend(rows);
self
}
/// Merge all layers into a deterministic snapshot.
#[must_use]
pub fn compile(self) -> CatalogSnapshot {
let mut merged: BTreeMap<(String, String), CatalogOffering> = BTreeMap::new();
for row in self
.bundled
.into_iter()
.chain(self.live)
.chain(self.overrides)
{
merged.insert(row.merge_key(), row);
}
CatalogSnapshot {
offerings: merged.into_values().collect(),
}
}
}
/// Normalize a base URL and fingerprint it for cache scoping.
///
/// Normalization folds case in the scheme/host, trims trailing slashes, and
/// drops a default-port suffix, so cosmetically different spellings of the same
/// endpoint share a cache scope while genuinely different endpoints do not. The
/// fingerprint is a dependency-free FNV-1a hex digest; it is deterministic
/// within and across runs but is not a cryptographic hash (it identifies a
/// cache bucket, nothing security-sensitive).
#[must_use]
pub fn base_url_fingerprint(base_url: &str) -> String {
let normalized = normalize_base_url(base_url);
fnv1a_hex(normalized.as_bytes())
}
fn normalize_base_url(base_url: &str) -> String {
let trimmed = base_url.trim().trim_end_matches('/');
// Lowercase only the scheme://host authority; leave the path case-sensitive.
if let Some(idx) = trimmed.find("://") {
let (scheme, rest) = trimmed.split_at(idx);
let scheme = scheme.to_ascii_lowercase();
let rest = &rest[3..];
let (authority, path) = match rest.find('/') {
Some(p) => (&rest[..p], &rest[p..]),
None => (rest, ""),
};
let authority = authority.to_ascii_lowercase();
// Strip only the scheme's own default port, so a non-default pairing
// such as `http://host:443` stays distinct from `http://host`.
let default_port = match scheme.as_str() {
"https" => Some(":443"),
"http" => Some(":80"),
_ => None,
};
let authority = default_port
.and_then(|port| authority.strip_suffix(port))
.unwrap_or(&authority);
format!("{scheme}://{authority}{path}")
} else {
trimmed.to_ascii_lowercase()
}
}
fn fnv1a_hex(bytes: &[u8]) -> String {
const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
const PRIME: u64 = 0x0000_0100_0000_01b3;
let mut hash = OFFSET;
for &b in bytes {
hash ^= u64::from(b);
hash = hash.wrapping_mul(PRIME);
}
format!("{hash:016x}")
}
/// Current unix time in seconds, for callers assembling deltas / cache entries.
///
/// Pure cache logic takes `now_unix` explicitly so it stays deterministic in
/// tests; this helper is the one place that reads the wall clock.
#[must_use]
pub fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests;
+695
View File
@@ -0,0 +1,695 @@
//! Behavior tests for the Models.dev-backed catalog cache (#3385).
//!
//! Fixtures use synthetic ids for anti-hardcoding guards, plus the GLM-5.2 and
//! hosted-DeepSeek rows the issue explicitly asks to exercise. No full hosted
//! provider model list is copied here.
use super::*;
/// Zhipu canonical + Zhipu/Z.AI provider offerings, and a hosted DeepSeek row
/// served by an aggregator under a prefixed wire id with an explicit canonical
/// `base_model` join.
const FIXTURE: &str = r#"{
"models": {
"zhipuai/glm-5.2": {
"id": "zhipuai/glm-5.2",
"family": "glm",
"reasoning": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 }
}
},
"providers": {
"zhipuai": {
"id": "zhipuai",
"models": {
"glm-5.2": {
"id": "glm-5.2",
"family": "glm",
"default": true,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 },
"cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
},
"glm-voice": {
"id": "glm-voice",
"modalities": { "input": ["text"], "output": ["audio"] }
}
}
},
"together": {
"id": "together",
"models": {
"deepseek-ai/DeepSeek-V4-Pro": {
"id": "deepseek-ai/DeepSeek-V4-Pro",
"base_model": "deepseek-v4-pro",
"family": "deepseek",
"reasoning": false,
"modalities": { "input": ["text"], "output": ["text"] },
"cost": { "input": 0.9, "output": 0.9 }
}
}
}
}
}"#;
fn fixture() -> ModelsDevCatalog {
ModelsDevCatalog::parse_json(FIXTURE).expect("fixture parses")
}
fn find<'a>(rows: &'a [CatalogOffering], provider: &str, wire: &str) -> &'a CatalogOffering {
rows.iter()
.find(|r| r.provider == provider && r.wire_model_id == wire)
.unwrap_or_else(|| panic!("offering {provider}/{wire} not found"))
}
#[test]
fn hydrates_models_dev_offerings_preserving_offering_facts() {
let rows = bundled_offerings_from_models_dev(&fixture());
// glm-voice (audio output) is excluded; two chat offerings remain.
assert_eq!(rows.len(), 2, "audio-only rows are not chat offerings");
let glm = find(&rows, "zhipuai", "glm-5.2");
assert!(glm.default_for_provider);
assert_eq!(glm.family.as_deref(), Some("glm"));
assert_eq!(glm.reasoning, Some(true));
// Provider-scoped reasoning options are preserved, not collapsed.
assert_eq!(glm.reasoning_options.len(), 1);
assert_eq!(glm.limit.as_ref().and_then(|l| l.context), Some(1_000_000));
assert_eq!(glm.cost.as_ref().and_then(|c| c.cache_read), Some(0.26));
// Provider row carried no base_model link → no inferred canonical model.
assert_eq!(glm.canonical_model, None);
assert_eq!(glm.source, CatalogSource::Bundled);
}
#[test]
fn hosted_offering_keeps_prefixed_wire_id_and_explicit_canonical_join() {
let rows = bundled_offerings_from_models_dev(&fixture());
let hosted = find(&rows, "together", "deepseek-ai/DeepSeek-V4-Pro");
// The prefixed wire id is preserved verbatim under the serving provider.
assert_eq!(hosted.wire_model_id, "deepseek-ai/DeepSeek-V4-Pro");
assert_eq!(hosted.provider, "together");
// Canonical link comes only from the explicit base_model.
assert_eq!(hosted.canonical_model.as_deref(), Some("deepseek-v4-pro"));
assert_eq!(hosted.reasoning, Some(false));
}
#[test]
fn to_offering_projects_routing_identity_and_limits() {
let rows = bundled_offerings_from_models_dev(&fixture());
let glm = find(&rows, "zhipuai", "glm-5.2").to_offering();
assert_eq!(glm.provider.as_str(), "zhipuai");
assert_eq!(glm.wire_model_id.as_str(), "glm-5.2");
assert_eq!(glm.canonical_model, None);
assert_eq!(glm.endpoint_key, "chat");
assert_eq!(glm.limits.context_tokens, Some(1_000_000));
assert_eq!(glm.limits.output_tokens, Some(131_072));
}
#[test]
fn compiler_merges_layers_with_override_precedence() {
// Bundled default for synthetic provider "acme".
let bundled = vec![CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-1".into(),
endpoint_key: "chat".into(),
default_for_provider: true,
family: Some("synth".into()),
source: CatalogSource::Bundled,
..Default::default()
}];
// Live refresh adds a new row AND restates the bundled one with a cost.
let live = vec![
CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-1".into(),
endpoint_key: "chat".into(),
cost: Some(ModelsDevCost {
input: Some(2.0),
..Default::default()
}),
source: CatalogSource::Live {
base_url_fingerprint: "fp".into(),
fetched_at: 100,
},
..Default::default()
},
CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-2".into(),
endpoint_key: "chat".into(),
source: CatalogSource::Live {
base_url_fingerprint: "fp".into(),
fetched_at: 100,
},
..Default::default()
},
];
// User override pins a custom canonical model on synth-chat-1.
let overrides = vec![CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-1".into(),
canonical_model: Some("acme-canonical".into()),
endpoint_key: "chat".into(),
source: CatalogSource::UserOverride,
..Default::default()
}];
let snapshot = CatalogCompiler::new()
.with_bundled(bundled)
.with_live(live)
.with_overrides(overrides)
.compile();
// Two distinct (provider, wire) identities survive de-duplication.
assert_eq!(snapshot.offerings.len(), 2);
let one = find(&snapshot.offerings, "acme", "synth-chat-1");
// Highest-precedence layer (override) wins the identity collision.
assert_eq!(one.source, CatalogSource::UserOverride);
assert_eq!(one.canonical_model.as_deref(), Some("acme-canonical"));
let two = find(&snapshot.offerings, "acme", "synth-chat-2");
assert!(matches!(two.source, CatalogSource::Live { .. }));
}
#[test]
fn cache_scopes_by_provider_and_base_url_fingerprint() {
let fp_a = base_url_fingerprint("https://api.example.com/v1");
let fp_b = base_url_fingerprint("https://other.example.com/v1");
assert_ne!(fp_a, fp_b, "different hosts must not share a fingerprint");
let mut cache = ProviderCatalogCache::new();
let row = |id: &str| CatalogOffering {
provider: "acme".into(),
wire_model_id: id.into(),
endpoint_key: "chat".into(),
..Default::default()
};
// Same provider, two different base URLs.
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp_a.clone(),
fetched_at: 1_000,
offerings: vec![row("from-a")],
},
3_600,
);
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp_b.clone(),
fetched_at: 1_000,
offerings: vec![row("from-b")],
},
3_600,
);
// Different provider, SAME base URL as fp_a.
cache.record_success(
ProviderCatalogDelta {
provider: "beta".into(),
base_url_fingerprint: fp_a.clone(),
fetched_at: 1_000,
offerings: vec![row("from-beta")],
},
3_600,
);
let a = cache.fresh_offerings("acme", &fp_a, 1_100);
assert_eq!(a.len(), 1);
assert_eq!(a[0].wire_model_id, "from-a");
// Same provider, different base URL must not leak rows across.
let b = cache.fresh_offerings("acme", &fp_b, 1_100);
assert_eq!(b[0].wire_model_id, "from-b");
// Different provider on the same base URL must not share rows either.
let beta = cache.fresh_offerings("beta", &fp_a, 1_100);
assert_eq!(beta[0].wire_model_id, "from-beta");
assert_eq!(cache.entries.len(), 3);
}
#[test]
fn fingerprint_folds_cosmetic_base_url_differences() {
let canonical = base_url_fingerprint("https://API.Example.com/v1");
assert_eq!(
canonical,
base_url_fingerprint("https://api.example.com/v1/"),
"trailing slash + host case must not change the cache scope"
);
assert_eq!(
canonical,
base_url_fingerprint(" https://api.example.com:443/v1 "),
"default https port + surrounding whitespace must fold away"
);
// Path case is significant (providers can be case-sensitive on the path).
assert_ne!(
canonical,
base_url_fingerprint("https://api.example.com/V1")
);
// Port stripping is scheme-aware: :80 is http's default (folds away), but
// :443 on http is a non-default port and must stay distinct from bare http.
assert_eq!(
base_url_fingerprint("http://h.example.com:80/v1"),
base_url_fingerprint("http://h.example.com/v1"),
"http default port :80 must fold away"
);
assert_ne!(
base_url_fingerprint("http://h.example.com:443/v1"),
base_url_fingerprint("http://h.example.com/v1"),
":443 is not http's default port and must not fold"
);
}
#[test]
fn ttl_marks_entries_stale_and_excludes_them_from_fresh() {
let fp = base_url_fingerprint("https://api.example.com");
let mut cache = ProviderCatalogCache::new();
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 1_000,
offerings: vec![CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-1".into(),
endpoint_key: "chat".into(),
..Default::default()
}],
},
100, // ttl
);
// Within TTL: fresh.
assert_eq!(cache.status("acme", &fp, 1_050), CatalogStatus::Fresh);
assert_eq!(cache.fresh_offerings("acme", &fp, 1_050).len(), 1);
// Past TTL: stale, and excluded from fresh offerings.
match cache.status("acme", &fp, 1_200) {
CatalogStatus::Stale { age_secs } => assert_eq!(age_secs, 200),
other => panic!("expected stale, got {other:?}"),
}
assert!(cache.fresh_offerings("acme", &fp, 1_200).is_empty());
// But the rows are still present in the cache for explicit fallback display.
assert_eq!(cache.get("acme", &fp).unwrap().offerings.len(), 1);
}
#[test]
fn ttl_zero_is_always_stale() {
let fp = base_url_fingerprint("https://api.example.com");
let mut cache = ProviderCatalogCache::new();
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 1_000,
offerings: vec![],
},
0,
);
assert!(cache.get("acme", &fp).unwrap().is_stale(1_000));
}
#[test]
fn unknown_scope_reports_unknown_status() {
let cache = ProviderCatalogCache::new();
let fp = base_url_fingerprint("https://api.example.com");
assert_eq!(cache.status("acme", &fp, 1_000), CatalogStatus::Unknown);
assert!(cache.fresh_offerings("acme", &fp, 1_000).is_empty());
}
#[test]
fn refresh_failure_preserves_prior_rows_and_marks_failed() {
let fp = base_url_fingerprint("https://api.example.com");
let mut cache = ProviderCatalogCache::new();
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 1_000,
offerings: vec![CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-1".into(),
endpoint_key: "chat".into(),
..Default::default()
}],
},
3_600,
);
for reason in [
CatalogRefreshError::Unauthorized,
CatalogRefreshError::Forbidden,
CatalogRefreshError::NotFound,
CatalogRefreshError::RateLimited,
CatalogRefreshError::InvalidResponse,
CatalogRefreshError::EmptyList,
CatalogRefreshError::Network,
] {
cache.record_failure("acme", &fp, reason);
let entry = cache.get("acme", &fp).expect("entry survives failure");
// Prior successful rows remain available after a failed refresh.
assert_eq!(entry.offerings.len(), 1, "{reason:?} dropped prior rows");
assert_eq!(entry.status, CatalogStatus::Failed { reason });
// fetched_at is NOT bumped by a failure.
assert_eq!(entry.fetched_at, 1_000);
// ...but a Failed entry must NOT contribute to fresh offerings even
// while still within its TTL window (now=1_100, ttl=3_600). The rows
// are reachable only via get() for explicit fallback display.
assert!(
cache.fresh_offerings("acme", &fp, 1_100).is_empty(),
"{reason:?}: failed entry served fresh offerings within TTL"
);
assert!(cache.all_fresh_offerings(1_100).is_empty());
assert_eq!(
cache.status("acme", &fp, 1_100),
CatalogStatus::Failed { reason }
);
}
}
#[test]
fn failure_without_prior_creates_observable_empty_entry() {
let fp = base_url_fingerprint("https://api.example.com");
let mut cache = ProviderCatalogCache::new();
cache.record_failure("acme", &fp, CatalogRefreshError::Unauthorized);
let entry = cache.get("acme", &fp).expect("failure is observable");
assert!(entry.offerings.is_empty());
assert_eq!(
entry.status,
CatalogStatus::Failed {
reason: CatalogRefreshError::Unauthorized
}
);
}
#[test]
fn record_success_stamps_live_provenance_on_rows() {
let fp = base_url_fingerprint("https://api.example.com");
let mut cache = ProviderCatalogCache::new();
// Row arrives mislabeled as Bundled; ingest must normalize provenance.
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 4_242,
offerings: vec![CatalogOffering {
provider: "acme".into(),
wire_model_id: "synth-chat-1".into(),
endpoint_key: "chat".into(),
source: CatalogSource::Bundled,
..Default::default()
}],
},
3_600,
);
let entry = cache.get("acme", &fp).unwrap();
assert_eq!(
entry.offerings[0].source,
CatalogSource::Live {
base_url_fingerprint: fp,
fetched_at: 4_242,
}
);
}
#[test]
fn cache_serialization_round_trips_and_contains_no_secrets() {
let fp = base_url_fingerprint("https://api.example.com/v1");
let mut cache = ProviderCatalogCache::new();
cache.record_success(
ProviderCatalogDelta {
provider: "zhipuai".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 1_700,
offerings: bundled_offerings_from_models_dev(&fixture()),
},
3_600,
);
let json = serde_json::to_string_pretty(&cache).expect("cache serializes");
let round: ProviderCatalogCache = serde_json::from_str(&json).expect("cache round-trips");
assert_eq!(round, cache);
// The persisted shape carries model facts but has no field that could hold
// a credential. Guard against a future field reintroducing one.
let lower = json.to_lowercase();
for needle in [
"api_key",
"apikey",
"api-key",
"authorization",
"secret",
"password",
"bearer",
"access_token",
] {
assert!(
!lower.contains(needle),
"cache JSON unexpectedly contains `{needle}`"
);
}
// Sanity: it did serialize meaningful provider/model facts.
assert!(json.contains("glm-5.2"));
assert!(json.contains("base_url_fingerprint"));
}
#[test]
fn all_fresh_offerings_spans_providers_and_skips_stale() {
let fp = base_url_fingerprint("https://api.example.com");
let mut cache = ProviderCatalogCache::new();
cache.record_success(
ProviderCatalogDelta {
provider: "acme".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 1_000,
offerings: vec![CatalogOffering {
provider: "acme".into(),
wire_model_id: "fresh-row".into(),
endpoint_key: "chat".into(),
..Default::default()
}],
},
3_600,
);
cache.record_success(
ProviderCatalogDelta {
provider: "beta".into(),
base_url_fingerprint: fp.clone(),
fetched_at: 0,
offerings: vec![CatalogOffering {
provider: "beta".into(),
wire_model_id: "stale-row".into(),
endpoint_key: "chat".into(),
..Default::default()
}],
},
10, // tiny ttl → stale at now=1_100
);
let fresh = cache.all_fresh_offerings(1_100);
assert_eq!(fresh.len(), 1);
assert_eq!(fresh[0].wire_model_id, "fresh-row");
// #4139: pickers still see stale rows; only the fresh helper drops them.
let visible = cache.all_visible_offerings(1_100);
assert_eq!(visible.len(), 2);
assert!(visible.iter().any(|row| row.wire_model_id == "fresh-row"));
assert!(visible.iter().any(|row| row.wire_model_id == "stale-row"));
}
#[test]
fn snapshot_feeds_route_resolver_offerings() {
// The compiled snapshot projects into the exact type RouteResolver consumes,
// proving catalog rows reach routing only through the offering seam.
let snapshot = CatalogCompiler::new().with_models_dev(&fixture()).compile();
let offerings = snapshot.to_offerings();
let glm = offerings
.iter()
.find(|o| o.provider.as_str() == "zhipuai" && o.wire_model_id.as_str() == "glm-5.2")
.expect("GLM offering reaches the route resolver seam");
assert_eq!(glm.limits.context_tokens, Some(1_000_000));
assert_eq!(glm.limits.output_tokens, Some(131_072));
// Audio-only row never becomes a routing offering.
assert!(
!offerings
.iter()
.any(|o| o.wire_model_id.as_str() == "glm-voice")
);
}
// ---------------------------------------------------------------------------
// #3385 / #4188: the committed offline/stale bundled Models.dev asset.
// ---------------------------------------------------------------------------
#[test]
fn bundled_asset_parses() {
// The committed asset must `include_str!`-load and deserialize into the
// parser's `ModelsDevCatalog` shape. This is the build-time guard that keeps
// `bundled_models_dev_catalog()` panic-free in shipped builds.
let catalog = ModelsDevCatalog::parse_json(BUNDLED_MODELS_DEV_JSON)
.expect("committed bundled asset must be valid Models.dev JSON");
assert!(
!catalog.providers.is_empty(),
"bundled asset must carry provider rows"
);
// The helper returns the same parsed catalog.
assert_eq!(bundled_models_dev_catalog(), catalog);
}
#[test]
fn bundled_asset_meta_describes_offline_fallback_not_competing_truth() {
// #4188: the asset must document itself as offline/stale fallback, not a
// competing curated source of truth alongside live Models.dev.
let raw: serde_json::Value =
serde_json::from_str(BUNDLED_MODELS_DEV_JSON).expect("bundled JSON");
let meta = raw
.get("_meta")
.and_then(|m| m.as_object())
.expect("_meta object");
let role = meta
.get("role")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert!(
role.to_ascii_lowercase().contains("not a competing"),
"_meta.role must demote the bundled asset: {role}"
);
assert!(
role.to_ascii_lowercase().contains("live"),
"_meta.role must point at live Models.dev preference: {role}"
);
}
#[test]
fn bundled_asset_yields_real_chat_offerings_for_key_models() {
let rows = bundled_catalog_offerings();
assert!(
rows.len() >= 20,
"expected dozens of bundled chat offerings, got {}",
rows.len()
);
// A GLM and a Kimi row carry their real (non-default) context windows,
// proving real facts flow rather than `RouteLimits::default()` (unknown).
let glm = find(&rows, "zai", "GLM-5.2");
assert_eq!(glm.limit.as_ref().and_then(|l| l.context), Some(1_000_000));
assert!(glm.default_for_provider);
let kimi = find(&rows, "moonshot", "kimi-k2.7-code");
assert_eq!(kimi.limit.as_ref().and_then(|l| l.context), Some(262_144));
// Audio/TTS rows are absent (the asset only ships chat models, but assert
// the filter contract anyway).
assert!(
rows.iter().all(|r| !r.wire_model_id.contains("tts")),
"no TTS rows should reach the offering layer"
);
}
#[test]
fn bundled_asset_pricing_is_honest() {
let rows = bundled_catalog_offerings();
// DeepSeek-native rows are intentionally unpriced here (priced via the
// time-aware DeepSeek table elsewhere); pricing them would also break the
// route layer's `unpriced_offering_stays_unknown` invariant.
let deepseek = find(&rows, "deepseek", "deepseek-v4-pro");
assert!(
deepseek.cost.is_none(),
"DeepSeek-native rows must stay unpriced in the bundled asset"
);
// Any row that *does* carry a cost must expose a usable input/output rate
// (the honesty rule: no cache-only / empty cost objects that would render as
// a rate-less Token at the route layer).
for row in &rows {
if let Some(cost) = row.cost.as_ref() {
assert!(
cost.input.is_some() || cost.output.is_some(),
"{}/{}: priced row must have an input or output rate",
row.provider,
row.wire_model_id
);
}
}
// A sampled priced row matches the in-repo USD table (crates/tui pricing):
// GLM-5.1 at the 2026-07-09 Z.ai published rates.
let glm51 = find(&rows, "zai", "glm-5.1");
let cost = glm51.cost.as_ref().expect("glm-5.1 is priced");
assert_eq!(cost.input, Some(1.40));
assert_eq!(cost.output, Some(4.40));
assert_eq!(cost.cache_read, Some(0.26));
}
#[test]
fn live_offerings_normalize_models_dev_provider_aliases() {
// Live Models.dev ids that must map onto CodeWhale kinds (#4186/#4187).
let raw = r#"{
"models": {},
"providers": {
"moonshotai": {
"id": "moonshotai",
"models": {
"kimi-k2.5": {
"id": "kimi-k2.5",
"modalities": { "input": ["text"], "output": ["text"] }
}
}
},
"togetherai": {
"id": "togetherai",
"models": {
"deepseek-ai/DeepSeek-V4-Pro": {
"id": "deepseek-ai/DeepSeek-V4-Pro",
"modalities": { "input": ["text"], "output": ["text"] }
}
}
},
"zhipuai": {
"id": "zhipuai",
"models": {
"glm-5.2": {
"id": "glm-5.2",
"modalities": { "input": ["text"], "output": ["text"] }
}
}
},
"brand-new-gateway": {
"id": "brand-new-gateway",
"models": {
"x-1": {
"id": "x-1",
"modalities": { "input": ["text"], "output": ["text"] }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let rows = live_offerings_from_models_dev(&catalog, "fp-models-dev", 1_700);
assert_eq!(
find(&rows, "moonshot", "kimi-k2.5").source,
CatalogSource::Live {
base_url_fingerprint: "fp-models-dev".into(),
fetched_at: 1_700,
}
);
find(&rows, "together", "deepseek-ai/DeepSeek-V4-Pro");
find(&rows, "zai", "glm-5.2");
// Unknown upstream providers keep their Models.dev id.
find(&rows, "brand-new-gateway", "x-1");
assert!(rows.iter().all(|r| r.provider != "moonshotai"));
assert!(rows.iter().all(|r| r.provider != "togetherai"));
assert!(rows.iter().all(|r| r.provider != "zhipuai"));
}
+247
View File
@@ -0,0 +1,247 @@
//! Harness posture + profile config types (#3311).
//!
//! A *harness posture* is the agent-shaping policy (sub-agent cap, tool
//! surface, compaction/cache strategy, safety stance); a *harness profile*
//! binds a posture to a provider route + model pattern. Extracted verbatim
//! from lib.rs to separate this agent-posture domain from the rest of the
//! config schema; re-exported at the crate root so existing paths are
//! unchanged. Behavior is identical.
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::ProviderKind;
/// Kinds of built-in harness postures.
///
/// A posture names the runtime strategy CodeWhale should use for a
/// provider/model route: how much context to preload, how aggressively to lean
/// on sub-agents, and how to balance prompt-cache stability against quick
/// exploration. Runtime selection is wired in later v0.9 slices; this config
/// model intentionally keeps the policy data explicit first.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessPostureKind {
/// Full-featured default: rich constitution, broad tool catalog, and normal
/// sub-agent posture.
#[default]
Standard,
/// Cache-heavy: deeper prompt layering and prefix-cache-oriented context.
CacheHeavy,
/// Lean: smaller starting context, faster compaction, and stronger
/// exploration/delegation bias.
Lean,
/// User-defined posture assembled from explicit knobs below.
Custom,
}
/// How this posture should approach compaction and prompt-cache stability.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessCompactionStrategy {
#[default]
Default,
PrefixCache,
Aggressive,
}
/// Which tool catalog shape this posture prefers.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessToolSurface {
#[default]
Full,
ReadOnly,
Auto,
}
/// Safety posture applied when the runtime consumes a harness profile.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum HarnessSafetyPosture {
#[default]
Standard,
Strict,
Permissive,
}
/// A concrete harness posture with policy knobs.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HarnessPosture {
/// Named posture kind.
#[serde(default)]
pub kind: HarnessPostureKind,
/// Maximum number of concurrent sub-agents (0 = runtime default).
#[serde(default)]
pub max_subagents: usize,
/// Prefer search-based/on-demand context over always-on documentation.
#[serde(default)]
pub prefer_codebase_search: bool,
/// Compaction and prompt-cache strategy.
#[serde(default)]
pub compaction_strategy: HarnessCompactionStrategy,
/// Preferred tool catalog shape.
#[serde(default)]
pub tool_surface: HarnessToolSurface,
/// Safety posture for runtime consumers.
#[serde(default)]
pub safety_posture: HarnessSafetyPosture,
}
impl Default for HarnessPosture {
fn default() -> Self {
Self {
kind: HarnessPostureKind::Standard,
max_subagents: 0,
prefer_codebase_search: false,
compaction_strategy: HarnessCompactionStrategy::default(),
tool_surface: HarnessToolSurface::default(),
safety_posture: HarnessSafetyPosture::default(),
}
}
}
impl HarnessPosture {
/// A cache-heavy posture tuned for DeepSeek V4 / MiMo-style models.
#[must_use]
pub fn cache_heavy() -> Self {
Self {
kind: HarnessPostureKind::CacheHeavy,
max_subagents: 10,
prefer_codebase_search: false,
compaction_strategy: HarnessCompactionStrategy::PrefixCache,
tool_surface: HarnessToolSurface::Full,
safety_posture: HarnessSafetyPosture::Standard,
}
}
/// A lean posture for smaller-context or weaker tool-use models.
#[must_use]
pub fn lean() -> Self {
Self {
kind: HarnessPostureKind::Lean,
max_subagents: 20,
prefer_codebase_search: true,
compaction_strategy: HarnessCompactionStrategy::Aggressive,
tool_surface: HarnessToolSurface::Full,
safety_posture: HarnessSafetyPosture::Standard,
}
}
}
/// A harness profile binds a posture to a provider route and model pattern.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct HarnessProfile {
/// Provider route this profile applies to, e.g. "deepseek" or
/// "xiaomi-mimo".
pub provider_route: String,
/// Regex or glob pattern for model names, e.g. "deepseek-v4.*".
pub model_pattern: String,
/// The posture to apply.
#[serde(default)]
pub posture: HarnessPosture,
}
impl HarnessProfile {
/// Return true when this profile applies to the provider/model route.
///
/// This is a pure config helper: matching a profile must not mutate runtime
/// provider selection, prompts, auth, tools, context, or persisted config.
#[must_use]
pub fn matches_route(&self, provider_route: &str, model: &str) -> bool {
provider_routes_equal(&self.provider_route, provider_route)
&& wildcard_pattern_matches(&self.model_pattern, model)
}
}
/// Built-in profile seeds for common provider/model families.
///
/// User-configured profiles are always checked first; these seeds only provide
/// a stable resolver result when config has no narrower match.
#[must_use]
pub fn built_in_harness_profiles() -> &'static [HarnessProfile] {
static PROFILES: OnceLock<Vec<HarnessProfile>> = OnceLock::new();
PROFILES.get_or_init(|| {
vec![
HarnessProfile {
provider_route: "deepseek".to_string(),
model_pattern: "deepseek-v4*".to_string(),
posture: HarnessPosture::cache_heavy(),
},
HarnessProfile {
provider_route: "xiaomi-mimo".to_string(),
model_pattern: "mimo-v2.5*".to_string(),
posture: HarnessPosture::cache_heavy(),
},
HarnessProfile {
provider_route: "arcee".to_string(),
model_pattern: "trinity-large-thinking".to_string(),
posture: HarnessPosture::cache_heavy(),
},
HarnessProfile {
provider_route: "huggingface".to_string(),
model_pattern: "*".to_string(),
posture: HarnessPosture::lean(),
},
HarnessProfile {
provider_route: "sglang".to_string(),
model_pattern: "*".to_string(),
posture: HarnessPosture::lean(),
},
HarnessProfile {
provider_route: "vllm".to_string(),
model_pattern: "*".to_string(),
posture: HarnessPosture::lean(),
},
HarnessProfile {
provider_route: "ollama".to_string(),
model_pattern: "*".to_string(),
posture: HarnessPosture::lean(),
},
]
})
}
fn provider_routes_equal(expected: &str, actual: &str) -> bool {
match (ProviderKind::parse(expected), ProviderKind::parse(actual)) {
(Some(expected), Some(actual)) => expected == actual,
_ => expected.trim().eq_ignore_ascii_case(actual.trim()),
}
}
fn wildcard_pattern_matches(pattern: &str, value: &str) -> bool {
wildcard_chars_match(
&pattern.chars().collect::<Vec<_>>(),
&value.chars().collect::<Vec<_>>(),
)
}
fn wildcard_chars_match(pattern: &[char], value: &[char]) -> bool {
let (mut pattern_idx, mut value_idx) = (0, 0);
let mut star_idx: Option<usize> = None;
let mut star_value_idx = 0;
while value_idx < value.len() {
if pattern_idx < pattern.len()
&& (pattern[pattern_idx] == '?' || pattern[pattern_idx] == value[value_idx])
{
pattern_idx += 1;
value_idx += 1;
} else if pattern_idx < pattern.len() && pattern[pattern_idx] == '*' {
star_idx = Some(pattern_idx);
pattern_idx += 1;
star_value_idx = value_idx;
} else if let Some(star) = star_idx {
pattern_idx = star + 1;
star_value_idx += 1;
value_idx = star_value_idx;
} else {
return false;
}
}
pattern[pattern_idx..].iter().all(|ch| *ch == '*')
}
File diff suppressed because it is too large Load Diff
+535
View File
@@ -0,0 +1,535 @@
//! Factual model reference database (#3205, #2300).
//!
//! A browsable, read-only projection of the compiled catalog into per-offering
//! "fact cards": the model id as-is, the serving provider and its kind, the
//! context window, the price, and the modality (text vs multimodal). It exists
//! to answer "what are this model's stated attributes?", nothing more.
//!
//! This layer is **labels only**. It performs no selection, routing, tiering,
//! or ranking — it never decides which model to use, and it carries no
//! `strong`/`balanced`/`fast` or role concept. It is a superset-free view over
//! [`crate::catalog::CatalogOffering`] rows.
//!
//! Honesty rule (shared with #2608 / #3085): an attribute the catalog layer did
//! not state is reported as **unknown**, never guessed. A local/custom endpoint
//! with no catalog facts yields `Unknown` modality, `None` context window, and
//! an unknown price — its model id is still preserved verbatim. Nothing here is
//! inferred from a model-id prefix.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::ProviderKind;
use crate::catalog::{CatalogOffering, CatalogSnapshot, CatalogSource, bundled_catalog_offerings};
use crate::models_dev::ModelsDevModalities;
use crate::pricing::{Currency, OfferingPricing};
/// Coarse, factual input/output modality label for a model.
///
/// `text` vs `multimodal` is derived from the union of stated input/output
/// modalities. Absent modality metadata is [`Modality::Unknown`], distinct from
/// a stated text-only model — "we were not told" is not "text only".
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum Modality {
/// Every stated modality is text.
Text,
/// At least one stated modality is non-text (image/audio/video/…).
Multimodal,
/// No modality metadata was stated for this row.
#[default]
Unknown,
}
impl Modality {
/// Classify the modality from a Models.dev-shaped modality block.
///
/// Returns [`Modality::Unknown`] for absent metadata or an empty list,
/// [`Modality::Multimodal`] when any stated input/output modality is not
/// `text`, and [`Modality::Text`] when the only stated modalities are text.
#[must_use]
pub fn from_modalities(modalities: Option<&ModelsDevModalities>) -> Self {
let Some(modalities) = modalities else {
return Self::Unknown;
};
let mut saw_any = false;
for modality in modalities.input.iter().chain(modalities.output.iter()) {
let trimmed = modality.trim();
if trimmed.is_empty() {
continue;
}
saw_any = true;
if !trimmed.eq_ignore_ascii_case("text") {
return Self::Multimodal;
}
}
if saw_any { Self::Text } else { Self::Unknown }
}
/// Stable lowercase label.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Text => "text",
Self::Multimodal => "multimodal",
Self::Unknown => "unknown",
}
}
}
/// A factual reference card for one provider offering.
///
/// Every field is either a stated fact or an explicit unknown. This is a
/// labels-only projection: it carries no routing, tier, or selection concept.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelReferenceCard {
/// Provider id serving this offering, exactly as the catalog row states it.
pub provider: String,
/// Resolved built-in provider kind, when the provider id maps to one.
///
/// `None` for an unrecognized / user-named custom provider — an unknown
/// kind, not a guess.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_kind: Option<ProviderKind>,
/// The provider wire model id, verbatim. Never normalized or prefixed.
pub model_id: String,
/// Canonical model identity, only when the row carried an explicit join.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_model: Option<String>,
/// Model family / series, when stated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
/// Context-window tokens, when stated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_window: Option<u64>,
/// Max-output tokens, when stated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_output: Option<u64>,
/// Text vs multimodal, or unknown.
pub modality: Modality,
/// Per-token pricing facts, when priced. `None` is unknown, never free.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<OfferingPricing>,
/// Provenance of the underlying catalog row (bundled / live / override).
pub source: CatalogSource,
}
impl ModelReferenceCard {
/// Project a catalog offering into its factual reference card.
#[must_use]
pub fn from_offering(offering: &CatalogOffering) -> Self {
Self {
provider: offering.provider.clone(),
provider_kind: ProviderKind::parse(&offering.provider),
model_id: offering.wire_model_id.clone(),
canonical_model: offering.canonical_model.clone(),
family: offering.family.clone(),
context_window: offering.limit.as_ref().and_then(|limit| limit.context),
max_output: offering.limit.as_ref().and_then(|limit| limit.output),
modality: Modality::from_modalities(offering.modalities.as_ref()),
pricing: OfferingPricing::from_catalog_offering(offering),
source: offering.source.clone(),
}
}
/// Label for the resolved provider kind, or `"unknown"`.
#[must_use]
pub fn provider_kind_label(&self) -> &'static str {
self.provider_kind.map_or("unknown", ProviderKind::as_str)
}
/// Human context-window label such as `"1M"`, `"131K"`, `"512"`, or
/// `"unknown"`. The exact token count remains on [`Self::context_window`].
#[must_use]
pub fn context_window_label(&self) -> String {
humanize_tokens(self.context_window)
}
/// Human max-output label, same shape as [`Self::context_window_label`].
#[must_use]
pub fn max_output_label(&self) -> String {
humanize_tokens(self.max_output)
}
/// Short factual price label, e.g. `"$0.30 / $1.20 per Mtok"`, or
/// `"unknown"` when no per-token input/output rate is sourced.
///
/// A `?` in one slot means that single rate is unknown while the other is
/// stated; a fully unknown price collapses to `"unknown"` rather than a
/// fabricated zero.
#[must_use]
pub fn price_label(&self) -> String {
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() {
return "unknown".to_string();
}
let symbol = currency_symbol(&pricing.currency);
let render = |value: Option<f64>| match value {
Some(rate) => format!("{symbol}{rate:.2}"),
None => "?".to_string(),
};
let suffix = currency_suffix(&pricing.currency);
format!(
"{} / {} per Mtok{suffix}",
render(pricing.input_per_million),
render(pricing.output_per_million),
)
}
}
/// A browsable, read-only factual reference database of model offerings.
///
/// Cards are sorted by `(provider, model id)` and de-duplicated on that
/// identity, so the database is deterministic regardless of input order.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ModelReferenceDatabase {
cards: Vec<ModelReferenceCard>,
}
impl ModelReferenceDatabase {
/// Build from raw catalog offerings.
///
/// Rows are keyed by `(provider, model id)`; a later row with the same
/// identity replaces an earlier one, matching catalog merge semantics.
#[must_use]
pub fn from_offerings(offerings: &[CatalogOffering]) -> Self {
let mut by_identity: BTreeMap<(String, String), ModelReferenceCard> = BTreeMap::new();
for offering in offerings {
let card = ModelReferenceCard::from_offering(offering);
by_identity.insert((card.provider.clone(), card.model_id.clone()), card);
}
Self {
cards: by_identity.into_values().collect(),
}
}
/// Build from a compiled catalog snapshot (bundled < live < overrides).
#[must_use]
pub fn from_snapshot(snapshot: &CatalogSnapshot) -> Self {
Self::from_offerings(&snapshot.offerings)
}
/// Build from CodeWhale's offline/stale bundled catalog snapshot (#4188).
///
/// Prefer a live/compiled [`CatalogSnapshot`] when available. The bundled
/// set needs no credentials or network and remains the offline fallback
/// every install carries.
#[must_use]
pub fn bundled() -> Self {
Self::from_offerings(&bundled_catalog_offerings())
}
/// All cards, in stable `(provider, model id)` order.
#[must_use]
pub fn cards(&self) -> &[ModelReferenceCard] {
&self.cards
}
/// Number of cards.
#[must_use]
pub fn len(&self) -> usize {
self.cards.len()
}
/// Whether the database is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.cards.is_empty()
}
/// Distinct provider ids present, sorted.
#[must_use]
pub fn providers(&self) -> Vec<&str> {
self.cards
.iter()
.map(|card| card.provider.as_str())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
/// All cards served by one provider id.
#[must_use]
pub fn for_provider(&self, provider: &str) -> Vec<&ModelReferenceCard> {
self.cards
.iter()
.filter(|card| card.provider == provider)
.collect()
}
/// Find a card by `(provider, model id)`.
#[must_use]
pub fn find(&self, provider: &str, model_id: &str) -> Option<&ModelReferenceCard> {
self.cards
.iter()
.find(|card| card.provider == provider && card.model_id == model_id)
}
}
/// Round a token count to a short human label (`"1M"`, `"203K"`, `"512"`), or
/// `"unknown"` for an absent count. Used for display only; callers needing the
/// exact value read the `Option<u64>` field directly.
fn humanize_tokens(tokens: Option<u64>) -> String {
let Some(tokens) = tokens else {
return "unknown".to_string();
};
if tokens >= 1_000_000 {
let millions = tokens as f64 / 1_000_000.0;
let rendered = format!("{millions:.2}");
let trimmed = rendered.trim_end_matches('0').trim_end_matches('.');
format!("{trimmed}M")
} else if tokens >= 1_000 {
format!("{}K", (tokens as f64 / 1_000.0).round() as u64)
} else {
tokens.to_string()
}
}
fn currency_symbol(currency: &Currency) -> &'static str {
match currency {
Currency::Usd => "$",
Currency::Cny => "¥",
Currency::Other(_) => "",
}
}
fn currency_suffix(currency: &Currency) -> String {
match currency {
Currency::Usd | Currency::Cny => String::new(),
Currency::Other(code) => format!(" {code}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models_dev::{ModelsDevCost, ModelsDevLimit};
fn offering(provider: &str, wire: &str) -> CatalogOffering {
CatalogOffering {
provider: provider.to_string(),
wire_model_id: wire.to_string(),
endpoint_key: "chat".to_string(),
source: CatalogSource::Bundled,
..Default::default()
}
}
#[test]
fn modality_text_multimodal_and_unknown() {
assert_eq!(Modality::from_modalities(None), Modality::Unknown);
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities::default())),
Modality::Unknown,
"an empty modality block is unknown, not text-only"
);
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["text".to_string()],
})),
Modality::Text
);
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities {
input: vec!["text".to_string(), "image".to_string()],
output: vec!["text".to_string()],
})),
Modality::Multimodal
);
// Case-insensitive and tolerant of an output-only non-text modality.
assert_eq!(
Modality::from_modalities(Some(&ModelsDevModalities {
input: vec!["TEXT".to_string()],
output: vec!["Audio".to_string()],
})),
Modality::Multimodal
);
}
#[test]
fn card_projects_stated_facts() {
let row = CatalogOffering {
family: Some("deepseek".to_string()),
limit: Some(ModelsDevLimit {
context: Some(1_000_000),
input: None,
output: Some(384_000),
}),
cost: Some(ModelsDevCost {
input: Some(0.3),
output: Some(1.2),
cache_read: Some(0.06),
cache_write: None,
}),
modalities: Some(ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["text".to_string()],
}),
..offering("deepseek", "deepseek-v4-pro")
};
let card = ModelReferenceCard::from_offering(&row);
assert_eq!(card.provider, "deepseek");
assert_eq!(card.provider_kind, Some(ProviderKind::Deepseek));
assert_eq!(card.provider_kind_label(), "deepseek");
assert_eq!(card.model_id, "deepseek-v4-pro");
assert_eq!(card.family.as_deref(), Some("deepseek"));
assert_eq!(card.context_window, Some(1_000_000));
assert_eq!(card.context_window_label(), "1M");
assert_eq!(card.max_output, Some(384_000));
assert_eq!(card.max_output_label(), "384K");
assert_eq!(card.modality, Modality::Text);
assert_eq!(card.price_label(), "$0.30 / $1.20 per Mtok");
}
#[test]
fn custom_local_row_is_all_unknown_but_keeps_model_id_verbatim() {
// A user-named custom endpoint with no catalog facts: provider kind,
// context window, modality, and price are all unknown — never guessed —
// and the model id is preserved exactly.
let row = CatalogOffering {
source: CatalogSource::UserOverride,
..offering("my-local-llm", "Vendor/Custom-Model_v1")
};
let card = ModelReferenceCard::from_offering(&row);
assert_eq!(card.provider_kind, None);
assert_eq!(card.provider_kind_label(), "unknown");
assert_eq!(card.model_id, "Vendor/Custom-Model_v1");
assert_eq!(card.context_window, None);
assert_eq!(card.context_window_label(), "unknown");
assert_eq!(card.max_output_label(), "unknown");
assert_eq!(card.modality, Modality::Unknown);
assert_eq!(card.price_label(), "unknown");
}
#[test]
fn unpriced_and_cache_only_rows_report_unknown_price_never_zero() {
// No cost block at all.
let unpriced = ModelReferenceCard::from_offering(&offering("deepseek", "deepseek-v4-pro"));
assert_eq!(unpriced.price_label(), "unknown");
assert!(unpriced.pricing.is_none());
// A cost object priced only on cache classes is still unknown for the
// headline input/output rate label.
let cache_only = CatalogOffering {
cost: Some(ModelsDevCost {
input: None,
output: None,
cache_read: Some(0.05),
cache_write: None,
}),
..offering("acme", "house-model")
};
assert_eq!(
ModelReferenceCard::from_offering(&cache_only).price_label(),
"unknown"
);
}
#[test]
fn partial_price_renders_known_rate_and_marks_the_other_unknown() {
let row = CatalogOffering {
cost: Some(ModelsDevCost {
input: Some(5.0),
output: None,
cache_read: None,
cache_write: None,
}),
..offering("openai", "gpt-5.5")
};
assert_eq!(
ModelReferenceCard::from_offering(&row).price_label(),
"$5.00 / ? per Mtok"
);
}
#[test]
fn database_is_sorted_deduped_and_queryable() {
let rows = vec![
CatalogOffering {
limit: Some(ModelsDevLimit {
context: Some(1),
input: None,
output: None,
}),
..offering("zai", "GLM-5.2")
},
offering("deepseek", "deepseek-v4-pro"),
// Duplicate identity with a higher context wins (last-write).
CatalogOffering {
limit: Some(ModelsDevLimit {
context: Some(1_000_000),
input: None,
output: None,
}),
..offering("zai", "GLM-5.2")
},
];
let db = ModelReferenceDatabase::from_offerings(&rows);
assert_eq!(db.len(), 2, "duplicate (provider, model) collapses to one");
// Sorted by (provider, model id): deepseek before zai.
assert_eq!(db.cards()[0].provider, "deepseek");
assert_eq!(db.cards()[1].provider, "zai");
assert_eq!(db.providers(), vec!["deepseek", "zai"]);
assert_eq!(db.for_provider("zai").len(), 1);
assert_eq!(
db.find("zai", "GLM-5.2")
.and_then(|card| card.context_window),
Some(1_000_000),
"last-write-wins kept the richer row"
);
assert!(db.find("zai", "missing").is_none());
}
#[test]
fn bundled_database_is_nonempty_and_honest() {
let db = ModelReferenceDatabase::bundled();
assert!(!db.is_empty());
assert!(
db.len() >= 20,
"bundled offline snapshot should carry seed offerings, got {}",
db.len()
);
// Every card preserves a non-empty model id and resolves a known kind
// for the bundled (first-class) providers.
for card in db.cards() {
assert!(!card.model_id.is_empty());
assert!(
card.provider_kind.is_some(),
"bundled provider {} should map to a known kind",
card.provider
);
}
// A DeepSeek-native row: context window known, price honestly unknown
// (the bundled snapshot omits DeepSeek-native per-token pricing).
let deepseek = db
.find("deepseek", "deepseek-v4-pro")
.expect("bundled deepseek row");
assert_eq!(deepseek.context_window, Some(1_000_000));
assert_eq!(deepseek.modality, Modality::Text);
assert_eq!(deepseek.price_label(), "unknown");
// A priced row surfaces its stated per-token rate.
let minimax = db
.find("minimax", "MiniMax-M3")
.expect("bundled minimax row");
assert_eq!(minimax.price_label(), "$0.30 / $1.20 per Mtok");
}
#[test]
fn humanize_tokens_shapes() {
assert_eq!(humanize_tokens(None), "unknown");
assert_eq!(humanize_tokens(Some(512)), "512");
assert_eq!(humanize_tokens(Some(131_072)), "131K");
assert_eq!(humanize_tokens(Some(1_000_000)), "1M");
assert_eq!(humanize_tokens(Some(1_050_000)), "1.05M");
}
}
+814
View File
@@ -0,0 +1,814 @@
//! Models.dev catalog schema and helpers.
//!
//! Models.dev is the upstream taxonomy CodeWhale should use for model facts,
//! provider offerings, pricing, limits, and capabilities. This module is
//! intentionally network-free: callers provide JSON from a bundled snapshot,
//! live refresh, or tests. Runtime fetch/cache policy belongs above this layer.
//!
//! The important boundary is the same one Models.dev uses:
//! - `models` are provider-agnostic model facts.
//! - `providers.*.models` are provider-scoped wire offerings.
//!
//! A provider row may inline inherited facts without exposing a canonical
//! `base_model` link. CodeWhale must preserve that distinction instead of
//! inferring canonical ownership from wire IDs or namespace prefixes.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::route::{ModelId, ProviderId, ProviderModelOffering, RouteLimits, WireModelId};
/// Provider catalog endpoint used by Models.dev.
pub const MODELS_DEV_API_URL: &str = "https://models.dev/api.json";
/// Provider-agnostic model metadata endpoint used by Models.dev.
pub const MODELS_DEV_MODELS_URL: &str = "https://models.dev/models.json";
/// Combined `{ models, providers }` endpoint used by Models.dev.
pub const MODELS_DEV_CATALOG_URL: &str = "https://models.dev/catalog.json";
/// Combined Models.dev catalog payload.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevCatalog {
/// Provider-agnostic model facts, keyed by canonical model id.
#[serde(default)]
pub models: BTreeMap<String, ModelsDevModel>,
/// Provider-scoped catalogs, keyed by provider id.
#[serde(default)]
pub providers: BTreeMap<String, ModelsDevProvider>,
}
impl ModelsDevCatalog {
/// Parse a Models.dev combined catalog JSON payload.
///
/// # Errors
/// Returns a serde error when the input is not valid Models.dev JSON.
pub fn parse_json(raw: &str) -> serde_json::Result<Self> {
serde_json::from_str(raw)
}
/// Look up provider-agnostic model facts by canonical model id.
#[must_use]
pub fn model(&self, model_id: &str) -> Option<&ModelsDevModel> {
self.models.get(model_id.trim())
}
/// Look up a provider catalog by provider id.
#[must_use]
pub fn provider(&self, provider_id: &str) -> Option<&ModelsDevProvider> {
self.providers.get(provider_id.trim())
}
/// Look up a provider-scoped wire model row.
#[must_use]
pub fn provider_model(
&self,
provider_id: &str,
wire_model_id: &str,
) -> Option<&ModelsDevProviderModel> {
self.provider(provider_id)?.models.get(wire_model_id.trim())
}
/// Build a route offering from a provider-scoped Models.dev row.
///
/// The canonical model is set only when the row carries an explicit
/// `base_model` id. Generated Models.dev JSON often inlines inherited facts
/// without that link, so callers must not guess one from a prefix.
#[must_use]
pub fn provider_offering(
&self,
provider_id: &str,
wire_model_id: &str,
) -> Option<ProviderModelOffering> {
let provider_key = provider_id.trim();
let provider = self.provider(provider_key)?;
let model = provider.models.get(wire_model_id.trim())?;
let provider_id = provider.effective_id(provider_key);
Some(ProviderModelOffering {
provider: ProviderId::from(provider_id),
canonical_model: model.base_model.clone().map(ModelId::from),
wire_model_id: WireModelId::from(model.id.clone()),
endpoint_key: "chat".to_string(),
default_for_provider: model.default_for_provider,
limits: model
.limit
.as_ref()
.map(RouteLimits::from)
.unwrap_or_default(),
pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
})
}
/// Build route offerings for every normal text-chat model served by a
/// provider.
///
/// Non-chat rows (for example TTS/audio-only offerings) stay in the parsed
/// catalog but are excluded from route resolution lists.
#[must_use]
pub fn provider_offerings(&self, provider_id: &str) -> Option<Vec<ProviderModelOffering>> {
let provider_key = provider_id.trim();
let provider = self.provider(provider_key)?;
let provider_id = provider.effective_id(provider_key);
Some(
provider
.models
.values()
.filter(|model| model.supports_text_chat())
.map(|model| ProviderModelOffering {
provider: ProviderId::from(provider_id.clone()),
canonical_model: model.base_model.clone().map(ModelId::from),
wire_model_id: WireModelId::from(model.id.clone()),
endpoint_key: "chat".to_string(),
default_for_provider: model.default_for_provider,
limits: model
.limit
.as_ref()
.map(RouteLimits::from)
.unwrap_or_default(),
pricing: crate::pricing::route_pricing_sku_from_cost(model.cost.as_ref()),
})
.collect(),
)
}
}
/// Provider-agnostic model facts from `models.json` / `catalog.models`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevModel {
/// Canonical Models.dev model id, such as `zhipuai/glm-5.2`.
#[serde(default)]
pub id: String,
/// Human-friendly model name.
#[serde(default)]
pub name: Option<String>,
/// Model family, such as `glm`, `gpt`, or `claude`.
#[serde(default)]
pub family: Option<String>,
/// Whether attachments are accepted.
#[serde(default)]
pub attachment: Option<bool>,
/// Whether the model supports reasoning.
#[serde(default)]
pub reasoning: Option<bool>,
/// Whether tool calling is supported.
#[serde(default)]
pub tool_call: Option<bool>,
/// Whether structured output is supported.
#[serde(default)]
pub structured_output: Option<bool>,
/// Whether temperature is supported.
#[serde(default)]
pub temperature: Option<bool>,
/// Whether weights are open.
#[serde(default)]
pub open_weights: Option<bool>,
/// Token limits.
#[serde(default)]
pub limit: Option<ModelsDevLimit>,
/// Input/output modalities.
#[serde(default)]
pub modalities: Option<ModelsDevModalities>,
}
impl ModelsDevModel {
/// True when the model can be used for normal text chat.
#[must_use]
pub fn supports_text_chat(&self) -> bool {
supports_text_chat(self.modalities.as_ref())
}
}
/// Provider-scoped model row from `api.json` / `catalog.providers.*.models`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevProviderModel {
/// Provider wire model id.
#[serde(default)]
pub id: String,
/// Optional explicit canonical model link from source TOML.
#[serde(default)]
pub base_model: Option<String>,
/// Human-friendly model name.
#[serde(default)]
pub name: Option<String>,
/// Model family as exposed for this provider row.
#[serde(default)]
pub family: Option<String>,
/// Whether this is the provider's default model in a CodeWhale snapshot.
#[serde(default, alias = "default")]
pub default_for_provider: bool,
/// Whether attachments are accepted.
#[serde(default)]
pub attachment: Option<bool>,
/// Whether the model supports reasoning.
#[serde(default)]
pub reasoning: Option<bool>,
/// Flexible reasoning-control metadata.
#[serde(default)]
pub reasoning_options: Vec<serde_json::Value>,
/// Whether tool calling is supported.
#[serde(default)]
pub tool_call: Option<bool>,
/// Whether structured output is supported.
#[serde(default)]
pub structured_output: Option<bool>,
/// Whether temperature is supported.
#[serde(default)]
pub temperature: Option<bool>,
/// Whether weights are open through this offering.
#[serde(default)]
pub open_weights: Option<bool>,
/// Token limits for this provider offering.
#[serde(default)]
pub limit: Option<ModelsDevLimit>,
/// Input/output modalities for this provider offering.
#[serde(default)]
pub modalities: Option<ModelsDevModalities>,
/// Provider-scoped pricing.
#[serde(default)]
pub cost: Option<ModelsDevCost>,
/// Interleaved reasoning field hints.
#[serde(default)]
pub interleaved: Option<ModelsDevInterleaved>,
}
impl ModelsDevProviderModel {
/// True when the provider offering can be used for normal text chat.
#[must_use]
pub fn supports_text_chat(&self) -> bool {
supports_text_chat(self.modalities.as_ref())
}
}
/// Provider row from Models.dev.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevProvider {
/// Provider id, such as `zai`, `zhipuai`, or `openrouter`.
#[serde(default)]
pub id: String,
/// Human-friendly provider name.
#[serde(default)]
pub name: Option<String>,
/// Default API base URL, if published.
#[serde(default)]
pub api: Option<String>,
/// AI SDK package identifier, useful as a protocol hint.
#[serde(default)]
pub npm: Option<String>,
/// Documentation URL, if published.
#[serde(default)]
pub doc: Option<String>,
/// Environment variable names for credentials.
#[serde(default)]
pub env: Vec<String>,
/// Provider-scoped wire model rows.
#[serde(default)]
pub models: BTreeMap<String, ModelsDevProviderModel>,
}
impl ModelsDevProvider {
/// Resolve the effective provider id for this row.
///
/// Models.dev snapshots usually repeat the catalog key in the `id` field,
/// but generated JSON can omit it. Fall back to the catalog key so callers
/// never emit an empty [`ProviderId`].
#[must_use]
fn effective_id(&self, provider_key: &str) -> String {
if self.id.trim().is_empty() {
provider_key.to_string()
} else {
self.id.trim().to_string()
}
}
}
/// Token limits.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModelsDevLimit {
#[serde(default)]
pub context: Option<u64>,
#[serde(default)]
pub input: Option<u64>,
#[serde(default)]
pub output: Option<u64>,
}
impl From<&ModelsDevLimit> for RouteLimits {
fn from(limit: &ModelsDevLimit) -> Self {
Self {
context_tokens: limit.context,
input_tokens: limit.input,
output_tokens: limit.output,
}
}
}
/// Input/output modalities.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModelsDevModalities {
#[serde(default)]
pub input: Vec<String>,
#[serde(default)]
pub output: Vec<String>,
}
/// Provider-scoped cost fields. Values are per million tokens unless a future
/// Models.dev row specifies a richer tiering object in fields CodeWhale does
/// not yet model.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ModelsDevCost {
#[serde(default)]
pub input: Option<f64>,
#[serde(default)]
pub output: Option<f64>,
#[serde(default)]
pub cache_read: Option<f64>,
#[serde(default)]
pub cache_write: Option<f64>,
}
/// Interleaved reasoning metadata from a Models.dev provider row.
///
/// Live Models.dev uses two shapes for this field, verified against
/// `https://models.dev/catalog.json` on 2026-07-07:
///
/// - a bare boolean (`interleaved: true`) on ~32 provider rows, signalling the
/// provider supports interleaved reasoning without naming a wire field, and
/// - an object (`interleaved: { "field": "reasoning_content" }`) on the
/// majority of rows, naming the wire field that carries reasoning deltas.
///
/// Modeling only the object shape made `serde_json::from_str::<ModelsDevCatalog>`
/// reject every boolean row before the live catalog could be used at all
/// (#4185). This untagged enum accepts both shapes while preserving the `field`
/// hint whenever the object form supplies one.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ModelsDevInterleaved {
/// Boolean form: `interleaved: true` / `interleaved: false`.
Enabled(bool),
/// Object form: `interleaved: { "field": "reasoning_content" }`.
///
/// `field` stays optional so an empty or partial object still parses, and
/// unknown sibling keys are ignored rather than rejected.
Field {
#[serde(default)]
field: Option<String>,
},
}
impl ModelsDevInterleaved {
/// Whether interleaved reasoning is enabled for this row.
///
/// The boolean form reports its literal value. The object form is treated as
/// enabled because upstream only emits the object (naming a wire field) for
/// interleaved-capable rows.
#[must_use]
pub fn is_enabled(&self) -> bool {
match self {
Self::Enabled(enabled) => *enabled,
Self::Field { .. } => true,
}
}
/// The provider wire field carrying reasoning deltas, when upstream names
/// one.
///
/// Only the object form supplies this; the boolean form returns `None`.
#[must_use]
pub fn field(&self) -> Option<&str> {
match self {
Self::Enabled(_) => None,
Self::Field { field } => field.as_deref(),
}
}
}
fn supports_text_chat(modalities: Option<&ModelsDevModalities>) -> bool {
let Some(modalities) = modalities else {
return true;
};
// Treat an empty modality list the same as absent metadata. An incomplete
// catalog snapshot can deserialize to `Some({ input: [], output: [] })`,
// and `Iterator::any` over an empty slice is `false` — without this guard
// such rows would be silently dropped from chat offerings even though the
// `None` branch above defaults them to chat-capable. Only an explicitly
// populated, non-text list excludes the row.
let input_ok = modalities.input.is_empty()
|| modalities
.input
.iter()
.any(|modality| modality.eq_ignore_ascii_case("text"));
let output_ok = modalities.output.is_empty()
|| modalities
.output
.iter()
.any(|modality| modality.eq_ignore_ascii_case("text"));
input_ok && output_ok
}
#[cfg(test)]
mod tests {
use super::*;
const GLM_FIXTURE: &str = r#"{
"models": {
"zhipuai/glm-5.2": {
"id": "zhipuai/glm-5.2",
"name": "GLM-5.2",
"family": "glm",
"reasoning": true,
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 },
"open_weights": true
}
},
"providers": {
"zhipuai": {
"id": "zhipuai",
"name": "Zhipu AI",
"api": "https://open.bigmodel.cn/api/paas/v4",
"npm": "@ai-sdk/openai-compatible",
"env": ["ZHIPU_API_KEY"],
"models": {
"glm-5.2": {
"id": "glm-5.2",
"name": "GLM-5.2",
"family": "glm",
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 },
"cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
}
}
},
"zai": {
"id": "zai",
"name": "Z.AI",
"api": "https://api.z.ai/api/paas/v4",
"npm": "@ai-sdk/openai-compatible",
"env": ["ZHIPU_API_KEY"],
"models": {
"glm-5.2": {
"id": "glm-5.2",
"family": "glm",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"cost": { "input": 1.4, "output": 4.4 }
}
}
}
}
}"#;
#[test]
fn parses_models_dev_catalog_layers_without_joining_by_prefix() {
let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
let canonical = catalog.model("zhipuai/glm-5.2").expect("canonical model");
assert_eq!(canonical.family.as_deref(), Some("glm"));
assert_eq!(
canonical.limit.as_ref().and_then(|limit| limit.context),
Some(1_000_000)
);
assert!(canonical.supports_text_chat());
let provider = catalog.provider("zhipuai").expect("provider");
assert_eq!(
provider.api.as_deref(),
Some("https://open.bigmodel.cn/api/paas/v4")
);
assert_eq!(provider.npm.as_deref(), Some("@ai-sdk/openai-compatible"));
assert_eq!(provider.env, ["ZHIPU_API_KEY"]);
let offering = catalog
.provider_model("zhipuai", "glm-5.2")
.expect("provider model");
assert_eq!(offering.id, "glm-5.2");
assert_eq!(offering.reasoning, Some(true));
assert_eq!(
offering.cost.as_ref().and_then(|cost| cost.cache_read),
Some(0.26)
);
assert!(offering.supports_text_chat());
assert_eq!(
offering.base_model, None,
"generated JSON does not prove a canonical join"
);
let route_offering = catalog
.provider_offering("zhipuai", "glm-5.2")
.expect("route offering");
assert_eq!(route_offering.limits.context_tokens, Some(1_000_000));
assert_eq!(route_offering.limits.output_tokens, Some(131_072));
}
#[test]
fn provider_offering_preserves_wire_id_without_inferred_canonical_model() {
let catalog = ModelsDevCatalog::parse_json(GLM_FIXTURE).expect("fixture parses");
let offering = catalog
.provider_offering("zai", "glm-5.2")
.expect("offering");
assert_eq!(offering.provider.as_str(), "zai");
assert_eq!(offering.wire_model_id.as_str(), "glm-5.2");
assert_eq!(offering.canonical_model, None);
assert_eq!(offering.endpoint_key, "chat");
}
#[test]
fn provider_offering_uses_explicit_base_model_when_present() {
let raw = r#"{
"providers": {
"openrouter": {
"id": "openrouter",
"models": {
"z-ai/glm-5.2": {
"id": "z-ai/glm-5.2",
"base_model": "zhipuai/glm-5.2"
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let offering = catalog
.provider_offering("openrouter", "z-ai/glm-5.2")
.expect("offering");
assert_eq!(
offering.canonical_model.as_ref().map(ModelId::as_str),
Some("zhipuai/glm-5.2")
);
assert_eq!(offering.wire_model_id.as_str(), "z-ai/glm-5.2");
}
#[test]
fn provider_offerings_emit_chat_rows_and_skip_non_text_outputs() {
let raw = r#"{
"providers": {
"zai": {
"models": {
"glm-5.2": {
"id": "glm-5.2",
"base_model": "zhipuai/glm-5.2",
"default": true,
"modalities": { "input": ["text"], "output": ["text"] }
},
"glm-voice": {
"id": "glm-voice",
"modalities": { "input": ["text"], "output": ["audio"] }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let offerings = catalog
.provider_offerings("zai")
.expect("provider offerings");
assert_eq!(offerings.len(), 1);
assert_eq!(offerings[0].provider.as_str(), "zai");
assert_eq!(offerings[0].wire_model_id.as_str(), "glm-5.2");
assert_eq!(
offerings[0].canonical_model.as_ref().map(ModelId::as_str),
Some("zhipuai/glm-5.2")
);
assert!(offerings[0].default_for_provider);
}
#[test]
fn non_text_output_is_not_a_chat_model() {
let model = ModelsDevProviderModel {
id: "mimo-v2.5-tts".to_string(),
modalities: Some(ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["audio".to_string()],
}),
..Default::default()
};
assert!(!model.supports_text_chat());
}
#[test]
fn empty_modalities_struct_is_chat_capable() {
// `"modalities": {}` deserializes to Some(empty); it must default to
// chat-capable just like absent modality metadata (the None branch),
// otherwise rows from incomplete snapshots are silently dropped.
let provider_model = ModelsDevProviderModel {
modalities: Some(ModelsDevModalities::default()),
..Default::default()
};
assert!(provider_model.supports_text_chat());
let canonical = ModelsDevModel {
modalities: Some(ModelsDevModalities::default()),
..Default::default()
};
assert!(canonical.supports_text_chat());
// A list populated with only non-text entries still excludes the row.
let audio_only = ModelsDevProviderModel {
modalities: Some(ModelsDevModalities {
input: vec!["text".to_string()],
output: vec!["audio".to_string()],
}),
..Default::default()
};
assert!(!audio_only.supports_text_chat());
}
#[test]
fn interleaved_boolean_true_parses_and_reports_enabled() {
// 32 live provider rows (e.g. `vercel`, `amazon-bedrock`) send
// `interleaved: true`; the object-only model rejected all of them.
let raw = r#"{
"providers": {
"vercel": {
"models": {
"zai/glm-4.7": { "id": "zai/glm-4.7", "interleaved": true }
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
let model = catalog
.provider_model("vercel", "zai/glm-4.7")
.expect("provider model");
let interleaved = model.interleaved.as_ref().expect("interleaved present");
assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(true));
assert!(interleaved.is_enabled());
assert_eq!(interleaved.field(), None);
}
#[test]
fn interleaved_boolean_false_parses_and_reports_disabled() {
let raw = r#"{
"providers": {
"custom": {
"models": {
"house-model": { "id": "house-model", "interleaved": false }
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("boolean interleaved parses");
let model = catalog
.provider_model("custom", "house-model")
.expect("provider model");
let interleaved = model.interleaved.as_ref().expect("interleaved present");
assert_eq!(interleaved, &ModelsDevInterleaved::Enabled(false));
assert!(!interleaved.is_enabled());
assert_eq!(interleaved.field(), None);
}
#[test]
fn interleaved_object_form_preserves_field_metadata() {
// The majority of live rows use `{ "field": "reasoning_content" }`; the
// fix must keep parsing them and surface the named wire field.
let raw = r#"{
"providers": {
"alibaba-cn": {
"models": {
"glm-5.2": {
"id": "glm-5.2",
"interleaved": { "field": "reasoning_content" }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("object interleaved parses");
let model = catalog
.provider_model("alibaba-cn", "glm-5.2")
.expect("provider model");
let interleaved = model.interleaved.as_ref().expect("interleaved present");
assert_eq!(interleaved.field(), Some("reasoning_content"));
assert!(interleaved.is_enabled());
}
#[test]
fn interleaved_object_tolerates_empty_and_unknown_keys() {
// An empty object and an object with only unmodeled sibling keys must
// still parse (object form, no named field) rather than erroring.
let raw = r#"{
"providers": {
"custom": {
"models": {
"empty-obj": { "id": "empty-obj", "interleaved": {} },
"future-obj": {
"id": "future-obj",
"interleaved": { "future_hint": "x" }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("tolerant interleaved parses");
let empty = catalog
.provider_model("custom", "empty-obj")
.and_then(|m| m.interleaved.clone())
.expect("empty object interleaved present");
assert_eq!(empty, ModelsDevInterleaved::Field { field: None });
assert_eq!(empty.field(), None);
assert!(empty.is_enabled());
let future = catalog
.provider_model("custom", "future-obj")
.and_then(|m| m.interleaved.clone())
.expect("future object interleaved present");
assert_eq!(future.field(), None);
}
#[test]
fn live_ish_mixed_interleaved_sample_deserializes() {
// A representative slice of live `catalog.json`: boolean and object
// interleaved rows side by side, plus an unmodeled top-level provider
// key (`doc`) and an unmodeled model key to prove unknown upstream
// fields are ignored safely. This is the acceptance "live-ish sample".
let raw = r#"{
"providers": {
"amazon-bedrock": {
"id": "amazon-bedrock",
"doc": "https://docs.aws.amazon.com/bedrock/",
"models": {
"anthropic.claude-opus": {
"id": "anthropic.claude-opus",
"reasoning": true,
"interleaved": true,
"some_future_flag": 7,
"modalities": { "input": ["text"], "output": ["text"] }
}
}
},
"alibaba-cn": {
"id": "alibaba-cn",
"models": {
"deepseek-v4-flash": {
"id": "deepseek-v4-flash",
"interleaved": { "field": "reasoning_content" },
"modalities": { "input": ["text"], "output": ["text"] }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("live-ish sample parses");
let bedrock = catalog
.provider_model("amazon-bedrock", "anthropic.claude-opus")
.expect("bedrock row");
assert_eq!(
bedrock.interleaved,
Some(ModelsDevInterleaved::Enabled(true))
);
let alibaba = catalog
.provider_model("alibaba-cn", "deepseek-v4-flash")
.expect("alibaba row");
assert_eq!(
alibaba.interleaved.as_ref().and_then(|i| i.field()),
Some("reasoning_content")
);
// Both rows still resolve as chat offerings; interleaved does not
// interfere with route resolution.
assert_eq!(
catalog
.provider_offerings("amazon-bedrock")
.map(|rows| rows.len()),
Some(1)
);
}
#[test]
fn provider_offerings_keep_rows_with_empty_modalities_object() {
// End-to-end guard for the empty-modalities case at the offering layer:
// a custom/local provider row with `"modalities": {}` must still emit a
// chat offering rather than being filtered out of route resolution.
let raw = r#"{
"providers": {
"custom": {
"models": {
"house-model": { "id": "house-model", "modalities": {} }
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let offerings = catalog
.provider_offerings("custom")
.expect("provider offerings");
assert_eq!(offerings.len(), 1);
assert_eq!(offerings[0].wire_model_id.as_str(), "house-model");
// `id` was omitted on the provider row → effective id is the catalog key.
assert_eq!(offerings[0].provider.as_str(), "custom");
}
}
+530
View File
@@ -0,0 +1,530 @@
//! Transactional persistence, atomic writes, and secret redaction for the
//! v0.8.67 constitution-first setup lane (#3410).
//!
//! This is the safety layer under every setup step. A setup session may touch
//! several files (the setup-state sidecar, the user-global constitution, and —
//! through the existing comment-preserving `ConfigStore` — `config.toml`). The
//! contract this module guarantees:
//!
//! - **Preview writes nothing.** [`SetupTransaction::preview`] reports what
//! would change without touching the filesystem.
//! - **Cancel leaves files unchanged.** A staged transaction that is dropped
//! without [`SetupTransaction::commit`] never wrote anything.
//! - **Save is atomic.** Each file is written through a temp file + rename
//! ([`atomic_write`]); a multi-file commit either fully applies or fully
//! rolls back, so a partial failure never leaves a half-written file.
//! - **Secrets never leak.** [`redact_secrets`] masks secret-bearing values for
//! any report, log line, or diagnostic that might echo config text.
//!
//! This module deliberately owns only the write / rollback / secret contract.
//! Each setup step owns *which* fields it writes; see [`crate::setup_state`] and
//! [`crate::user_constitution`].
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::Serialize;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
/// Restrictive file mode for setup-owned files (owner read/write only).
#[cfg(unix)]
const SETUP_FILE_MODE: u32 = 0o600;
/// Atomically write `bytes` to `path` via a sibling temp file + rename.
///
/// The temp file is created in the same directory as `path` so the final
/// `rename` is atomic on the same filesystem. On Unix the file is created with
/// `0o600` so setup-owned state never lands world-readable. Parent directories
/// are created as needed.
pub fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
if let Some(parent) = parent {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create directory {}", parent.display()))?;
}
let dir = parent.unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(dir)
.with_context(|| format!("failed to create temp file in {}", dir.display()))?;
use std::io::Write as _;
tmp.write_all(bytes)
.with_context(|| format!("failed to write temp file for {}", path.display()))?;
tmp.flush()
.with_context(|| format!("failed to flush temp file for {}", path.display()))?;
#[cfg(unix)]
{
let perms = fs::Permissions::from_mode(SETUP_FILE_MODE);
tmp.as_file()
.set_permissions(perms)
.with_context(|| format!("failed to set permissions for {}", path.display()))?;
}
tmp.persist(path)
.map_err(|e| e.error)
.with_context(|| format!("failed to persist {}", path.display()))?;
Ok(())
}
/// Atomically write `value` as pretty-printed JSON to `path`.
///
/// A trailing newline is appended so the file is well-formed for line-oriented
/// tooling and diffs.
pub fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
let mut body = serde_json::to_string_pretty(value)
.with_context(|| format!("failed to serialize JSON for {}", path.display()))?;
body.push('\n');
atomic_write(path, body.as_bytes())
}
/// A staged multi-file write that either fully applies or fully rolls back.
///
/// Stage every file the setup step intends to write, then call [`commit`]. If
/// any single write fails, every already-applied write in the transaction is
/// restored to its pre-commit contents (or removed if it did not previously
/// exist), and the original error is returned. A transaction that is dropped
/// without committing leaves the filesystem untouched.
///
/// [`commit`]: SetupTransaction::commit
#[derive(Debug, Default)]
pub struct SetupTransaction {
writes: Vec<StagedWrite>,
}
#[derive(Debug, Clone)]
struct StagedWrite {
path: PathBuf,
bytes: Vec<u8>,
}
/// A snapshot of a file's pre-commit state, captured so [`SetupTransaction`]
/// can restore it during rollback.
struct Snapshot {
path: PathBuf,
/// Original bytes, or `None` if the file did not exist before commit.
original: Option<Vec<u8>>,
}
impl SetupTransaction {
/// Create an empty transaction.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Stage `bytes` to be written to `path` on [`commit`](Self::commit).
///
/// Staging touches nothing on disk. A later stage for the same path
/// replaces an earlier one, so a step can revise its intended output before
/// committing.
pub fn stage(&mut self, path: impl Into<PathBuf>, bytes: impl Into<Vec<u8>>) -> &mut Self {
let path = path.into();
let bytes = bytes.into();
if let Some(existing) = self.writes.iter_mut().find(|w| w.path == path) {
existing.bytes = bytes;
} else {
self.writes.push(StagedWrite { path, bytes });
}
self
}
/// Stage `value` serialized as pretty JSON (with trailing newline).
pub fn stage_json<T: Serialize>(
&mut self,
path: impl Into<PathBuf>,
value: &T,
) -> Result<&mut Self> {
let path = path.into();
let mut body = serde_json::to_string_pretty(value)
.with_context(|| format!("failed to serialize JSON for {}", path.display()))?;
body.push('\n');
Ok(self.stage(path, body.into_bytes()))
}
/// The paths that [`commit`](Self::commit) would write, in staging order.
/// Writes nothing — this is the preview surface.
#[must_use]
pub fn preview(&self) -> Vec<&Path> {
self.writes.iter().map(|w| w.path.as_path()).collect()
}
/// True when nothing is staged.
#[must_use]
pub fn is_empty(&self) -> bool {
self.writes.is_empty()
}
/// Apply every staged write atomically.
///
/// On success all files are updated. On the first failure, every write that
/// already landed is rolled back to its captured pre-commit state and the
/// original error is returned (rollback failures are attached as context).
pub fn commit(self) -> Result<()> {
let mut snapshots: Vec<Snapshot> = Vec::with_capacity(self.writes.len());
for write in &self.writes {
// Capture the pre-commit state before mutating, so we can restore it.
let original = match fs::read(&write.path) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => {
rollback(&snapshots);
return Err(e).with_context(|| {
format!(
"failed to read existing {} before write; rolled back {} prior change(s)",
write.path.display(),
snapshots.len()
)
});
}
};
match atomic_write(&write.path, &write.bytes) {
Ok(()) => snapshots.push(Snapshot {
path: write.path.clone(),
original,
}),
Err(err) => {
// This write did not land (atomic_write is all-or-nothing),
// so roll back only the writes that came before it.
rollback(&snapshots);
return Err(err).with_context(|| {
format!(
"setup transaction failed writing {}; rolled back {} prior change(s)",
write.path.display(),
snapshots.len()
)
});
}
}
}
Ok(())
}
}
/// Restore every snapshot to its captured pre-commit state. Best-effort: a
/// rollback error is logged but does not abort the remaining restores, because
/// leaving as many files as possible in their original state is the goal.
fn rollback(snapshots: &[Snapshot]) {
for snap in snapshots.iter().rev() {
let result = match &snap.original {
Some(bytes) => atomic_write(&snap.path, bytes),
None => match fs::remove_file(&snap.path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
},
};
if let Err(e) = result {
tracing::error!(
target: "config::persistence",
"failed to roll back {} during setup transaction: {e:#}",
snap.path.display()
);
}
}
}
/// Substrings that mark a config/JSON/env key as carrying a secret value.
const SENSITIVE_KEY_HINTS: &[&str] = &[
"api_key",
"apikey",
"api-key",
"secret",
"token",
"password",
"passwd",
"authorization",
"auth_token",
"access_key",
"client_secret",
"private_key",
];
/// Known opaque-token prefixes worth masking even when they appear bare (not as
/// `key = value`). Conservative on purpose: only well-known provider/key shapes.
const SECRET_TOKEN_PREFIXES: &[&str] = &["sk-", "sk_", "ghp_", "gho_", "xoxb-", "xoxp-", "pk-"];
/// The placeholder substituted for any redacted secret value.
pub const REDACTED: &str = "[redacted]";
/// Redact secret-bearing values from arbitrary text so it is safe to put in a
/// setup report, log line, error message, or test snapshot.
///
/// Two passes, both dependency-free:
///
/// 1. **Keyed assignments.** Lines shaped like `key = value`, `key: value`, or
/// `key=value` whose key (case-insensitively, ignoring quotes) contains a
/// [`SENSITIVE_KEY_HINTS`] substring have their value replaced with
/// [`REDACTED`].
/// 2. **Bare tokens.** Whitespace-delimited words beginning with a known
/// [`SECRET_TOKEN_PREFIXES`] are replaced wholesale.
///
/// The goal is defense in depth: setup state and reports are built from safe
/// summaries that never include secrets in the first place, and this is the
/// backstop for anything that echoes raw config text.
#[must_use]
pub fn redact_secrets(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut first = true;
for line in input.split_inclusive('\n') {
if !first {
// split_inclusive keeps the newline on the previous chunk, so we do
// not need to re-add separators here.
}
first = false;
out.push_str(&redact_line(line));
}
out
}
/// Redact a single line (which may include a trailing newline).
fn redact_line(line: &str) -> String {
// Preserve any trailing newline so callers keep their line structure.
let (body, newline) = match line.strip_suffix('\n') {
Some(rest) => (rest, "\n"),
None => (line, ""),
};
if let Some(redacted) = redact_keyed_assignment(body) {
return format!("{redacted}{newline}");
}
// Bare-token pass: mask any whitespace-delimited word with a known prefix.
let mut changed = false;
let masked: Vec<String> = body
.split(' ')
.map(|word| {
let trimmed = word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
changed = true;
word.replace(trimmed, REDACTED)
} else {
word.to_string()
}
})
.collect();
if changed {
format!("{}{newline}", masked.join(" "))
} else {
format!("{body}{newline}")
}
}
/// If `body` is a `key <sep> value` assignment with a sensitive key, return the
/// line with the value redacted; otherwise `None`.
fn redact_keyed_assignment(body: &str) -> Option<String> {
// Find the first `=` or `:` that separates a key from a value.
let sep_idx = body.find(['=', ':'])?;
let (raw_key, rest) = body.split_at(sep_idx);
let sep = &rest[..1];
let raw_value = &rest[1..];
let key_norm = raw_key
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'))
.to_ascii_lowercase();
if key_norm.is_empty() || !SENSITIVE_KEY_HINTS.iter().any(|h| key_norm.contains(h)) {
return None;
}
// Keep leading whitespace of the key and the original separator spacing so
// the redacted line reads naturally.
let key_lead_ws: String = raw_key.chars().take_while(|c| c.is_whitespace()).collect();
let value_lead_ws: String = raw_value
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let value_rest = raw_value.trim_start();
// If the value is empty, there is nothing to hide.
if value_rest.is_empty() {
return None;
}
// Preserve surrounding quotes so structured files stay parseable-looking.
let quoted = value_rest.starts_with('"') || value_rest.starts_with('\'');
let replacement = if quoted {
format!("\"{REDACTED}\"")
} else {
REDACTED.to_string()
};
Some(format!(
"{key_lead_ws}{}{sep}{value_lead_ws}{replacement}",
raw_key.trim()
))
}
fn looks_like_secret_token(word: &str) -> bool {
SECRET_TOKEN_PREFIXES
.iter()
.any(|p| word.len() > p.len() + 6 && word.starts_with(p))
}
#[cfg(test)]
mod tests {
use super::*;
fn read(path: &Path) -> String {
fs::read_to_string(path).unwrap()
}
#[test]
fn atomic_write_creates_parent_dirs_and_content() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested/dir/state.json");
atomic_write(&path, b"hello").unwrap();
assert_eq!(read(&path), "hello");
}
#[cfg(unix)]
#[test]
fn atomic_write_uses_owner_only_permissions() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
atomic_write(&path, b"x").unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, SETUP_FILE_MODE);
}
#[test]
fn atomic_write_replaces_existing_atomically() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("state.json");
atomic_write(&path, b"old").unwrap();
atomic_write(&path, b"new").unwrap();
assert_eq!(read(&path), "new");
// No stray temp files left behind.
let leftovers: Vec<_> = fs::read_dir(tmp.path())
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name() != "state.json")
.collect();
assert!(leftovers.is_empty(), "stray temp files: {leftovers:?}");
}
#[test]
fn transaction_preview_writes_nothing() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.json");
let b = tmp.path().join("b.json");
let mut tx = SetupTransaction::new();
tx.stage(a.clone(), b"1".to_vec())
.stage(b.clone(), b"2".to_vec());
let preview = tx.preview();
assert_eq!(preview, vec![a.as_path(), b.as_path()]);
assert!(!a.exists());
assert!(!b.exists());
}
#[test]
fn dropped_transaction_leaves_files_unchanged() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.json");
{
let mut tx = SetupTransaction::new();
tx.stage(a.clone(), b"staged".to_vec());
// tx dropped here without commit
}
assert!(!a.exists());
}
#[test]
fn transaction_commit_applies_all() {
let tmp = tempfile::tempdir().unwrap();
let a = tmp.path().join("a.json");
let b = tmp.path().join("sub/b.json");
let mut tx = SetupTransaction::new();
tx.stage(a.clone(), b"A".to_vec())
.stage(b.clone(), b"B".to_vec());
tx.commit().unwrap();
assert_eq!(read(&a), "A");
assert_eq!(read(&b), "B");
}
#[test]
fn transaction_rolls_back_on_partial_failure() {
let tmp = tempfile::tempdir().unwrap();
let good = tmp.path().join("good.json");
fs::write(&good, "ORIGINAL").unwrap();
// Second target is unwritable: a path whose parent is an existing file.
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "i am a file").unwrap();
let bad = blocker.join("child.json"); // parent is a file → create_dir_all fails
let mut tx = SetupTransaction::new();
tx.stage(good.clone(), b"UPDATED".to_vec())
.stage(bad.clone(), b"NOPE".to_vec());
let err = tx.commit().unwrap_err();
assert!(format!("{err:#}").contains("rolled back"));
// The first file must be restored to its original contents.
assert_eq!(read(&good), "ORIGINAL");
assert!(!bad.exists());
}
#[test]
fn transaction_rollback_removes_newly_created_file() {
let tmp = tempfile::tempdir().unwrap();
let fresh = tmp.path().join("fresh.json"); // did not exist before
let blocker = tmp.path().join("blocker");
fs::write(&blocker, "file").unwrap();
let bad = blocker.join("child.json");
let mut tx = SetupTransaction::new();
tx.stage(fresh.clone(), b"created".to_vec())
.stage(bad, b"x".to_vec());
assert!(tx.commit().is_err());
// The newly created file must be removed on rollback, not left behind.
assert!(!fresh.exists());
}
#[test]
fn redact_masks_keyed_secrets_toml_and_json() {
let input = "\
api_key = \"sk-supersecretvalue123\"
provider = \"openai\"
\"token\": \"abc123def456ghi\",
model = \"mimo-ultraspeed\"
PASSWORD=hunter2hunter2";
let out = redact_secrets(input);
assert!(!out.contains("sk-supersecretvalue123"), "{out}");
assert!(!out.contains("abc123def456ghi"), "{out}");
assert!(!out.contains("hunter2hunter2"), "{out}");
// Non-secret values survive untouched.
assert!(out.contains("provider = \"openai\""));
assert!(out.contains("model = \"mimo-ultraspeed\""));
assert!(out.matches(REDACTED).count() >= 3, "{out}");
}
#[test]
fn redact_masks_bare_token_prefixes() {
let out = redact_secrets("the leaked key sk-abcdef1234567890 appeared in a log");
assert!(!out.contains("sk-abcdef1234567890"), "{out}");
assert!(out.contains(REDACTED));
assert!(out.contains("appeared in a log"));
}
#[test]
fn redact_preserves_line_structure() {
let input = "line1\nsecret = \"xyzsecretvalue\"\nline3";
let out = redact_secrets(input);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "line1");
assert_eq!(lines[2], "line3");
assert!(lines[1].contains(REDACTED));
}
#[test]
fn redact_leaves_plain_text_untouched() {
let input = "the quick brown fox = jumps over";
// `fox` key has no sensitive hint → unchanged.
assert_eq!(redact_secrets(input), input);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Provider/offering-scoped pricing projection with provenance (#3085).
//!
//! Network-free. Maps Models.dev offering `cost` (and live / user-override
//! rows) into pricing rows that carry explicit **provenance**, **currency**, and
//! **effective-at** metadata, plus a pure cost estimator over normalized token
//! usage. UI display (`CostDisplay`) and provider usage-payload parsing live
//! above this layer and are out of scope here.
//!
//! Boundary with the route layer: this models *pricing* — offering-owned,
//! per-token unit prices. The coarse route-facing meter shape already exists as
//! [`crate::route::PricingSku`]
//! (`Token` / `SubscriptionQuota` / `AccountCredits` / `LocalOrNotApplicable` /
//! `UnknownOrStale`); [`OfferingPricing::to_route_sku`] and
//! [`route_pricing_sku`] bridge to it.
//!
//! Honesty rule (#2608 / #3085): pricing is never assumed. A route with no
//! sourced price yields `None` here and `UnknownOrStale` at the route layer —
//! never a fabricated token price, and never an implicit "free" for
//! local/custom/subscription routes.
use serde::{Deserialize, Serialize};
use crate::catalog::{CatalogOffering, CatalogSource};
use crate::models_dev::ModelsDevCost;
use crate::route::PricingSku;
/// Billing currency for a pricing row. Models.dev publishes USD per-million
/// costs; other currencies arrive via provider docs or user overrides.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Currency {
#[default]
Usd,
Cny,
/// An ISO-4217-style code CodeWhale does not special-case.
Other(String),
}
/// 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)]
#[serde(tag = "source", rename_all = "snake_case")]
pub enum PricingProvenance {
/// Seeded from a bundled Models.dev catalog snapshot.
ModelsDevBundled,
/// From a provider live `/models` (or pricing) refresh.
ProviderLive,
/// From provider documentation / a hand-sourced seed. Set only by callers
/// constructing rows directly; `from_catalog_offering` never produces this
/// (Models.dev-sourced rows map to `ModelsDevBundled` / `ProviderLive`).
ProviderDocs,
/// User-supplied override (custom endpoint, enterprise terms, local route).
UserOverride,
/// No sourced price.
Unknown,
}
/// Normalized token usage for a single turn, in canonical billable classes.
///
/// 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)]
pub struct TokenUsage {
/// Non-cached input (prompt) tokens.
pub input: u64,
/// Output (completion) tokens, including reasoning output.
pub output: u64,
/// Cache-read (cache-hit) input tokens, billed at the cache-read rate.
pub cache_read: u64,
/// Cache-write (cache-creation) tokens, billed at the cache-write rate.
pub cache_write: u64,
}
/// A provider/offering-scoped pricing row.
///
/// 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)]
pub struct OfferingPricing {
/// Provider id serving the offering.
pub provider: String,
/// Provider-owned wire id the price applies to.
pub wire_model_id: String,
/// Canonical model identity, when the offering carries one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub canonical_model: Option<String>,
/// Billing currency.
pub currency: Currency,
/// Input price per million tokens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_per_million: Option<f64>,
/// Output price per million tokens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_per_million: Option<f64>,
/// Cache-read price per million tokens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_per_million: Option<f64>,
/// Cache-write price per million tokens.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_per_million: Option<f64>,
/// Where the price came from.
pub provenance: PricingProvenance,
/// Unix seconds the price was fetched / became effective, when known.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_at: Option<u64>,
}
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`]).
///
/// Models.dev `cost` values are USD per million tokens, so the currency is
/// [`Currency::Usd`]; provenance and `effective_at` follow the offering's
/// [`CatalogSource`].
#[must_use]
pub fn from_catalog_offering(offering: &CatalogOffering) -> Option<Self> {
let cost = offering.cost.as_ref()?;
if cost.input.is_none()
&& cost.output.is_none()
&& cost.cache_read.is_none()
&& cost.cache_write.is_none()
{
return None;
}
Some(Self {
provider: offering.provider.clone(),
wire_model_id: offering.wire_model_id.clone(),
canonical_model: offering.canonical_model.clone(),
currency: Currency::Usd,
input_per_million: cost.input,
output_per_million: cost.output,
cache_read_per_million: cost.cache_read,
cache_write_per_million: cost.cache_write,
provenance: provenance_from_source(&offering.source),
effective_at: effective_at_from_source(&offering.source),
})
}
/// Whether any per-token price is known.
#[must_use]
pub fn has_any_price(&self) -> bool {
self.input_per_million.is_some()
|| self.output_per_million.is_some()
|| self.cache_read_per_million.is_some()
|| self.cache_write_per_million.is_some()
}
/// 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.
#[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,
}
}
/// 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 (tokens, price) in [
(usage.input, self.input_per_million),
(usage.output, self.output_per_million),
(usage.cache_read, self.cache_read_per_million),
(usage.cache_write, self.cache_write_per_million),
] {
if tokens > 0 {
let price = price?;
// Per-turn token counts are far below 2^53, so this cast is
// exact; revisit if TokenUsage ever aggregates across sessions.
total += (tokens as f64 / 1_000_000.0) * price;
}
}
Some(total)
}
/// Project to the coarse route-facing meter shape.
///
/// Returns [`PricingSku::Token`] only when an input or output rate is known.
/// The route-layer `Token` shape carries only input/output rates, so a row
/// priced *only* on cache classes would become a `Token` with no visible
/// rates — misleading at the route layer. Such rows degrade to
/// [`PricingSku::UnknownOrStale`] here while their cache rates remain usable
/// through [`OfferingPricing::estimate_cost`].
#[must_use]
pub fn to_route_sku(&self) -> PricingSku {
if self.input_per_million.is_none() && self.output_per_million.is_none() {
return PricingSku::UnknownOrStale;
}
PricingSku::Token {
input_per_mtok: self.input_per_million,
output_per_mtok: self.output_per_million,
}
}
}
/// The honest route-facing pricing meter for a catalog offering.
///
/// 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.)
#[must_use]
pub fn route_pricing_sku(offering: &CatalogOffering) -> PricingSku {
OfferingPricing::from_catalog_offering(offering)
.map_or(PricingSku::UnknownOrStale, |pricing| pricing.to_route_sku())
}
/// The honest route-facing pricing meter for a raw Models.dev `cost` block.
///
/// Same honesty rule as [`route_pricing_sku`], but for callers that hold a
/// [`ModelsDevCost`] directly (the route-offering builders in
/// [`crate::models_dev`]) rather than a full [`CatalogOffering`]. An absent or
/// concretely-empty cost, or a cache-only cost, yields
/// [`PricingSku::UnknownOrStale`]; only a usable input/output rate yields
/// [`PricingSku::Token`].
#[must_use]
pub(crate) fn route_pricing_sku_from_cost(cost: Option<&ModelsDevCost>) -> PricingSku {
let Some(cost) = cost else {
return PricingSku::UnknownOrStale;
};
if cost.input.is_none() && cost.output.is_none() {
// No input/output rate: a cache-only or empty cost would render as a
// rate-less `Token` at the route layer, so it stays honestly unknown.
return PricingSku::UnknownOrStale;
}
PricingSku::Token {
input_per_mtok: cost.input,
output_per_mtok: cost.output,
}
}
fn provenance_from_source(source: &CatalogSource) -> PricingProvenance {
match source {
CatalogSource::Bundled => PricingProvenance::ModelsDevBundled,
CatalogSource::Live { .. } => PricingProvenance::ProviderLive,
CatalogSource::UserOverride => PricingProvenance::UserOverride,
}
}
fn effective_at_from_source(source: &CatalogSource) -> Option<u64> {
match source {
CatalogSource::Live { fetched_at, .. } => Some(*fetched_at),
CatalogSource::Bundled | CatalogSource::UserOverride => None,
}
}
#[cfg(test)]
mod tests;
+254
View File
@@ -0,0 +1,254 @@
//! Behavior tests for the offering pricing projection (#3085).
use super::*;
use crate::catalog::{CatalogOffering, CatalogSource, bundled_offerings_from_models_dev};
use crate::models_dev::{ModelsDevCatalog, ModelsDevCost};
use crate::route::PricingSku;
/// A DeepSeek-shaped priced offering (input/output/cache-read known,
/// cache-write deliberately unknown) tagged with the given provenance source.
fn priced(source: CatalogSource) -> CatalogOffering {
CatalogOffering {
provider: "deepseek".into(),
wire_model_id: "deepseek-v4-pro".into(),
canonical_model: Some("deepseek-v4-pro".into()),
endpoint_key: "chat".into(),
cost: Some(ModelsDevCost {
input: Some(0.28),
output: Some(0.42),
cache_read: Some(0.028),
cache_write: None,
}),
source,
..Default::default()
}
}
#[test]
fn maps_models_dev_cost_with_bundled_provenance_in_usd() {
let p =
OfferingPricing::from_catalog_offering(&priced(CatalogSource::Bundled)).expect("priced");
assert_eq!(p.currency, Currency::Usd);
assert_eq!(p.input_per_million, Some(0.28));
assert_eq!(p.output_per_million, Some(0.42));
assert_eq!(p.cache_read_per_million, Some(0.028));
assert_eq!(p.cache_write_per_million, None);
assert_eq!(p.provenance, PricingProvenance::ModelsDevBundled);
assert_eq!(p.effective_at, None);
assert!(p.has_any_price());
}
#[test]
fn live_source_carries_provider_live_provenance_and_effective_at() {
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));
}
#[test]
fn no_cost_or_empty_cost_object_is_unknown() {
let mut offering = priced(CatalogSource::Bundled);
offering.cost = None;
assert!(
OfferingPricing::from_catalog_offering(&offering).is_none(),
"absent cost is unknown, not free"
);
// 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());
}
#[test]
fn estimate_cost_sums_priced_classes() {
let p = OfferingPricing::from_catalog_offering(&priced(CatalogSource::Bundled)).unwrap();
// 1M input @0.28 + 0.5M output @0.42 + 2M cache_read @0.028 = 0.546
let usage = TokenUsage {
input: 1_000_000,
output: 500_000,
cache_read: 2_000_000,
cache_write: 0,
};
let cost = p.estimate_cost(&usage).expect("priced classes estimate");
assert!((cost - 0.546).abs() < 1e-9, "got {cost}");
}
#[test]
fn estimate_cost_is_none_when_a_used_class_is_unpriced() {
// cache_write price is unknown; charging cache-write tokens cannot be
// estimated honestly, so the whole estimate is None rather than under-reported.
let p = OfferingPricing::from_catalog_offering(&priced(CatalogSource::Bundled)).unwrap();
let usage = TokenUsage {
input: 100,
output: 0,
cache_read: 0,
cache_write: 10,
};
assert!(p.estimate_cost(&usage).is_none());
}
#[test]
fn estimate_cost_with_zero_usage_is_zero() {
let p = OfferingPricing::from_catalog_offering(&priced(CatalogSource::Bundled)).unwrap();
assert_eq!(p.estimate_cost(&TokenUsage::default()), Some(0.0));
}
#[test]
fn route_pricing_sku_is_token_when_priced_and_unknown_otherwise() {
match route_pricing_sku(&priced(CatalogSource::Bundled)) {
PricingSku::Token {
input_per_mtok,
output_per_mtok,
} => {
assert_eq!(input_per_mtok, Some(0.28));
assert_eq!(output_per_mtok, Some(0.42));
}
other => panic!("expected Token, got {other:?}"),
}
// No cost → honest UnknownOrStale, never a fabricated zero price.
let mut unpriced = priced(CatalogSource::Bundled);
unpriced.cost = None;
assert!(matches!(
route_pricing_sku(&unpriced),
PricingSku::UnknownOrStale
));
}
#[test]
fn currency_round_trips_including_other() {
for currency in [Currency::Usd, Currency::Cny, Currency::Other("eur".into())] {
let json = serde_json::to_string(&currency).expect("serialize");
let back: Currency = serde_json::from_str(&json).expect("deserialize");
assert_eq!(currency, back);
}
}
#[test]
fn user_override_pricing_round_trips_and_carries_no_secrets() {
let pricing = OfferingPricing {
provider: "custom".into(),
wire_model_id: "house-model".into(),
canonical_model: None,
currency: Currency::Cny,
input_per_million: Some(8.0),
output_per_million: Some(16.0),
cache_read_per_million: None,
cache_write_per_million: None,
provenance: PricingProvenance::UserOverride,
effective_at: None,
};
let json = serde_json::to_string_pretty(&pricing).expect("serialize");
let back: OfferingPricing = serde_json::from_str(&json).expect("round-trip");
assert_eq!(pricing, back);
let lower = json.to_lowercase();
for needle in [
"api_key",
"apikey",
"authorization",
"secret",
"password",
"bearer",
"access_token",
] {
assert!(!lower.contains(needle), "pricing JSON contains `{needle}`");
}
}
#[test]
fn staleness_applies_to_live_rows_only() {
let live = CatalogSource::Live {
base_url_fingerprint: "fp".into(),
fetched_at: 1_000,
};
let live_price = OfferingPricing::from_catalog_offering(&priced(live)).unwrap();
assert!(!live_price.is_stale(1_500, 3_600), "within TTL");
assert!(live_price.is_stale(5_000, 3_600), "past TTL");
// A bundled price has no fetch clock and is not age-stale.
let bundled = OfferingPricing::from_catalog_offering(&priced(CatalogSource::Bundled)).unwrap();
assert!(!bundled.is_stale(u64::MAX, 1));
}
#[test]
fn pricing_flows_from_the_models_dev_parser() {
let raw = r#"{
"providers": {
"zai": {
"models": {
"glm-5.2": {
"id": "glm-5.2",
"modalities": { "input": ["text"], "output": ["text"] },
"cost": { "input": 1.4, "output": 4.4, "cache_read": 0.26 }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("fixture parses");
let rows = bundled_offerings_from_models_dev(&catalog);
let pricing = OfferingPricing::from_catalog_offering(&rows[0]).expect("zai glm-5.2 is priced");
assert_eq!(pricing.provider, "zai");
assert_eq!(pricing.wire_model_id, "glm-5.2");
assert_eq!(pricing.input_per_million, Some(1.4));
assert_eq!(pricing.output_per_million, Some(4.4));
assert_eq!(pricing.cache_read_per_million, Some(0.26));
assert_eq!(pricing.provenance, PricingProvenance::ModelsDevBundled);
}
#[test]
fn cache_only_offering_is_unknown_at_the_route_layer() {
// Priced only on cache classes (no input/output): the route Token badge
// would have no visible rates, so route_pricing_sku degrades to
// UnknownOrStale — yet the cache rate is still usable for estimate_cost.
let mut offering = priced(CatalogSource::Bundled);
offering.cost = Some(ModelsDevCost {
input: None,
output: None,
cache_read: Some(0.028),
cache_write: None,
});
assert!(matches!(
route_pricing_sku(&offering),
PricingSku::UnknownOrStale
));
let pricing =
OfferingPricing::from_catalog_offering(&offering).expect("cache-only row is still priced");
assert!(pricing.has_any_price());
let usage = TokenUsage {
cache_read: 1_000_000,
..Default::default()
};
assert_eq!(pricing.estimate_cost(&usage), Some(0.028));
}
#[test]
fn user_override_source_maps_through_from_catalog_offering() {
// Exercises provenance_from_source / effective_at_from_source for the
// override arm via the hydration path (not direct construction).
let pricing = OfferingPricing::from_catalog_offering(&priced(CatalogSource::UserOverride))
.expect("priced");
assert_eq!(pricing.provenance, PricingProvenance::UserOverride);
assert_eq!(pricing.effective_at, None);
}
#[test]
fn staleness_is_inclusive_at_the_ttl_boundary() {
let live = CatalogSource::Live {
base_url_fingerprint: "fp".into(),
fetched_at: 1_000,
};
let p = OfferingPricing::from_catalog_offering(&priced(live)).unwrap();
// age == max_age_secs counts as stale (`>=` semantics)...
assert!(p.is_stale(1_100, 100));
// ...one second younger is still fresh.
assert!(!p.is_stale(1_099, 100));
}
+917
View File
@@ -0,0 +1,917 @@
//! Built-in provider metadata.
//!
//! This module is a metadata foundation for collapsing provider drift over
//! time. It deliberately does not mutate request bodies or choose fallback
//! providers; runtime routing remains in `ConfigToml::resolve_runtime_options`.
use super::{
DEFAULT_ARCEE_BASE_URL, DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL,
DEFAULT_ATLASCLOUD_MODEL, DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL,
DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, DEFAULT_FIREWORKS_BASE_URL,
DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL,
DEFAULT_LONGCAT_BASE_URL, DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL, DEFAULT_MOONSHOT_BASE_URL,
DEFAULT_MOONSHOT_MODEL, DEFAULT_NOVITA_BASE_URL, DEFAULT_NOVITA_MODEL,
DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL, DEFAULT_OLLAMA_BASE_URL,
DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_CODEX_BASE_URL,
DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENMODEL_BASE_URL,
DEFAULT_OPENMODEL_MODEL, DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL,
DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL,
DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL,
DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL,
DEFAULT_STEPFUN_MODEL, DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL,
DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL,
DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL,
DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, DEFAULT_XIAOMI_MIMO_BASE_URL,
DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL, ProviderKind,
};
/// Wire protocol spoken by a provider.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum WireFormat {
/// OpenAI-compatible `/v1/chat/completions` style payloads.
ChatCompletions,
/// OpenAI Responses API (`/responses`).
Responses,
/// Native Anthropic Messages API (`/v1/messages`).
AnthropicMessages,
}
/// Static metadata for a built-in model provider.
pub trait Provider: Send + Sync {
/// Provider enum variant represented by this entry.
fn kind(&self) -> ProviderKind;
/// Canonical provider identifier.
fn id(&self) -> &'static str {
self.kind().as_str()
}
/// Human-readable provider label for UIs and diagnostics.
fn display_name(&self) -> &'static str;
/// Default base URL used when no config/env/CLI override is present.
fn default_base_url(&self) -> &'static str;
/// Default model used when no config/env/CLI override is present.
fn default_model(&self) -> &'static str;
/// Environment variable candidates used for this provider's API key.
fn env_vars(&self) -> &'static [&'static str];
/// TOML table key under `[providers.<key>]`.
fn provider_config_key(&self) -> &'static str;
/// Alternate names accepted during provider resolution.
fn aliases(&self) -> &'static [&'static str] {
&[]
}
/// Wire format used by the provider.
fn wire(&self) -> WireFormat {
WireFormat::ChatCompletions
}
}
macro_rules! provider {
(
$struct_name:ident,
$kind:ident,
$id:literal,
$display_name:literal,
$base_url:ident,
$model:ident,
[$($env_var:literal),* $(,)?],
$config_key:literal,
aliases: [$($alias:literal),* $(,)?]
) => {
/// Zero-sized metadata entry for this built-in provider.
pub struct $struct_name;
impl Provider for $struct_name {
fn id(&self) -> &'static str {
$id
}
fn kind(&self) -> ProviderKind {
ProviderKind::$kind
}
fn display_name(&self) -> &'static str {
$display_name
}
fn default_base_url(&self) -> &'static str {
$base_url
}
fn default_model(&self) -> &'static str {
$model
}
fn env_vars(&self) -> &'static [&'static str] {
&[$($env_var),*]
}
fn provider_config_key(&self) -> &'static str {
$config_key
}
fn aliases(&self) -> &'static [&'static str] {
&[$($alias),*]
}
}
};
}
provider!(
Deepseek,
Deepseek,
"deepseek",
"DeepSeek",
DEFAULT_DEEPSEEK_BASE_URL,
DEFAULT_DEEPSEEK_MODEL,
["DEEPSEEK_API_KEY"],
"deepseek",
aliases: ["deep-seek", "deepseek-cn", "deepseek_china", "deepseekcn", "deepseek-china"]
);
/// Opt-in DeepSeek route that speaks the Anthropic Messages wire protocol.
pub struct DeepseekAnthropic;
impl Provider for DeepseekAnthropic {
fn id(&self) -> &'static str {
"deepseek-anthropic"
}
fn kind(&self) -> ProviderKind {
ProviderKind::DeepseekAnthropic
}
fn display_name(&self) -> &'static str {
"DeepSeek (Anthropic-compatible)"
}
fn default_base_url(&self) -> &'static str {
DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL
}
fn default_model(&self) -> &'static str {
DEFAULT_DEEPSEEK_ANTHROPIC_MODEL
}
fn env_vars(&self) -> &'static [&'static str] {
&["DEEPSEEK_API_KEY"]
}
fn provider_config_key(&self) -> &'static str {
"deepseek_anthropic"
}
fn aliases(&self) -> &'static [&'static str] {
&["deepseek_anthropic", "deepseek-claude", "deepseek_claude"]
}
fn wire(&self) -> WireFormat {
WireFormat::AnthropicMessages
}
}
provider!(
NvidiaNim,
NvidiaNim,
"nvidia-nim",
"NVIDIA NIM",
DEFAULT_NVIDIA_NIM_BASE_URL,
DEFAULT_NVIDIA_NIM_MODEL,
["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"],
"nvidia_nim",
aliases: ["nvidia", "nvidia_nim", "nim"]
);
provider!(
Openai,
Openai,
"openai",
"OpenAI-compatible",
DEFAULT_OPENAI_BASE_URL,
DEFAULT_OPENAI_MODEL,
["OPENAI_API_KEY"],
"openai",
aliases: ["open-ai"]
);
provider!(
Atlascloud,
Atlascloud,
"atlascloud",
"AtlasCloud",
DEFAULT_ATLASCLOUD_BASE_URL,
DEFAULT_ATLASCLOUD_MODEL,
["ATLASCLOUD_API_KEY"],
"atlascloud",
aliases: ["atlas-cloud", "atlas_cloud", "atlas"]
);
provider!(
WanjieArk,
WanjieArk,
"wanjie-ark",
"Wanjie Ark",
DEFAULT_WANJIE_ARK_BASE_URL,
DEFAULT_WANJIE_ARK_MODEL,
[
"WANJIE_ARK_API_KEY",
"WANJIE_API_KEY",
"WANJIE_MAAS_API_KEY"
],
"wanjie_ark",
aliases: ["wanjie", "wanjie_ark", "ark-wanjie", "ark_wanjie", "wanjieark", "wanjie-maas", "wanjie_maas", "wanjiemaas"]
);
provider!(
Volcengine,
Volcengine,
"volcengine",
"Volcengine Ark",
DEFAULT_VOLCENGINE_BASE_URL,
DEFAULT_VOLCENGINE_MODEL,
[
"VOLCENGINE_API_KEY",
"VOLCENGINE_ARK_API_KEY",
"ARK_API_KEY"
],
"volcengine",
aliases: ["volcengine-ark", "volcengine_ark", "ark", "volc-ark", "volcengineark"]
);
provider!(
Openrouter,
Openrouter,
"openrouter",
"OpenRouter",
DEFAULT_OPENROUTER_BASE_URL,
DEFAULT_OPENROUTER_MODEL,
["OPENROUTER_API_KEY"],
"openrouter",
aliases: ["open_router"]
);
provider!(
XiaomiMimo,
XiaomiMimo,
"xiaomi-mimo",
"Xiaomi MiMo",
DEFAULT_XIAOMI_MIMO_BASE_URL,
DEFAULT_XIAOMI_MIMO_MODEL,
[
"XIAOMI_MIMO_TOKEN_PLAN_API_KEY",
"MIMO_TOKEN_PLAN_API_KEY",
"XIAOMI_MIMO_API_KEY",
"XIAOMI_API_KEY",
"MIMO_API_KEY",
],
"xiaomi_mimo",
aliases: ["xiaomi_mimo", "xiaomimimo", "mimo", "xiaomi"]
);
provider!(
Novita,
Novita,
"novita",
"Novita AI",
DEFAULT_NOVITA_BASE_URL,
DEFAULT_NOVITA_MODEL,
["NOVITA_API_KEY"],
"novita",
// `novita-ai` is the id Models.dev publishes for this provider; without it a
// live/full Models.dev catalog row keyed `novita-ai` would fail to normalize
// onto ProviderKind::Novita (Refs #4186).
aliases: ["novita-ai", "novita_ai"]
);
provider!(
Fireworks,
Fireworks,
"fireworks",
"Fireworks AI",
DEFAULT_FIREWORKS_BASE_URL,
DEFAULT_FIREWORKS_MODEL,
["FIREWORKS_API_KEY"],
"fireworks",
aliases: ["fireworks-ai"]
);
provider!(
Siliconflow,
Siliconflow,
"siliconflow",
"SiliconFlow",
DEFAULT_SILICONFLOW_BASE_URL,
DEFAULT_SILICONFLOW_MODEL,
["SILICONFLOW_API_KEY"],
"siliconflow",
aliases: ["silicon-flow", "silicon_flow"]
);
provider!(
SiliconflowCN,
SiliconflowCN,
"siliconflow-CN",
"SiliconFlow (China)",
DEFAULT_SILICONFLOW_CN_BASE_URL,
DEFAULT_SILICONFLOW_MODEL,
["SILICONFLOW_API_KEY"],
"siliconflow_cn",
aliases: [
"silicon-flow-cn",
"silicon-flow-CN",
"silicon_flow_cn",
"silicon_flow_CN",
"siliconflow-china",
]
);
provider!(
Arcee,
Arcee,
"arcee",
"Arcee AI",
DEFAULT_ARCEE_BASE_URL,
DEFAULT_ARCEE_MODEL,
["ARCEE_API_KEY"],
"arcee",
aliases: ["arcee-ai", "arcee_ai"]
);
provider!(
Moonshot,
Moonshot,
"moonshot",
"Moonshot/Kimi",
DEFAULT_MOONSHOT_BASE_URL,
DEFAULT_MOONSHOT_MODEL,
["MOONSHOT_API_KEY", "KIMI_API_KEY"],
"moonshot",
// `moonshotai` is the id Models.dev publishes for Moonshot/Kimi; without
// it a live/full Models.dev catalog row keyed `moonshotai` would fail to
// normalize onto ProviderKind::Moonshot (Refs #4186).
aliases: ["moonshot-ai", "moonshotai", "moonshot_ai", "kimi", "kimi-k2"]
);
provider!(
Sglang,
Sglang,
"sglang",
"SGLang",
DEFAULT_SGLANG_BASE_URL,
DEFAULT_SGLANG_MODEL,
["SGLANG_API_KEY"],
"sglang",
aliases: ["sg-lang"]
);
provider!(
Vllm,
Vllm,
"vllm",
"vLLM",
DEFAULT_VLLM_BASE_URL,
DEFAULT_VLLM_MODEL,
["VLLM_API_KEY"],
"vllm",
aliases: ["v-llm"]
);
provider!(
Ollama,
Ollama,
"ollama",
"Ollama",
DEFAULT_OLLAMA_BASE_URL,
DEFAULT_OLLAMA_MODEL,
["OLLAMA_API_KEY"],
"ollama",
aliases: ["ollama-local"]
);
provider!(
Huggingface,
Huggingface,
"huggingface",
"Hugging Face",
DEFAULT_HUGGINGFACE_BASE_URL,
DEFAULT_HUGGINGFACE_MODEL,
["HUGGINGFACE_API_KEY", "HF_TOKEN"],
"huggingface",
aliases: ["hugging-face", "hugging_face", "hf"]
);
provider!(
Together,
Together,
"together",
"Together AI",
DEFAULT_TOGETHER_BASE_URL,
DEFAULT_TOGETHER_MODEL,
["TOGETHER_API_KEY"],
"together",
// `togetherai` (no separator) is the id Models.dev publishes for Together;
// the hyphen/underscore spellings are legacy config aliases. All three must
// normalize onto ProviderKind::Together so live-catalog rows keyed
// `togetherai` resolve to the right kind (Refs #4186).
aliases: ["together-ai", "together_ai", "togetherai"]
);
provider!(
Qianfan,
Qianfan,
"qianfan",
"Baidu Qianfan",
DEFAULT_QIANFAN_BASE_URL,
DEFAULT_QIANFAN_MODEL,
["QIANFAN_API_KEY", "BAIDU_QIANFAN_API_KEY"],
"qianfan",
aliases: ["baidu-qianfan", "baidu_qianfan", "baidu"]
);
/// OpenAI Codex / ChatGPT OAuth provider using the Responses API.
pub struct OpenaiCodex;
impl Provider for OpenaiCodex {
fn id(&self) -> &'static str {
"openai-codex"
}
fn kind(&self) -> ProviderKind {
ProviderKind::OpenaiCodex
}
fn display_name(&self) -> &'static str {
"OpenAI Codex (ChatGPT)"
}
fn default_base_url(&self) -> &'static str {
DEFAULT_OPENAI_CODEX_BASE_URL
}
fn default_model(&self) -> &'static str {
DEFAULT_OPENAI_CODEX_MODEL
}
fn env_vars(&self) -> &'static [&'static str] {
&["OPENAI_CODEX_ACCESS_TOKEN", "CODEX_ACCESS_TOKEN"]
}
fn provider_config_key(&self) -> &'static str {
"openai_codex"
}
fn aliases(&self) -> &'static [&'static str] {
&[
"openai_codex",
"openaicodex",
"codex",
"chatgpt",
"chatgpt-codex",
"chatgpt_codex",
"chatgptcodex",
]
}
fn wire(&self) -> WireFormat {
WireFormat::Responses
}
}
/// Native Anthropic Messages API provider (#3014).
pub struct Anthropic;
impl Provider for Anthropic {
fn id(&self) -> &'static str {
"anthropic"
}
fn kind(&self) -> ProviderKind {
ProviderKind::Anthropic
}
fn display_name(&self) -> &'static str {
"Anthropic"
}
fn default_base_url(&self) -> &'static str {
crate::DEFAULT_ANTHROPIC_BASE_URL
}
fn default_model(&self) -> &'static str {
crate::DEFAULT_ANTHROPIC_MODEL
}
fn env_vars(&self) -> &'static [&'static str] {
&["ANTHROPIC_API_KEY"]
}
fn provider_config_key(&self) -> &'static str {
"anthropic"
}
fn wire(&self) -> WireFormat {
WireFormat::AnthropicMessages
}
}
/// OpenModel Anthropic-compatible Messages API provider.
pub struct Openmodel;
impl Provider for Openmodel {
fn id(&self) -> &'static str {
"openmodel"
}
fn kind(&self) -> ProviderKind {
ProviderKind::Openmodel
}
fn display_name(&self) -> &'static str {
"OpenModel"
}
fn default_base_url(&self) -> &'static str {
DEFAULT_OPENMODEL_BASE_URL
}
fn default_model(&self) -> &'static str {
DEFAULT_OPENMODEL_MODEL
}
fn env_vars(&self) -> &'static [&'static str] {
&["OPENMODEL_API_KEY"]
}
fn provider_config_key(&self) -> &'static str {
"openmodel"
}
fn aliases(&self) -> &'static [&'static str] {
&["open-model", "open_model"]
}
fn wire(&self) -> WireFormat {
WireFormat::AnthropicMessages
}
}
provider!(
Zai,
Zai,
"zai",
"Zhipu AI / Z.ai",
DEFAULT_ZAI_BASE_URL,
DEFAULT_ZAI_MODEL,
["ZAI_API_KEY", "Z_AI_API_KEY", "ZHIPU_API_KEY", "GLM_API_KEY"],
"zai",
aliases: ["z-ai", "z_ai", "z.ai", "zhipu", "zhipuai", "bigmodel", "big-model"]
);
provider!(
Stepfun,
Stepfun,
"stepfun",
"StepFun / StepFlash",
DEFAULT_STEPFUN_BASE_URL,
DEFAULT_STEPFUN_MODEL,
["STEPFUN_API_KEY", "STEP_API_KEY"],
"stepfun",
aliases: ["step-fun", "step_fun", "stepflash", "step-flash", "step_flash"]
);
provider!(
Minimax,
Minimax,
"minimax",
"MiniMax",
DEFAULT_MINIMAX_BASE_URL,
DEFAULT_MINIMAX_MODEL,
["MINIMAX_API_KEY"],
"minimax",
aliases: ["mini-max", "mini_max"]
);
provider!(
Deepinfra,
Deepinfra,
"deepinfra",
"DeepInfra",
DEFAULT_DEEPINFRA_BASE_URL,
DEFAULT_DEEPINFRA_MODEL,
["DEEPINFRA_API_KEY", "DEEPINFRA_TOKEN"],
"deepinfra",
aliases: ["deep-infra", "deep_infra"]
);
provider!(
Sakana,
Sakana,
"sakana",
"Sakana AI (Fugu)",
DEFAULT_SAKANA_BASE_URL,
DEFAULT_SAKANA_MODEL,
["FUGU_API_KEY", "SAKANA_API_KEY"],
"sakana",
aliases: ["sakana-ai", "sakana_ai", "fugu"]
);
provider!(
LongCat,
LongCat,
"longcat",
"Meituan LongCat",
DEFAULT_LONGCAT_BASE_URL,
DEFAULT_LONGCAT_MODEL,
["LONGCAT_API_KEY"],
"longcat",
aliases: ["long-cat", "meituan-longcat", "meituan"]
);
provider!(
Meta,
Meta,
"meta",
"Meta Model API",
DEFAULT_META_BASE_URL,
DEFAULT_META_MODEL,
["META_MODEL_API_KEY", "MODEL_API_KEY"],
"meta",
aliases: [
"meta-ai",
"meta_ai",
"meta-model-api",
"meta_model_api",
"muse",
"muse-spark"
]
);
provider!(
Xai,
Xai,
"xai",
"xAI",
DEFAULT_XAI_BASE_URL,
DEFAULT_XAI_MODEL,
["XAI_API_KEY"],
"xai",
aliases: ["x-ai", "x_ai", "grok"]
);
/// User-defined OpenAI-compatible endpoint (#1519).
///
/// A single dynamic provider identity for arbitrary `[providers.<name>]
/// kind="openai-compatible"` config entries. Unlike the built-in providers it
/// carries no real default base URL/model/env var: the concrete endpoint, model
/// id, and auth env var all arrive from the named `[providers.<name>]` config
/// table at route time. The placeholder base URL/model here exist only so the
/// descriptor stays well-formed (non-empty) for conformance; runtime routing
/// always supplies a `base_url_override` and a wire model id, so these
/// placeholders are never used to reach the network.
pub struct Custom;
impl Provider for Custom {
fn id(&self) -> &'static str {
"custom"
}
fn kind(&self) -> ProviderKind {
ProviderKind::Custom
}
fn display_name(&self) -> &'static str {
"Custom (OpenAI-compatible)"
}
fn default_base_url(&self) -> &'static str {
// Placeholder only; the real endpoint comes from the named config table
// via the route's base_url_override. Loopback so a misconfigured custom
// provider fails closed locally rather than reaching a public host.
"http://localhost/v1"
}
fn default_model(&self) -> &'static str {
// Placeholder only; the real model id comes from config and is preserved
// verbatim as the wire model id.
"custom-model"
}
fn env_vars(&self) -> &'static [&'static str] {
// No built-in env var: the auth env var is named per-entry via
// `[providers.<name>] api_key_env = "..."`.
&[]
}
fn provider_config_key(&self) -> &'static str {
"custom"
}
fn wire(&self) -> WireFormat {
WireFormat::ChatCompletions
}
}
static DEEPSEEK: Deepseek = Deepseek;
static DEEPSEEK_ANTHROPIC: DeepseekAnthropic = DeepseekAnthropic;
static NVIDIA_NIM: NvidiaNim = NvidiaNim;
static OPENAI: Openai = Openai;
static ATLASCLOUD: Atlascloud = Atlascloud;
static WANJIE_ARK: WanjieArk = WanjieArk;
static VOLCENGINE: Volcengine = Volcengine;
static OPENROUTER: Openrouter = Openrouter;
static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo;
static NOVITA: Novita = Novita;
static FIREWORKS: Fireworks = Fireworks;
static SILICONFLOW: Siliconflow = Siliconflow;
static SILICONFLOW_CN: SiliconflowCN = SiliconflowCN;
static ARCEE: Arcee = Arcee;
static MOONSHOT: Moonshot = Moonshot;
static SGLANG: Sglang = Sglang;
static VLLM: Vllm = Vllm;
static OLLAMA: Ollama = Ollama;
static HUGGINGFACE: Huggingface = Huggingface;
static TOGETHER: Together = Together;
static QIANFAN: Qianfan = Qianfan;
static OPENAI_CODEX: OpenaiCodex = OpenaiCodex;
static ANTHROPIC: Anthropic = Anthropic;
static OPENMODEL: Openmodel = Openmodel;
static ZAI: Zai = Zai;
static STEPFUN: Stepfun = Stepfun;
static MINIMAX: Minimax = Minimax;
static DEEPINFRA: Deepinfra = Deepinfra;
static SAKANA: Sakana = Sakana;
static LONGCAT: LongCat = LongCat;
static META: Meta = Meta;
static XAI: Xai = Xai;
static CUSTOM: Custom = Custom;
static PROVIDER_REGISTRY: [&dyn Provider; 33] = [
&DEEPSEEK,
&DEEPSEEK_ANTHROPIC,
&NVIDIA_NIM,
&OPENAI,
&ATLASCLOUD,
&WANJIE_ARK,
&VOLCENGINE,
&OPENROUTER,
&XIAOMI_MIMO,
&NOVITA,
&FIREWORKS,
&SILICONFLOW,
&ARCEE,
&SILICONFLOW_CN,
&MOONSHOT,
&SGLANG,
&VLLM,
&OLLAMA,
&HUGGINGFACE,
&TOGETHER,
&QIANFAN,
&OPENAI_CODEX,
&ANTHROPIC,
&OPENMODEL,
&ZAI,
&STEPFUN,
&MINIMAX,
&DEEPINFRA,
&SAKANA,
&LONGCAT,
&META,
&XAI,
&CUSTOM,
];
/// Return all built-in provider metadata entries in `ProviderKind::ALL` order.
///
/// This insertion order is the stable order used for internal parsing and
/// default selection. It is intentionally NOT the order user-facing UI should
/// render; for browsing/picker surfaces use [`providers_sorted_for_display`].
#[must_use]
pub fn all_providers() -> &'static [&'static dyn Provider] {
&PROVIDER_REGISTRY
}
/// Return all built-in providers ordered for user-facing display.
///
/// Providers are sorted alphabetically (case-insensitively) by
/// [`Provider::display_name`] so model/provider browsing surfaces present a
/// neutral, predictable list rather than leading with whichever provider
/// happens to sit first in [`ProviderKind::ALL`] (historically DeepSeek). The
/// ordering policy intentionally differs from internal parsing/default order:
///
/// - [`all_providers`] / [`ProviderKind::ALL`] — stable order for internal
/// matching, parsing, and default selection. Do not reorder.
/// - [`providers_sorted_for_display`] — neutral alphabetical order for UI
/// browsing. DeepSeek stays present and searchable but is not hard-coded
/// first; a caller may still highlight/pin the active provider separately.
///
/// Returns an owned `Vec` because the sorted order is computed, not static.
#[must_use]
pub fn providers_sorted_for_display() -> Vec<&'static dyn Provider> {
let mut providers = all_providers().to_vec();
providers.sort_by(|a, b| {
a.display_name()
.to_ascii_lowercase()
.cmp(&b.display_name().to_ascii_lowercase())
});
providers
}
/// Find a provider by canonical id only.
#[must_use]
pub fn lookup_provider(id: &str) -> Option<&'static dyn Provider> {
let id = id.trim();
all_providers()
.iter()
.copied()
.find(|provider| provider.id() == id)
}
/// Resolve a provider by canonical id or supported legacy alias.
#[must_use]
pub fn resolve_provider(id_or_alias: &str) -> Option<&'static dyn Provider> {
ProviderKind::parse(id_or_alias).map(provider_for_kind)
}
/// Return metadata for a known provider kind.
#[must_use]
pub fn provider_for_kind(kind: ProviderKind) -> &'static dyn Provider {
PROVIDER_REGISTRY
.iter()
.find(|p| p.kind() == kind)
.copied()
.expect("ProviderKind variant missing from PROVIDER_REGISTRY")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn display_order_is_alphabetical_by_display_name() {
let display = providers_sorted_for_display();
let names: Vec<String> = display
.iter()
.map(|p| p.display_name().to_ascii_lowercase())
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(
names, sorted,
"providers_sorted_for_display must be alphabetical (case-insensitive) by display name"
);
}
#[test]
fn display_order_differs_from_internal_all_order() {
// The whole point of the helper is that UI ordering is NOT the
// internal ProviderKind::ALL / all_providers() insertion order.
let display_ids: Vec<&str> = providers_sorted_for_display()
.iter()
.map(|p| p.id())
.collect();
let internal_ids: Vec<&str> = all_providers().iter().map(|p| p.id()).collect();
assert_ne!(
display_ids, internal_ids,
"display order should not match internal ALL order"
);
}
#[test]
fn display_order_is_complete_and_unique() {
// No provider is dropped or duplicated by the sort.
let display = providers_sorted_for_display();
assert_eq!(
display.len(),
all_providers().len(),
"display order must include every built-in provider"
);
let mut ids: Vec<&str> = display.iter().map(|p| p.id()).collect();
ids.sort_unstable();
let before = ids.len();
ids.dedup();
assert_eq!(
before,
ids.len(),
"display order must not contain duplicates"
);
}
#[test]
fn deepseek_is_present_but_not_first_in_display_order() {
// Acceptance: DeepSeek stays searchable but is no longer hard-coded
// first in provider browsing UI. (It is first in internal ALL order.)
let display = providers_sorted_for_display();
assert_eq!(
all_providers()[0].kind(),
ProviderKind::Deepseek,
"DeepSeek is expected to remain first in the stable internal order"
);
assert!(
display.iter().any(|p| p.kind() == ProviderKind::Deepseek),
"DeepSeek must remain present in display order"
);
assert_ne!(
display[0].kind(),
ProviderKind::Deepseek,
"DeepSeek must not be hard-coded first in display order"
);
// Anthropic ('Anthropic') sorts before 'DeepSeek' alphabetically, so it
// is a stable check that the neutral ordering actually took effect.
assert_eq!(
display[0].display_name(),
"Anthropic",
"alphabetical display order should lead with Anthropic"
);
}
}
+141
View File
@@ -0,0 +1,141 @@
//! Built-in provider default seeds: per-provider default model ids and
//! base URLs, plus the named model/tier constants the alias-normalization
//! tables resolve to. Extracted verbatim from `lib.rs` (#3311) to separate
//! these provider execution defaults from config schema/loading code; values
//! are unchanged. Re-exported `pub(crate)` at the crate root so existing
//! `crate::DEFAULT_*` references keep resolving.
pub(crate) const DEFAULT_DEEPSEEK_MODEL: &str = "deepseek-v4-pro";
pub(crate) const DEFAULT_DEEPSEEK_ANTHROPIC_MODEL: &str = DEFAULT_DEEPSEEK_MODEL;
pub(crate) const DEFAULT_NVIDIA_NIM_MODEL: &str = "deepseek-ai/deepseek-v4-pro";
pub(crate) const DEFAULT_NVIDIA_NIM_FLASH_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
pub(crate) const DEFAULT_OPENAI_MODEL: &str = "deepseek-v4-pro";
pub(crate) const DEFAULT_DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/beta";
pub(crate) const DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL: &str = "https://api.deepseek.com/anthropic";
pub(crate) const DEFAULT_NVIDIA_NIM_BASE_URL: &str = "https://integrate.api.nvidia.com/v1";
pub(crate) const DEFAULT_OPENAI_CODEX_MODEL: &str = "gpt-5.5";
pub(crate) const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
pub(crate) const DEFAULT_ANTHROPIC_BASE_URL: &str = "https://api.anthropic.com";
pub(crate) const DEFAULT_OPENMODEL_MODEL: &str = "deepseek-v4-flash";
pub(crate) const DEFAULT_OPENMODEL_BASE_URL: &str = "https://api.openmodel.ai";
pub(crate) const DEFAULT_OPENAI_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api";
pub(crate) const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1";
pub(crate) const DEFAULT_ATLASCLOUD_MODEL: &str = "deepseek-ai/deepseek-v4-flash";
pub(crate) const DEFAULT_ATLASCLOUD_BASE_URL: &str = "https://api.atlascloud.ai/v1";
pub(crate) const DEFAULT_WANJIE_ARK_MODEL: &str = "deepseek-reasoner";
pub(crate) const DEFAULT_WANJIE_ARK_BASE_URL: &str = "https://maas-openapi.wanjiedata.com/api/v1";
pub(crate) const DEFAULT_VOLCENGINE_MODEL: &str = "DeepSeek-V4-Pro";
pub(crate) const DEFAULT_VOLCENGINE_BASE_URL: &str =
"https://ark.cn-beijing.volces.com/api/coding/v3";
pub(crate) const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
pub(crate) const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str =
"arcee-ai/trinity-large-thinking";
pub(crate) const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
pub(crate) const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
pub(crate) const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
pub(crate) const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
pub(crate) const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
pub(crate) const OPENROUTER_KIMI_K2_6_MODEL: &str = "moonshotai/kimi-k2.6";
pub(crate) const OPENROUTER_MINIMAX_M3_MODEL: &str = "minimax/minimax-m3";
pub(crate) const OPENROUTER_MINIMAX_M2_7_MODEL: &str = "minimax/minimax-m2.7";
pub(crate) const OPENROUTER_NEMOTRON_3_NANO_OMNI_MODEL: &str =
"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free";
pub(crate) const OPENROUTER_QWEN_3_6_FLASH_MODEL: &str = "qwen/qwen3.6-flash";
pub(crate) const OPENROUTER_QWEN_3_6_35B_A3B_MODEL: &str = "qwen/qwen3.6-35b-a3b";
pub(crate) const OPENROUTER_QWEN_3_6_MAX_PREVIEW_MODEL: &str = "qwen/qwen3.6-max-preview";
pub(crate) const OPENROUTER_QWEN_3_6_27B_MODEL: &str = "qwen/qwen3.6-27b";
pub(crate) const OPENROUTER_QWEN_3_6_PLUS_MODEL: &str = "qwen/qwen3.6-plus";
pub(crate) const OPENROUTER_QWEN_3_7_MAX_MODEL: &str = "qwen/qwen3.7-max";
pub(crate) const OPENROUTER_TENCENT_HY3_PREVIEW_MODEL: &str = "tencent/hy3-preview";
pub(crate) const OPENROUTER_XIAOMI_MIMO_V2_5_PRO_MODEL: &str = "xiaomi/mimo-v2.5-pro";
pub(crate) const OPENROUTER_XIAOMI_MIMO_V2_5_MODEL: &str = "xiaomi/mimo-v2.5";
pub(crate) const DEFAULT_XIAOMI_MIMO_MODEL: &str = "mimo-v2.5-pro";
pub(crate) const XIAOMI_MIMO_V2_5_PRO_ULTRASPEED_MODEL: &str = "mimo-v2.5-pro-ultraspeed";
pub(crate) const XIAOMI_MIMO_V2_5_OMNI_MODEL: &str = "mimo-v2.5";
pub(crate) const XIAOMI_MIMO_ASR_MODEL: &str = "mimo-v2.5-asr";
pub(crate) const XIAOMI_MIMO_TTS_MODEL: &str = "mimo-v2.5-tts";
pub(crate) const XIAOMI_MIMO_TTS_VOICE_DESIGN_MODEL: &str = "mimo-v2.5-tts-voicedesign";
pub(crate) const XIAOMI_MIMO_TTS_VOICE_CLONE_MODEL: &str = "mimo-v2.5-tts-voiceclone";
pub(crate) const XIAOMI_MIMO_V2_TTS_MODEL: &str = "mimo-v2-tts";
pub(crate) const DEFAULT_NOVITA_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_NOVITA_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
pub(crate) const DEFAULT_FIREWORKS_MODEL: &str = "accounts/fireworks/models/deepseek-v4-pro";
pub(crate) const DEFAULT_SILICONFLOW_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_SILICONFLOW_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_ARCEE_MODEL: &str = "trinity-large-thinking";
pub(crate) const ARCEE_TRINITY_LARGE_PREVIEW_MODEL: &str = "trinity-large-preview";
pub(crate) const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini";
pub(crate) const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code";
pub(crate) const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6";
pub(crate) const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1";
pub(crate) const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding";
pub(crate) const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1";
pub(crate) const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
pub(crate) const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
pub(crate) const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
pub(crate) const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str =
"https://token-plan-cn.xiaomimimo.com/v1";
pub(crate) const XIAOMI_MIMO_TOKEN_PLAN_SGP_BASE_URL: &str = DEFAULT_XIAOMI_MIMO_BASE_URL;
pub(crate) const XIAOMI_MIMO_TOKEN_PLAN_AMS_BASE_URL: &str =
"https://token-plan-ams.xiaomimimo.com/v1";
pub(crate) const DEFAULT_NOVITA_BASE_URL: &str = "https://api.novita.ai/openai/v1";
pub(crate) const DEFAULT_FIREWORKS_BASE_URL: &str = "https://api.fireworks.ai/inference/v1";
pub(crate) const DEFAULT_SILICONFLOW_BASE_URL: &str = "https://api.siliconflow.com/v1";
pub(crate) const DEFAULT_SILICONFLOW_CN_BASE_URL: &str = "https://api.siliconflow.cn/v1";
pub(crate) const DEFAULT_ARCEE_BASE_URL: &str = "https://api.arcee.ai/api/v1";
pub(crate) const DEFAULT_HUGGINGFACE_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_HUGGINGFACE_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_HUGGINGFACE_BASE_URL: &str = "https://router.huggingface.co/v1";
pub(crate) const DEFAULT_TOGETHER_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_TOGETHER_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_TOGETHER_BASE_URL: &str = "https://api.together.xyz/v1";
pub(crate) const DEFAULT_QIANFAN_MODEL: &str = "ernie-4.0-turbo-8k";
pub(crate) const DEFAULT_QIANFAN_BASE_URL: &str = "https://api.baiduqianfan.ai/v1";
pub(crate) const DEFAULT_SGLANG_BASE_URL: &str = "http://localhost:30000/v1";
pub(crate) const DEFAULT_VLLM_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "deepseek-v4-flash";
pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
// Z.ai (GLM Coding Plan) defaults
pub(crate) const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
pub(crate) const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
// GLM-5.2 is both the default and a named tier; the alias arm resolves the
// `glm-5.2` spelling to DEFAULT_ZAI_MODEL directly, so this constant is
// referenced only in cfg(test) assertions (see tests.rs).
#[allow(dead_code)]
pub(crate) const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
pub(crate) const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
pub(crate) const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
// StepFun / StepFlash defaults
pub(crate) const DEFAULT_STEPFUN_MODEL: &str = "step-3.7-flash";
pub(crate) const DEFAULT_STEPFUN_BASE_URL: &str = "https://api.stepfun.ai/v1";
// MiniMax defaults
pub(crate) const DEFAULT_MINIMAX_MODEL: &str = "MiniMax-M3";
pub(crate) const MINIMAX_M2_7_MODEL: &str = "MiniMax-M2.7";
pub(crate) const MINIMAX_M2_7_HIGHSPEED_MODEL: &str = "MiniMax-M2.7-highspeed";
pub(crate) const MINIMAX_M2_5_MODEL: &str = "MiniMax-M2.5";
pub(crate) const MINIMAX_M2_5_HIGHSPEED_MODEL: &str = "MiniMax-M2.5-highspeed";
pub(crate) const MINIMAX_M2_1_MODEL: &str = "MiniMax-M2.1";
pub(crate) const MINIMAX_M2_1_HIGHSPEED_MODEL: &str = "MiniMax-M2.1-highspeed";
pub(crate) const MINIMAX_M2_MODEL: &str = "MiniMax-M2";
pub(crate) const DEFAULT_MINIMAX_BASE_URL: &str = "https://api.minimax.io/v1";
pub(crate) const DEFAULT_DEEPINFRA_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_DEEPINFRA_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_DEEPINFRA_BASE_URL: &str = "https://api.deepinfra.com/v1/openai";
// Sakana AI Fugu defaults
pub(crate) const DEFAULT_SAKANA_MODEL: &str = "fugu";
pub(crate) const DEFAULT_SAKANA_BASE_URL: &str = "https://api.sakana.ai/v1";
// Meituan LongCat defaults
pub(crate) const DEFAULT_LONGCAT_MODEL: &str = "LongCat-2.0";
pub(crate) const DEFAULT_LONGCAT_BASE_URL: &str = "https://api.longcat.chat/openai/v1";
// Meta Model API / Muse Spark defaults
pub(crate) const DEFAULT_META_MODEL: &str = "muse-spark-1.1";
pub(crate) const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1";
// xAI / Grok API-key route defaults
pub(crate) const DEFAULT_XAI_MODEL: &str = "grok-4.5";
pub(crate) const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
+210
View File
@@ -0,0 +1,210 @@
//! The canonical [`ProviderKind`] enum (#3311): the set of built-in provider
//! kinds, their serde aliases, and identity helpers (`all`, `as_str`, `parse`,
//! `provider`). Extracted verbatim from `lib.rs` to separate provider identity
//! from config schema/loading; re-exported at the crate root so
//! `codewhale_config::ProviderKind` is unchanged. Behavior is identical.
use serde::{Deserialize, Serialize};
use crate::provider;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum ProviderKind {
#[default]
#[serde(
alias = "deepseek-cn",
alias = "deepseek_china",
alias = "deepseekcn",
alias = "deepseek-china"
)]
Deepseek,
#[serde(
alias = "deepseek-anthropic",
alias = "deepseek_anthropic",
alias = "deepseek-claude",
alias = "deepseek_claude"
)]
DeepseekAnthropic,
NvidiaNim,
#[serde(alias = "open-ai")]
Openai,
Atlascloud,
#[serde(
alias = "wanjie",
alias = "wanjie_ark",
alias = "ark-wanjie",
alias = "ark_wanjie",
alias = "wanjie-maas",
alias = "wanjie_maas"
)]
WanjieArk,
#[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
Volcengine,
Openrouter,
#[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
XiaomiMimo,
#[serde(alias = "novita-ai", alias = "novita_ai")]
Novita,
#[serde(alias = "fireworks-ai", alias = "fireworks_ai")]
Fireworks,
#[serde(alias = "silicon-flow", alias = "silicon_flow")]
Siliconflow,
#[serde(alias = "arcee-ai", alias = "arcee_ai")]
Arcee,
#[serde(alias = "siliconflow-cn", alias = "siliconflow-CN")]
SiliconflowCN,
#[serde(alias = "moonshot-ai", alias = "moonshotai", alias = "moonshot_ai")]
Moonshot,
Sglang,
Vllm,
Ollama,
#[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")]
Huggingface,
#[serde(alias = "together-ai", alias = "together_ai", alias = "togetherai")]
Together,
#[serde(alias = "baidu-qianfan", alias = "baidu_qianfan", alias = "baidu")]
Qianfan,
#[serde(
alias = "openai-codex",
alias = "openai_codex",
alias = "codex",
alias = "chatgpt",
alias = "chatgpt-codex",
alias = "chatgpt_codex"
)]
OpenaiCodex,
#[serde(alias = "claude")]
Anthropic,
#[serde(alias = "open-model", alias = "open_model")]
Openmodel,
#[serde(
alias = "z-ai",
alias = "z_ai",
alias = "z.ai",
alias = "zhipu",
alias = "zhipuai",
alias = "bigmodel",
alias = "big-model"
)]
Zai,
#[serde(
alias = "step-fun",
alias = "step_fun",
alias = "stepfun",
alias = "stepflash",
alias = "step-flash",
alias = "step_flash"
)]
Stepfun,
#[serde(alias = "mini-max", alias = "mini_max", alias = "minimax")]
Minimax,
#[serde(alias = "deep-infra", alias = "deep_infra")]
Deepinfra,
#[serde(alias = "sakana-ai", alias = "sakana_ai", alias = "fugu")]
Sakana,
#[serde(alias = "long-cat", alias = "meituan-longcat", alias = "meituan")]
LongCat,
#[serde(
alias = "meta-ai",
alias = "meta_ai",
alias = "meta-model-api",
alias = "meta_model_api",
alias = "muse",
alias = "muse-spark"
)]
Meta,
#[serde(alias = "x-ai", alias = "x_ai", alias = "grok")]
Xai,
/// User-defined OpenAI-compatible endpoint (#1519).
///
/// A single dynamic identity for arbitrary `[providers.<name>]
/// kind="openai-compatible"` entries. It speaks the OpenAI Chat Completions
/// wire protocol and carries no built-in base URL/model — the concrete
/// endpoint and model arrive via config (`base_url` / `model`) and the
/// route's `base_url_override`, never from this static descriptor.
Custom,
}
impl ProviderKind {
pub const ALL: [Self; 33] = [
Self::Deepseek,
Self::DeepseekAnthropic,
Self::NvidiaNim,
Self::Openai,
Self::Atlascloud,
Self::WanjieArk,
Self::Volcengine,
Self::Openrouter,
Self::XiaomiMimo,
Self::Novita,
Self::Fireworks,
Self::Siliconflow,
Self::Arcee,
Self::SiliconflowCN,
Self::Moonshot,
Self::Sglang,
Self::Vllm,
Self::Ollama,
Self::Huggingface,
Self::Together,
Self::Qianfan,
Self::OpenaiCodex,
Self::Anthropic,
Self::Openmodel,
Self::Zai,
Self::Stepfun,
Self::Minimax,
Self::Deepinfra,
Self::Sakana,
Self::LongCat,
Self::Meta,
Self::Xai,
Self::Custom,
];
#[must_use]
pub fn all() -> &'static [Self] {
&Self::ALL
}
#[must_use]
pub fn names_hint() -> String {
Self::all()
.iter()
.map(|provider| provider.as_str())
.collect::<Vec<_>>()
.join(", ")
}
#[must_use]
pub fn as_str(self) -> &'static str {
self.provider().id()
}
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
let trimmed = value.trim();
provider::all_providers()
.iter()
.find(|p| {
trimmed.eq_ignore_ascii_case(p.id())
|| p.aliases().iter().any(|a| trimmed.eq_ignore_ascii_case(a))
})
.map(|p| p.kind())
}
#[must_use]
pub fn is_siliconflow(self) -> bool {
matches!(self, Self::Siliconflow | Self::SiliconflowCN)
}
/// Return the built-in metadata entry for this provider.
///
/// This is a metadata foundation only; runtime routing still resolves
/// through [`crate::ConfigToml::resolve_runtime_options`].
#[must_use]
pub fn provider(self) -> &'static dyn provider::Provider {
provider::provider_for_kind(self)
}
}
+170
View File
@@ -0,0 +1,170 @@
//! The runtime-resolved executable route (#3384).
//!
//! A [`ReadyRouteCandidate`] is the concrete form of the #2608 contract:
//!
//! > Execution requires a `ReadyRouteCandidate`.
//! > A `ReadyRouteCandidate` can only be produced by `RouteResolver`.
//!
//! Fields are pub-*read*, but the type cannot be *constructed* outside this
//! crate: the struct is `#[non_exhaustive]` (no other crate can build it via a
//! struct literal) and deliberately does not derive `Deserialize` (so it cannot
//! be fabricated from JSON either). The only constructor is
//! [`ReadyRouteCandidate::new`]
//! (`pub(super)`), and [`super::resolver::RouteResolver::resolve`] is its sole
//! caller. A candidate's existence is therefore proof it passed the resolver.
//!
//! DEFERRED: #3384's full sketch also carried `capabilities: CapabilityProfile`
//! and `config_snapshot: Config`. Both are intentionally omitted here: pulling
//! `CapabilityProfile` into `crates/config` would force a `tui -> config` type
//! move, and embedding `Config` would couple the candidate to the full config
//! model. They will be added when those types have a home in this crate.
use serde::{Deserialize, Serialize};
use super::RequestProtocol;
use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId};
use super::offering::RouteLimits;
use crate::ProviderKind;
/// A concrete, resolved endpoint the route will talk to.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedEndpoint {
/// Resolved base URL (after any override).
pub base_url: String,
/// Endpoint key (e.g. `"chat"`, `"responses"`).
pub endpoint_key: String,
/// Wire protocol spoken at this endpoint.
pub protocol: RequestProtocol,
}
/// The CLASS of auth source resolved for the route.
///
/// This records only *where* a credential comes from, never the credential
/// value itself. There is intentionally no field that could hold a secret.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ResolvedAuthSource {
/// Supplied via CLI flag/argument.
Cli,
/// Read from a config file.
ConfigFile,
/// Read from the OS keyring.
Keyring,
/// Read from an environment variable.
Env,
/// Produced by running a command.
Command,
/// Resolved from a named secret.
Secret,
/// No credential resolved.
Missing,
}
/// Pricing/quota class for the resolved route.
///
/// Carries only coarse, non-sensitive shape; never secrets or account ids.
///
/// `PartialEq` (but not `Eq`: the `Token` rates are `f64`) lets offerings and
/// candidates be compared in tests and lets
/// [`super::offering::ProviderModelOffering`] carry a pricing meter.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PricingSku {
/// Per-token pricing.
Token {
/// Input price per million tokens, if known.
input_per_mtok: Option<f64>,
/// Output price per million tokens, if known.
output_per_mtok: Option<f64>,
},
/// Subscription quota usage.
SubscriptionQuota {
/// Percent of quota used, if known.
used_pct: Option<f32>,
/// When the quota resets, if known.
resets_at: Option<String>,
},
/// Prepaid account credits.
AccountCredits {
/// Remaining balance, if known.
balance: Option<f64>,
},
/// Local or otherwise not billed.
LocalOrNotApplicable,
/// Pricing unknown or stale.
UnknownOrStale,
}
/// Outcome of route validation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationReport {
/// Whether the route passed validation.
pub ok: bool,
/// Human-readable diagnostics (advisory; secret-free).
pub messages: Vec<String>,
}
/// A runtime-resolved, executable route.
///
/// Fields are read-only to callers; the type cannot be constructed outside this
/// crate (`#[non_exhaustive]` + no `Deserialize`). The only constructor is
/// [`Self::new`], which is `pub(super)`; see module docs.
#[derive(Debug, Clone, Serialize)]
#[non_exhaustive]
pub struct ReadyRouteCandidate {
/// Resolved provider id.
pub provider_id: ProviderId,
/// Resolved provider kind.
pub provider_kind: ProviderKind,
/// The selector the user/route requested.
pub logical_model: LogicalModelRef,
/// Canonical model identity, if one was resolved.
pub canonical_model: Option<ModelId>,
/// Provider-owned wire id put on the request.
pub wire_model_id: WireModelId,
/// Resolved endpoint transport facts.
pub endpoint: ResolvedEndpoint,
/// Resolved auth source CLASS (never a secret value).
pub auth: ResolvedAuthSource,
/// Selected wire protocol.
pub protocol: RequestProtocol,
/// Route/offering-scoped token limits, when known.
pub limits: RouteLimits,
/// Pricing/quota class, if known.
pub pricing: Option<PricingSku>,
/// Validation outcome.
pub validation: ValidationReport,
}
impl ReadyRouteCandidate {
/// Mint a candidate. Restricted to [`super::resolver`] so the resolver is
/// the sole producer of executable routes (the #2608 mutation gate).
#[allow(clippy::too_many_arguments)]
pub(super) fn new(
provider_id: ProviderId,
provider_kind: ProviderKind,
logical_model: LogicalModelRef,
canonical_model: Option<ModelId>,
wire_model_id: WireModelId,
endpoint: ResolvedEndpoint,
auth: ResolvedAuthSource,
protocol: RequestProtocol,
limits: RouteLimits,
pricing: Option<PricingSku>,
validation: ValidationReport,
) -> Self {
Self {
provider_id,
provider_kind,
logical_model,
canonical_model,
wire_model_id,
endpoint,
auth,
protocol,
limits,
pricing,
validation,
}
}
}
@@ -0,0 +1,135 @@
//! Provider descriptor conformance (#3084).
//!
//! These tests assert that *every* shipped `ProviderKind` has a well-formed
//! route-facing descriptor and resolves a default route — so adding a provider
//! without wiring its descriptor/resolver behavior fails CI here rather than at
//! runtime. They are intentionally data-driven over [`ProviderKind::all`] and
//! network-free; provider execution/adapter behavior is exercised elsewhere.
use super::bundled_offerings;
use super::descriptor::ProviderDescriptor;
use super::ids::{LogicalModelRef, ProviderId};
use super::resolver::{RouteRequest, RouteResolver};
use crate::ProviderKind;
fn none_request(kind: ProviderKind) -> RouteRequest {
RouteRequest {
explicit_provider: Some(kind),
model_selector: None,
saved_provider_model: None,
base_url_override: None,
}
}
#[test]
fn every_provider_kind_has_a_wellformed_descriptor() {
for &kind in ProviderKind::all() {
let descriptor = ProviderDescriptor::for_kind(kind);
// The descriptor id is non-empty and agrees with the canonical mapping;
// a mismatch means a provider was added to one table but not the other.
assert!(
!descriptor.id().as_str().trim().is_empty(),
"{kind:?}: empty provider id"
);
assert_eq!(
descriptor.id(),
ProviderId::from_kind(kind),
"{kind:?}: descriptor id disagrees with ProviderId::from_kind"
);
// Transport facts route resolution depends on must be present.
assert!(
!descriptor.default_wire_model().as_str().trim().is_empty(),
"{kind:?}: empty default wire model"
);
assert!(
!descriptor.default_base_url().trim().is_empty(),
"{kind:?}: empty default base URL"
);
// Any declared auth env var name must be a real, non-empty key.
for env_var in descriptor.env_vars() {
assert!(
!env_var.trim().is_empty(),
"{kind:?}: empty env var name in descriptor"
);
}
// The wire protocol accessor must not panic for any kind.
let _ = descriptor.protocol();
}
}
#[test]
fn every_provider_kind_resolves_its_default_route() {
let resolver = RouteResolver::new();
let bundled = bundled_offerings();
for &kind in ProviderKind::all() {
let descriptor = ProviderDescriptor::for_kind(kind);
let candidate = resolver.resolve(&none_request(kind)).unwrap_or_else(|err| {
panic!("{kind:?}: default (None selector) route must resolve, got {err:?}")
});
assert_eq!(
candidate.provider_kind, kind,
"{kind:?}: resolved to a different provider"
);
assert_eq!(
candidate.provider_id,
ProviderId::from_kind(kind),
"{kind:?}: resolved provider id mismatch"
);
// The resolver prefers this provider's bundled *default offering* wire id
// when one exists, and otherwise falls back to the descriptor default
// wire model. Assert that exact contract so a future drift between the
// catalog-backed default and `Provider::default_model()` fails with an
// honest message instead of coincidentally passing.
let expected_wire = bundled
.iter()
.find(|offering| {
offering.provider == ProviderId::from_kind(kind) && offering.default_for_provider
})
.map_or_else(
|| descriptor.default_wire_model().as_str().to_string(),
|offering| offering.wire_model_id.as_str().to_string(),
);
assert_eq!(
candidate.wire_model_id.as_str(),
expected_wire,
"{kind:?}: None selector must resolve to the bundled default offering (or descriptor default)"
);
}
}
#[test]
fn every_provider_kind_resolves_the_auto_selector() {
let resolver = RouteResolver::new();
for &kind in ProviderKind::all() {
let request = RouteRequest {
explicit_provider: Some(kind),
model_selector: Some(LogicalModelRef::from("auto")),
saved_provider_model: None,
base_url_override: None,
};
let candidate = resolver
.resolve(&request)
.unwrap_or_else(|err| panic!("{kind:?}: `auto` must resolve, got {err:?}"));
assert_eq!(
candidate.provider_kind, kind,
"{kind:?}: auto resolved to a different provider"
);
assert!(
candidate.logical_model.is_auto(),
"{kind:?}: `auto` must stay the auto sentinel, never a literal model"
);
// `auto` with no catalog default falls back to the descriptor default,
// which conformance #2 already pins; here we only assert it resolves.
assert!(
!candidate.wire_model_id.as_str().trim().is_empty(),
"{kind:?}: auto resolved to an empty wire model"
);
}
}
+94
View File
@@ -0,0 +1,94 @@
//! Provider descriptors over the existing built-in provider registry (#3084).
//!
//! A [`ProviderDescriptor`] is a thin, route-facing view over the static
//! [`provider::Provider`] trait objects already in [`crate::provider`]. It
//! surfaces only the transport facts route resolution needs (id, base URL,
//! default wire model, env vars, protocol) without duplicating the registry.
//!
//! Because a descriptor holds a `&'static dyn Provider`, it is intentionally
//! NOT `Serialize`/`PartialEq`-derivable. Never embed a [`ProviderDescriptor`]
//! inside a `Serialize` struct; serialize the resolved facts instead.
use crate::ProviderKind;
use crate::provider::{self, Provider};
use super::RequestProtocol;
use super::ids::{ProviderId, WireModelId};
/// Route-facing view of a built-in provider's transport facts.
///
/// Holds a trait object, so it is deliberately not serializable/comparable.
#[derive(Clone, Copy)]
pub struct ProviderDescriptor {
/// The provider kind this descriptor describes.
pub kind: ProviderKind,
/// Backing static provider metadata entry.
pub inner: &'static dyn Provider,
}
impl ProviderDescriptor {
/// Build a descriptor for a known provider kind.
#[must_use]
pub fn for_kind(kind: ProviderKind) -> Self {
Self {
kind,
inner: provider::provider_for_kind(kind),
}
}
/// Canonical provider id.
#[must_use]
pub fn id(&self) -> ProviderId {
ProviderId::from(self.inner.id())
}
/// Default base URL when no override is present.
#[must_use]
pub fn default_base_url(&self) -> &'static str {
self.inner.default_base_url()
}
/// Default wire model id when no model is selected.
#[must_use]
pub fn default_wire_model(&self) -> WireModelId {
WireModelId::from(self.inner.default_model())
}
/// Environment variable candidates for this provider's API key.
#[must_use]
pub fn env_vars(&self) -> &'static [&'static str] {
self.inner.env_vars()
}
/// Selected wire protocol for this provider.
#[must_use]
pub fn protocol(&self) -> RequestProtocol {
self.inner.wire()
}
}
impl std::fmt::Debug for ProviderDescriptor {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProviderDescriptor")
.field("kind", &self.kind)
.field("id", &self.inner.id())
.field("protocol", &self.inner.wire())
.finish()
}
}
/// A concrete endpoint's transport facts.
///
/// Unlike [`ProviderDescriptor`], this owns plain data and is safe to embed in
/// serializable route output (see [`super::candidate::ResolvedEndpoint`]).
#[derive(Debug, Clone)]
pub struct EndpointDescriptor {
/// Stable endpoint key (e.g. `"chat"`, `"responses"`).
pub endpoint_key: String,
/// Wire protocol spoken at this endpoint.
pub protocol: RequestProtocol,
/// Default base URL for this endpoint.
pub default_base_url: String,
/// Whether streaming is supported.
pub streaming: bool,
}
+50
View File
@@ -0,0 +1,50 @@
//! Route resolution errors (#3384).
//!
//! `thiserror` is not a dependency of this crate, so [`Display`] and
//! [`std::error::Error`] are hand-implemented. No new dependency is added.
use std::fmt;
use super::ids::ProviderId;
/// Why a [`super::resolver::RouteResolver`] could not produce a candidate.
#[derive(Debug, Clone)]
pub enum RouteError {
/// The requested model selector was empty.
EmptyModel,
/// The named provider could not be resolved.
InvalidProvider(String),
/// A model matched multiple providers; the caller must disambiguate.
AmbiguousModel(Vec<ProviderId>),
/// A clearly-foreign model was requested for a strict direct provider.
ForeignModelForDirectProvider {
/// The strict direct provider that rejected the model.
provider: ProviderId,
/// The foreign model selector that was rejected.
model: String,
},
}
impl fmt::Display for RouteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyModel => write!(f, "model selector was empty"),
Self::InvalidProvider(name) => write!(f, "invalid provider: {name}"),
Self::AmbiguousModel(providers) => {
let names: Vec<&str> = providers.iter().map(ProviderId::as_str).collect();
write!(
f,
"model matches multiple providers ({}); specify a provider",
names.join(", ")
)
}
Self::ForeignModelForDirectProvider { provider, model } => write!(
f,
"model {model:?} is not served by direct provider {}",
provider.as_str()
),
}
}
}
impl std::error::Error for RouteError {}
+172
View File
@@ -0,0 +1,172 @@
//! Transparent string newtypes for provider/model/route identities.
//!
//! These types make the distinct *meanings* of route strings unmistakable at
//! the type level so callers can no longer mix:
//!
//! - [`ProviderId`] — a provider's canonical id (e.g. `"deepseek"`).
//! - [`ModelId`] — a canonical, provider-agnostic logical model id.
//! - [`WireModelId`] — a provider-owned wire id sent on the request
//! (e.g. `"deepseek-ai/DeepSeek-V4-Pro"` on Together).
//! - [`LogicalModelRef`] — a user/selector reference to a model, which may be
//! `"auto"`, a bare model, or an aggregator-prefixed string.
//!
//! [`ModelId`] and [`WireModelId`] are deliberately DISTINCT types and are
//! never interchangeable: a canonical model identity is not the same thing as
//! the provider-specific string put on the wire.
//!
//! INVARIANT (load-bearing for #2608): a namespace prefix can NEVER become a
//! provider. There is intentionally NO `From`/`Into` conversion from
//! [`LogicalModelRef`] or [`NamespaceHint`] to [`ProviderId`]. A prefix like
//! `deepseek-ai/` is a catalog/namespace hint only; it is not proof of
//! provider ownership. Do not add such a conversion.
use std::fmt;
use serde::{Deserialize, Serialize};
/// The `"auto"` router sentinel for [`LogicalModelRef`].
///
/// `auto` is an opt-in router sentinel — it never refers to a literal model
/// named "auto". Centralized here so every comparison site uses the same
/// spelling (#4158).
pub const AUTO_SENTINEL: &str = "auto";
use crate::ProviderKind;
macro_rules! string_newtype {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(String);
impl $name {
/// Borrow the inner string slice.
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<&str> for $name {
fn from(value: &str) -> Self {
Self(value.to_string())
}
}
impl From<String> for $name {
fn from(value: String) -> Self {
Self(value)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
};
}
string_newtype!(
/// A provider's canonical identifier (e.g. `"deepseek"`, `"openrouter"`).
ProviderId
);
string_newtype!(
/// A canonical, provider-agnostic logical model identity.
///
/// Distinct from [`WireModelId`]: this is "what the model is", not "what
/// string a provider expects on the wire".
ModelId
);
string_newtype!(
/// A provider-owned wire model id sent verbatim on the request.
///
/// Distinct from [`ModelId`]: aggregator-prefixed strings such as
/// `"deepseek-ai/DeepSeek-V4-Pro"` are wire ids, not canonical identities.
WireModelId
);
string_newtype!(
/// A user/selector reference to a model.
///
/// May be the `"auto"` sentinel, a bare model name, or an
/// aggregator-prefixed string. A [`LogicalModelRef`] carries no provider
/// authority by itself; see [`Self::namespace_hint`].
LogicalModelRef
);
impl ProviderId {
/// Build a [`ProviderId`] from a [`ProviderKind`] using its canonical id.
#[must_use]
pub fn from_kind(kind: ProviderKind) -> Self {
Self(kind.as_str().to_string())
}
}
/// A leading namespace/organization prefix carried by a [`LogicalModelRef`].
///
/// A namespace hint is a *catalog* hint only. It is NEVER convertible to a
/// [`ProviderId`]; an aggregator may serve `deepseek-ai/...` without being
/// DeepSeek, and a custom endpoint may legitimately use a look-alike string.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum NamespaceHint {
/// `deepseek-ai/` prefix.
DeepseekAi,
/// `deepseek/` prefix.
Deepseek,
/// `anthropic/` prefix.
Anthropic,
/// `openai/` prefix.
Openai,
/// `qwen/` prefix.
Qwen,
}
impl LogicalModelRef {
/// Borrow the raw selector string.
#[must_use]
pub fn raw(&self) -> &str {
self.as_str()
}
/// Whether this selector is the explicit `auto` router sentinel.
///
/// `auto` is an opt-in router sentinel, never a literal model id.
#[must_use]
pub fn is_auto(&self) -> bool {
self.raw() == AUTO_SENTINEL
}
/// Parse the leading namespace prefix, if any.
///
/// Returns `Some` only for the curated aggregator/organization prefixes.
/// This is a hint about catalog namespace and does NOT identify a provider.
#[must_use]
pub fn namespace_hint(&self) -> Option<NamespaceHint> {
let raw = self.raw();
// Order matters: `deepseek-ai/` must be matched before `deepseek/`.
if raw.starts_with("deepseek-ai/") {
Some(NamespaceHint::DeepseekAi)
} else if raw.starts_with("deepseek/") {
Some(NamespaceHint::Deepseek)
} else if raw.starts_with("anthropic/") {
Some(NamespaceHint::Anthropic)
} else if raw.starts_with("openai/") {
Some(NamespaceHint::Openai)
} else if raw.starts_with("qwen/") {
Some(NamespaceHint::Qwen)
} else {
None
}
}
}

Some files were not shown because too many files have changed in this diff Show More