Compare commits

...
Author SHA1 Message Date
CodeWhale BotandClaude Sonnet 5.5 583984d76a refactor(acp): add the official ACP SDK dependency and share the bounded line reader (ACP-SDK-S0)
Adds agent-client-protocol =5.2.0 (default-features = false; no process,
stdio or unstable_* feature) as a workspace dependency of codewhale-engine
and codewhale-app-server, ground for porting both ACP directions onto it.

Moves BoundedLines, FrameRefusal and is_frame_refusal from the app-server
stdio module into codewhale_util::bounded_lines so the server and the client
directions share one 8 MiB reader. stdio.rs re-exports the three items, so
its call sites are unchanged, and a const assertion keeps the new
MAX_FRAME_BYTES equal to MAX_RUNTIME_IMAGE_BODY_BYTES. The two framing tests
that read the private pending buffer move next to the reader.

Lockfile: 11 packages added, none existing moved. The resolver also
re-pointed Windows-only windows-sys edges and tempfile's getrandom edge;
those edges were restored to the previously locked versions, so the only
removed lines are schemars/indexmap name disambiguation. cargo deny bans and
licenses stay ok with 10 duplicate warnings before and after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:50:57 -08:00
CodeWhale BotandClaude Opus 5.5 4948f6a6cd test(acp): check both ACP directions against the official v1 schema types (CW-8039)
Adds agent-client-protocol-schema =1.17.0 (default features off, stable
v1 only) as a dev-dependency of codewhale-app-server and codewhale-engine;
`cargo tree -e normal` shows no runtime path to it.

A shared checker (crates/engine/tests/support/acp_conformance.rs)
deserializes every frame into the official v1 type for its method,
re-serializes it and fails on any key the official type drops (outside
_meta), on a non-v1 method, and on a reply to no outstanding request.
Known deviations live in one allowlist per test; a new deviation or an
allowlisted one that disappears fails the test.

- Server: acp_conformance drives the real ACP frontend through
  initialize, $/cancel_request, error paths, session/new, set_mode,
  set_config_option, a prompt with a tool call, permission and plan tool,
  a cancelled prompt, session/list and session/load with replay, with
  requests built from the official types. 7 allowlisted deviations.
- Client: acp_client_conformance runs fleet::acp::AcpClient against both
  fixture agents through a capture wrapper (acp-tee.mjs) without touching
  the client or fixtures. Our client's frames and acp-agent.mjs are clean;
  acp-options-agent.mjs sends two unstable-only fields (allowlisted).
- acp_journey now shares the harness in tests/acp_support.

No runtime code or wire behavior changes. Findings and the
schema-vs-SDK decision table are in the task artifacts.

acp-tee.mjs starts a fixture agent script as a child and appends both
directions of its newline-framed stdio, in arrival order, to a log file:
"C <line>" for what the client wrote, "A <line>" for what the agent wrote.

The client test calls new_session and reopen_session with REF-09's
identity and additional-directories arguments (empty list).

Lockfile: only the schema crate's packages are added (agent-client-
protocol-schema, bs58, hashbrown 0.12.3, indexmap 1.9.3, schemars 0.9.0,
serde_with, serde_with_macros, time-macros) plus the indexmap/schemars
name disambiguation and the deranged/time feature edges; no existing
package moves. The resolver's re-pointing of Windows-only windows-sys
edges and tempfile's getrandom edge was restored to the locked versions.

Checks on the stack tip with ACP-SDK-S0 (Rust sources and lockfile
identical to farm runs 20261111T052504Z-acpland3-tests-edecfa and
-052503Z-acpland3-clippy-5bd65b; main has since moved in web/ and docs
only; Boat farm, Linux, RUST_MIN_STACK=8388608):
- acp_conformance: 1 passed, 0 failed
- acp_client_conformance: 1 passed, 0 failed (node v24 present, no skip)
- acp_journey: 1 passed, 0 failed
- codewhale-engine --lib fleet::acp::: 6 passed, 0 failed
- cargo clippy --workspace --all-targets --all-features --locked (CI
  flags): exit 0, no lint output
- Mac: cargo fmt --all -- --check clean; scripts/preflight.sh --check OK;
  cargo deny --offline check licenses bans ok, 10 duplicate warnings,
  same set as base; cargo metadata --locked --offline ok

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:50:54 -08:00
CodeWhale BotandClaude Fable 5.2 ace1c407d9 chore(changelog): sync the TUI changelog slice for the catalog, seam context and offline fix landing (CW-8038, CW-8012, CW-8031)
Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-10 21:49:43 -08:00
CodeWhale BotandClaude Sonnet 5.5 68ae405f0c tui: return the oldest held message to the composer on route activation (CW-8031)
Review repair: rustfmt wrap of the new test line, oldest-first draft pop so a
backlog is not sent out of order, ordering test added, tautological
rejected-key test removed (failed_*_activation rollback tests cover it).

The doc comment on cancel_queued_draft_edit no longer says the draft goes
back where pop_last_queued_into_draft took it from; it goes back at the tail.

Checks (Linux x86_64, on the landing stack):
- cargo test --locked -p codewhale-tui --lib -- route_activation offline_label
  tui::ui::tests::failed_custom_provider_activation
  tui::ui::tests::failed_native_xai_oauth queued
  test result: ok. 38 passed; 0 failed; 0 ignored; 5706 filtered out
- cargo fmt --all -- --check: clean

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:48:22 -08:00
CodeWhale BotandClaude Sonnet 5.5 5c3e20707d tui: connecting a route after "explore offline" ends offline queueing (CW-8031)
choose_offline_explore sets offline_mode, but route activation through
switch_provider only cleared the explore label, so every later message was
held as "Saved for later" until restart. clear_offline_explore_on_route_activation
now also clears offline_mode and returns a held message to the composer; the
rejected-key path (apply.rs) still sets offline_mode and is unchanged.

Checks (Boat farm, RUST_MIN_STACK=64MiB, cargo test -p codewhale-tui --lib,
filters tui::onboarding, route_activation_after_offline, tui::ui::tests::failed_,
provider_, reselecting): 68 passed, 0 failed. Without RUST_MIN_STACK two
failed_*_activation tests overflow the stack on unmodified main too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-10 21:48:18 -08:00
CodeWhale BotandClaude Opus 5.5 5c39379cf5 feat(extension-host): fill the dsh/broker network arm with per-host egress and an opaque credential vault (CW-7979)
The network arm decodes four typed methods (fetch, credentials.describe,
credentials.resolve, credentials.set); any other method is a typed
unknown_method refusal and malformed input is invalid_input. No pass-through.

Authority is a NetworkAuthority value: the plugin's reviewed network hosts,
the invocation's ToolContext (its network policy decider) and the credential
vault. BrokerCall carries none of these yet (SEAM-CONTEXT has not landed), so
serve() runs with a withheld authority and every method that would act
refuses with not_available. serve_with() holds the whole behaviour and is
what SEAM-CONTEXT's fields plug into; nothing is advertised to plugins.

fetch reuses the existing owners: the host must exactly match a reviewed
network host; then tools::web::guard::validate_fetch_target applies the
session's deny list and approval posture and the SSRF/DNS guard, and the
connection is pinned to the validated address through
codewhale_net::guard::guarded_reqwest_client_builder (no ambient proxy).
Redirects are followed manually, at most five, only within the requested
origin (scheme, host, port), and each hop is re-validated, so a redirect,
rename or rebinding never widens the grant.

Credentials live in codewhale_secrets under dsh-plugin:<plugin>:<ref>.
resolve and describe return only the handle codewhale-credential:<ref>;
fetch substitutes the secret into header values only after the hop passed
egress, refuses handles in the URL, and scrubs the secret back to its handle
in response headers and body. Logs name plugin, host, ref and status only.

TypeScript: src/dsh/network.ts adds credentialsAdapter (resolve, describe,
set, fetch) and brokeredFetch over AdapterHost.broker, unregistered until
the Rust authority is wired.

core_call_tests.rs: the seam's all-services not_available loop skips
network (its arm now answers unknown_method for "anything"), and one new
regression drives the real TABLE through serve_broker_in with minted
tickets: unknown method, withheld authority, another plugin, another
service's ticket, the core ticket, an ended invocation, a revoked owner.

Checks (farm, Linux x86_64, base 4133ed98ec):
- sh scripts/with-hermetic-test-home.sh cargo test --locked -p codewhale-engine --lib -- extension_host
  test result: ok. 182 passed; 0 failed; 0 ignored; 6975 filtered out
- extension-host: npm run typecheck exit 0; npm run build exit 0, tree unchanged;
  npm test 448 tests, 441 pass, 0 fail, 7 skipped; npm run test:bun 442 pass, 6 skip, 0 fail

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:48:14 -08:00
CodeWhale BotandClaude Opus 5.5 37b3983e9f feat(extension-host): broker arms act with their invocation's context, cancellation, gate and grant (CW-8012)
DSH-16 SEAM-CONTEXT. A DSH broker arm used to get only owner, session,
service, method and input. It now acts inside the invocation whose ticket
was redeemed, and nowhere else. This is additive: the Arm type,
BrokerFuture, every serve signature and the seven dsh_<service>.rs stubs
are unchanged.

- BrokerCall gains one field, `context: BrokerContext` (defined in
  core_call.rs and re-exported from dsh_broker):
  - tool(): the invocation's own ToolContext, captured at begin_scoped,
    with its nested-call gate removed. It carries the workspace, sandbox,
    approval and trust posture, disallowed tools and session objects.
    Nothing in the host's params feeds it.
  - cancel(): the call's token. It is a child of the invocation's token
    and fires when the host cancels the request, the invocation ends,
    the owner is revoked, the host exits, or serve_broker_in returns.
    So work an arm spawns (processes, tasks) can clean up after itself.
  - core_call(name, input): a nested core tool call through the
    invocation's CodemodeInvoker. It takes the same refusal list, gate,
    origin approval, receipts and 4-at-once cap as core/call, and the
    call's token withdraws it, open approval card included. All brokers
    of one invocation share a cap of 50 such calls. Failures map to the
    typed BrokerRefusal::Denied or ::Refused.
- Grant: dsh_broker::granted(service) is the one check. It is applied
  when broker tickets are minted (only granted services are ticketed, no
  longer all seven), and again in serve_broker_in before the arm runs.
  An ungranted service is refused with the existing NotAvailable,
  identical to an unbuilt arm, so the refusal reveals nothing. The
  production grant list is empty, so no broker ticket is minted today.
  Plugin authors see the same behavior as before: every broker service
  refused. Tests set grants with dsh_broker::TestGrants. The Engine holds
  no per-plugin declaration of the broker services a plugin uses (inject
  lists are checked only by the TS host at activation). The source for a
  per-plugin grant is a coordinator/founder question in the review
  packet.

Known limits: the 50 nested-call cap is separate from the core/call
ticket's 50 uses. cancel() is per broker call, so long-lived resources
(terminals) need an owner-scoped lifetime from their own Runtime owner.
TestGrants is thread-local, so a broker test runs on a current-thread
runtime.

Checks (farm, base 4133ed98ec, these three files shipped):
- with-hermetic-test-home cargo test --locked -p codewhale-engine --lib
  -- extension_host: 181 passed, 0 failed, 0 ignored, 6975 filtered out
  (run 20261111T034028Z-seamctx-test-8d29a5). Five new tests and the
  updated seam test pass by name.
- cargo clippy --locked -p codewhale-engine --all-targets -- -D warnings:
  exit 0, 0 warnings (run 20261111T034030Z-seamctx-clippy-ace2ad).
- bash scripts/preflight.sh --check: preflight: OK (local).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:48:10 -08:00
CodeWhale BotandClaude Sonnet 5.5 949ebc868e feat(catalog): offer qwen4-max-preview on the Model Studio Token Plan
Adds the id the Token Plan Supported Models table lists to the reviewed
roster, public list and Token Plan picker, both dialects. The vendor page
https://qwencloud.com/pricing/token-plan (verified 2026-11-11, also read by
the founder 2026-11-10) lists qwen4-max-preview as New with Text Generation,
Reasoning Model and Visual Understanding. Default stays qwen3.8-max.

The page publishes no context window, maximum output, tool calling or
thinking control, so none is recorded. The intrinsic row leaves reasoning
unset and the Token Plan transport no longer claims reasoning support, so
neither dialect sends a thinking field: the OpenAI dialect strips every
thinking field for an id in neither Model Studio thinking list
(apply_modelstudio_route_reasoning_controls in chat.rs), and on the
Anthropic dialect thinking_capable is false, so build_anthropic_body sends
neither a thinking block nor output_config, for any effort including off.
Visual Understanding is vendor-stated, so the transport records
image_input as the sibling Token Plan rows do. The Model Studio Messages
test now pins that no thinking field is sent for this id.

The Coding Plan completion rosters were derived from the Token Plan
transports; they now list their seven ids explicitly so the preview is not
offered there, and the Coding Plan rows in PROVIDERS.md, zh_hans/PROVIDERS.md
and config.example.toml say it omits qwen4-max-preview. The Token Plan rows
in both PROVIDERS.md copies say what is and is not recorded for it. Agent
docs and website facts regenerated.

Not exercised against the live endpoint (founder decision, no live calls):
that the endpoint accepts the id on each dialect, its limits, tool calling
and how it thinks are unproven.

Amended at landing per the coordinator's vendor read: reasoning dropped
from the intrinsic row, image input recorded, receipt cites the verified
vendor page.

Checks: seed render --check ok; seed age ok (0.3 days); catalog_models_dev_test 21 tests OK; web check-facts OK; check-docs PASS; check-runtime-routes PASS; preflight --check OK. Farm (Linux x86_64), stack tested on main a37d7fdd27; the rebased tree differs from the tested one only by main's own commits up to 1f9ad30c1f: cargo nextest run -p codewhale-config -p codewhale-models -p codewhale-engine (engine filtered to client::anthropic), RUST_MIN_STACK=8388608, 989 passed / 0 failed (config lib 834, configured_models 9, engine client::anthropic 38, models 108), run 20261111T043921Z-catalog8038final-test-193af4; CI-style clippy -D warnings --all-targets --all-features for config, models and engine, run 20261111T043925Z-catalog8038final-clippy-40cd61, exit 0, no warnings. The extended modelstudio_messages_body_requests_thinking_with_budget passed in that run.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-10 21:23:58 -08:00
35 changed files with 3329 additions and 317 deletions

No files matched your search

+2
View File
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
- **Anthropic xhigh effort:** Choosing xhigh effort on an Anthropic model that documents it, such as Claude Fable 5.2 or Claude Opus 4.8, now sends xhigh instead of max, so the model uses and bills the level you picked.
- **Public model list:** The public model list gains GPT-6.1 Terra, Kimi K3.1 Pro (and its high-speed variant), MiniMax M3.1 Flash and Mistral Large 4, each marked unreviewed.
- **Model Studio Token Plan:** Token Plan users can now pick `qwen4-max-preview` on both the OpenAI and Anthropic dialects. It was added from the model table Alibaba publishes on its Token Plan pricing page and has not been exercised against the live endpoint. Alibaba publishes no context window, output limit, tool calling or thinking control for it, so Codewhale records image input only and sends it no thinking switch on either dialect. The Coding Plan does not list it.
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
@@ -55,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
- After choosing "explore offline" on first run, connecting a provider or Custom endpoint with /provider now takes effect immediately; messages are no longer held as "Saved for later" until restart, and a message held while offline returns to the composer.
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
Generated
+157 -13
View File
@@ -19,6 +19,48 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "agent-client-protocol"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32339d83e679c8092ec69d2f18e84448f4acf09b46a753e48f3e9265f920acb1"
dependencies = [
"agent-client-protocol-derive",
"agent-client-protocol-schema",
"anyhow",
"futures",
"futures-concurrency",
"rustc-hash",
"serde",
"serde_json",
"tracing",
"uuid",
]
[[package]]
name = "agent-client-protocol-derive"
version = "5.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05a39fd0d81daab854d25144e82e31006b87c554818beb695f0edb576e9c54c4"
dependencies = [
"quote",
"syn 3.0.9",
]
[[package]]
name = "agent-client-protocol-schema"
version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fd595be39fc4f9c8170872e5d10d81b44cb6dca44bcc66daba0d34412d4237d"
dependencies = [
"anyhow",
"derive_more",
"serde",
"serde_json",
"serde_with",
"strum",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -601,6 +643,15 @@ dependencies = [
"syn 3.0.9",
]
[[package]]
name = "bs58"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
dependencies = [
"tinyvec",
]
[[package]]
name = "bstr"
version = "1.13.0"
@@ -882,6 +933,8 @@ dependencies = [
name = "codewhale-app-server"
version = "0.10.3"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"async-stream",
"async-trait",
@@ -966,7 +1019,7 @@ version = "0.10.3"
dependencies = [
"chrono",
"jsonschema",
"schemars",
"schemars 1.2.2",
"serde",
"serde_json",
]
@@ -1070,7 +1123,7 @@ dependencies = [
"libc",
"ring",
"rust_decimal",
"schemars",
"schemars 1.2.2",
"semver",
"serde",
"serde_json",
@@ -1123,6 +1176,8 @@ dependencies = [
name = "codewhale-engine"
version = "0.10.3"
dependencies = [
"agent-client-protocol",
"agent-client-protocol-schema",
"anyhow",
"arboard",
"async-stream",
@@ -1194,7 +1249,7 @@ dependencies = [
"rmcp",
"rusqlite",
"rustls",
"schemars",
"schemars 1.2.2",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -1830,7 +1885,7 @@ dependencies = [
"rquickjs",
"rusqlite",
"rustls",
"schemars",
"schemars 1.2.2",
"semver",
"serde",
"serde_json",
@@ -2389,6 +2444,9 @@ name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"serde_core",
]
[[package]]
name = "derive_more"
@@ -2859,6 +2917,19 @@ dependencies = [
"futures-sink",
]
[[package]]
name = "futures-concurrency"
version = "7.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6"
dependencies = [
"fixedbitset 0.5.7",
"futures-core",
"futures-lite",
"pin-project",
"smallvec",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -3723,7 +3794,7 @@ dependencies = [
"futures-core",
"futures-sink",
"http",
"indexmap",
"indexmap 2.14.0",
"slab",
"tokio",
"tokio-util",
@@ -3750,6 +3821,12 @@ dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -4156,6 +4233,17 @@ dependencies = [
"quick-error",
]
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -5346,7 +5434,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
dependencies = [
"fixedbitset 0.5.7",
"hashbrown 0.15.5",
"indexmap",
"indexmap 2.14.0",
]
[[package]]
@@ -6078,13 +6166,13 @@ dependencies = [
"async-trait",
"chrono",
"futures",
"indexmap",
"indexmap 2.14.0",
"libc",
"oauth2",
"pastey",
"pin-project-lite",
"reqwest",
"schemars",
"schemars 1.2.2",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -6126,7 +6214,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a2ea9adb03b669c933482ff740b683cd36be7cef23891657a8ccb850401d286"
dependencies = [
"convert_case 0.12.0",
"indexmap",
"indexmap 2.14.0",
"proc-macro-crate",
"proc-macro2",
"quote",
@@ -6384,6 +6472,18 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.2.2"
@@ -6553,7 +6653,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"foldhash 0.2.0",
"indexmap",
"indexmap 2.14.0",
"itoa",
"memchr",
"serde",
@@ -6613,6 +6713,39 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_with"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f"
dependencies = [
"base64 0.23.1",
"bs58",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.14.0",
"jiff",
"schemars 0.9.0",
"schemars 1.2.2",
"serde_core",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serial2"
version = "0.2.38"
@@ -7265,6 +7398,7 @@ dependencies = [
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
@@ -7273,6 +7407,16 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny_http"
version = "0.12.0"
@@ -7414,7 +7558,7 @@ version = "1.1.6+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde_core",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
@@ -7447,7 +7591,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
@@ -7461,7 +7605,7 @@ version = "0.25.15+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
dependencies = [
"indexmap",
"indexmap 2.14.0",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
+2
View File
@@ -73,6 +73,8 @@ repository = "https://github.com/codewhale-hq/CodeWhale"
warnings = "deny"
[workspace.dependencies]
agent-client-protocol = { version = "=5.2.0", default-features = false }
agent-client-protocol-schema = { version = "=1.17.0", default-features = false }
anyhow = "1.0.100"
async-trait = "0.1.89"
axum = { version = "0.8.5", features = ["json", "ws"] }
+3 -2
View File
@@ -924,7 +924,7 @@ base_url = "https://api.deepseek.com/beta"
# (https://bailian.console.aliyun.com/)
#
# Token Plan Personal and Team share the same AP-Southeast (Singapore) endpoint.
# Available text/coding models: qwen3.8-max, qwen3.8-max-preview, qwen3.7-plus,
# Available text/coding models: qwen3.8-max, qwen4-max-preview, qwen3.7-plus,
# qwen3.7-max, qwen3.6-flash, deepseek-v4-pro, deepseek-v4-flash-0731, glm-5.2
#
# Provider aliases: modelstudio-token-plan, modelstudio_token_plan,
@@ -944,7 +944,8 @@ base_url = "https://api.deepseek.com/beta"
# (https://bailian.console.aliyun.com/)
#
# Coding Plan uses a separate international endpoint (coding-intl.dashscope).
# Same model catalog as the Token Plan.
# Same model ids as the Token Plan except qwen4-max-preview, which is
# Token Plan only.
#
# Provider aliases: modelstudio-coding-plan, modelstudio_coding_plan,
# alibaba-coding-plan, dashscope-coding-plan
+2
View File
@@ -26,6 +26,7 @@ default = []
test-support = ["codewhale-engine/test-support"]
[dependencies]
agent-client-protocol.workspace = true
codewhale-account-cli = { path = "../account-cli", version = "0.10.3" }
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
anyhow.workspace = true
@@ -94,6 +95,7 @@ urlencoding = "2.1"
webbrowser = "1.0"
[dev-dependencies]
agent-client-protocol-schema.workspace = true
rusqlite.workspace = true
tempfile.workspace = true
tower = "0.5"
+4 -101
View File
@@ -18,111 +18,14 @@ use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::VecDeque;
use std::time::Duration;
use tokio::io::AsyncWriteExt as _;
use tokio::io::{AsyncBufRead, AsyncWrite};
use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _};
use tokio_util::sync::CancellationToken;
#[derive(Debug, thiserror::Error)]
pub enum FrameRefusal {
#[error("control frame exceeds the 8 MiB transport limit")]
TooLarge,
#[error("control frame is not UTF-8")]
InvalidUtf8,
}
pub use codewhale_util::bounded_lines::{BoundedLines, FrameRefusal, is_frame_refusal};
pub fn is_frame_refusal(error: &std::io::Error) -> bool {
error
.get_ref()
.is_some_and(|source| source.is::<FrameRefusal>())
}
/// Cancellation-safe, allocation-bounded newline framing shared by every
/// local control transport. Consumed partial bytes remain owned by the reader.
pub struct BoundedLines<R> {
reader: R,
pub(crate) pending: Vec<u8>,
discarding: bool,
}
impl<R: AsyncBufRead + Unpin> BoundedLines<R> {
pub fn new(reader: R) -> Self {
Self {
reader,
pending: Vec::new(),
discarding: false,
}
}
pub fn into_inner(self) -> R {
self.reader
}
pub async fn next_line(&mut self) -> std::io::Result<Option<String>> {
loop {
let available = self.reader.fill_buf().await?;
if available.is_empty() {
self.discarding = false;
if self.pending.is_empty() {
return Ok(None);
}
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES {
self.pending.clear();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
let available = &available[..available.len().min(8192)];
let newline = available.iter().position(|&byte| byte == b'\n');
let count = newline.unwrap_or(available.len());
if self.discarding {
self.reader.consume(count + usize::from(newline.is_some()));
if newline.is_some() {
self.discarding = false;
} else {
tokio::task::yield_now().await;
}
continue;
}
if count > (MAX_RUNTIME_IMAGE_BODY_BYTES + 1).saturating_sub(self.pending.len()) {
self.pending.clear();
self.reader.consume(count + usize::from(newline.is_some()));
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
// Do not let Vec's geometric growth double an almost-full frame.
self.pending.reserve_exact(count);
self.pending.extend_from_slice(&available[..count]);
self.reader.consume(count + usize::from(newline.is_some()));
if self.pending.len() > MAX_RUNTIME_IMAGE_BODY_BYTES
&& self.pending.last() != Some(&b'\r')
{
self.pending.clear();
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
if newline.is_some() {
if self.pending.last() == Some(&b'\r') {
self.pending.pop();
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
}
}
}
const _: () =
assert!(codewhale_util::bounded_lines::MAX_FRAME_BYTES == MAX_RUNTIME_IMAGE_BODY_BYTES);
/// The stdio JSON-RPC loop, generic over its transport so it can be driven by
/// a duplex pipe in tests rather than the process's real stdin/stdout.
-31
View File
@@ -31,37 +31,6 @@ async fn control_framing_accepts_exact_limit_crlf_and_preserves_next_frame() {
assert!(reader.next_line().await.unwrap().is_none());
}
#[tokio::test]
async fn control_framing_refuses_oversize_before_retaining_extra_bytes() {
let input = vec![b'x'; MAX_RUNTIME_IMAGE_BODY_BYTES + 4096];
let mut reader = BoundedLines::new(BufReader::with_capacity(8192, input.as_slice()));
assert_eq!(
reader.next_line().await.unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
assert!(reader.pending.len() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
assert!(reader.pending.capacity() <= MAX_RUNTIME_IMAGE_BODY_BYTES + 1);
}
#[tokio::test]
async fn control_framing_keeps_consumed_prefix_when_read_future_is_cancelled() {
let (mut input, output) = tokio::io::duplex(64);
input.write_all(b"partial").await.unwrap();
let mut reader = BoundedLines::new(BufReader::new(output));
std::future::poll_fn(|cx| {
let mut read = std::pin::pin!(reader.next_line());
assert!(std::future::Future::poll(read.as_mut(), cx).is_pending());
std::task::Poll::Ready(())
})
.await;
assert_eq!(reader.pending, b"partial");
input.write_all(b"-continued\n").await.unwrap();
assert_eq!(
reader.next_line().await.unwrap().as_deref(),
Some("partial-continued")
);
}
#[tokio::test]
async fn control_framing_rejects_non_utf8_and_keeps_eof_line_contract() {
let mut invalid = BoundedLines::new(BufReader::new(&b"\xff\n"[..]));
+344
View File
@@ -0,0 +1,344 @@
mod acp_support;
#[path = "../../engine/tests/support/acp_conformance.rs"]
mod conformance;
use acp_support::{Client, fixture_config, text_of, updates};
use agent_client_protocol_schema::ProtocolVersion;
use agent_client_protocol_schema::v1 as acp;
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use conformance::{Allowed, Conformance, Side};
use serde::Serialize;
use serde_json::{Value, json};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncWriteExt, BufReader};
const KNOWN: &[Allowed] = &[
Allowed {
side: Side::Agent,
frame: "initialize response",
problem: "field `.agentCapabilities.modelSelection` is not in the official type",
reason: "Codewhale extension flag; official AgentCapabilities has no modelSelection",
},
Allowed {
side: Side::Agent,
frame: "session/new response",
problem: "field `.models` is not in the official type",
reason: "pre-configOptions model selector; official NewSessionResponse has no models",
},
Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.models` is not in the official type",
reason: "pre-configOptions model selector; official LoadSessionResponse has no models",
},
Allowed {
side: Side::Agent,
frame: "session/load response",
problem: "field `.sessionId` is not in the official type",
reason: "session/load reuses the session/new body; official LoadSessionResponse has no sessionId",
},
Allowed {
side: Side::Agent,
frame: "session/list response",
problem: "field `.sessions[].createdAt` is not in the official type",
reason: "official SessionInfo carries updatedAt only",
},
Allowed {
side: Side::Agent,
frame: "session/list response",
problem: "field `.sessions[].messageCount` is not in the official type",
reason: "official SessionInfo has no message count",
},
Allowed {
side: Side::Agent,
frame: "response",
problem: "answers no outstanding request (id null)",
reason: "an unknown notification is answered with an id-null -32601 error; JSON-RPC forbids replying to a notification",
},
];
fn wire<T: Serialize>(value: T) -> Value {
serde_json::to_value(value).expect("official type serializes")
}
async fn prompt_to_end(
client: &mut Client,
params: Value,
answer: Value,
) -> Result<(Value, Vec<Value>)> {
client.next_id += 1;
let id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":id,"method":"session/prompt","params":params}))
.await?;
let mut frames = Vec::new();
loop {
let frame = client.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
if frame["method"] == "session/request_permission" {
client
.send(json!({"jsonrpc":"2.0","id":frame["id"],"result":answer}))
.await?;
}
frames.push(frame);
}
}
#[test]
fn every_frame_the_acp_server_writes_is_an_official_v1_shape() -> Result<()> {
std::thread::Builder::new()
.stack_size(64 << 20)
.spawn(|| {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?
.block_on(async {
tokio::time::timeout(Duration::from_secs(120), journey())
.await
.map_err(|_| anyhow!("conformance journey exceeded 120s"))?
})
})?
.join()
.map_err(|_| anyhow!("conformance journey thread panicked"))?
}
async fn journey() -> Result<()> {
let dir = tempfile::tempdir()?;
let _home = codewhale_engine::test_support::SealedHome::at(dir.path());
let workspace = dir.path().join("workspace");
std::fs::create_dir(&workspace)?;
let runtime_dir = dir.path().join("runtime");
let config = fixture_config();
let plugins = Arc::new(codewhale_plugins::PluginRegistry::empty(&workspace));
let manager = Arc::new(RuntimeThreadManager::open_host(
config.clone(),
workspace.clone(),
RuntimeThreadManagerConfig {
mcp_auth_context: Default::default(),
data_dir: runtime_dir.clone(),
task_data_dir: runtime_dir,
sessions_dir: None,
max_active_threads: 4,
},
plugins,
codewhale_engine::core::engine::EngineHostProfile::Acp,
None,
None,
)?);
let provider = Arc::new(MockLlmClient::new(vec![
canned::tool_call_turn(
"plan-call",
"todo_write",
r#"{"todos":[{"content":"write the file","status":"in_progress"}]}"#,
),
canned::tool_call_turn(
"write-call",
"write",
r#"{"path":"allowed.txt","content":"approved"}"#,
),
canned::simple_text_turn("settled after approval"),
canned::tool_call_turn(
"cancel-call",
"write",
r#"{"path":"cancelled.txt","content":"never"}"#,
),
canned::simple_text_turn("unused"),
]));
manager.set_test_model_client(provider.clone());
let sessions_dir = codewhale_engine::session_manager::default_sessions_dir()?;
let frontend = capture_frontend(
config,
"fixture-model".into(),
workspace.clone(),
Arc::clone(&manager),
sessions_dir,
None,
None,
)?;
let mut observed = Conformance::checking(&[Side::Agent]);
let (mut client, agent_read, agent_write) = Client::connect(true);
let first_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let first_script = async {
let result = async {
let (initialized, _) = client
.request(
"initialize",
wire(
acp::InitializeRequest::new(ProtocolVersion::V1)
.client_capabilities(
acp::ClientCapabilities::new()
.fs(acp::FileSystemCapabilities::new())
.terminal(false),
)
.client_info(acp::Implementation::new("conformance", "1.0.0")),
),
)
.await?;
assert_eq!(initialized["result"]["protocolVersion"], 1);
client
.send(json!({"jsonrpc":"2.0","method":"$/cancel_request","params":wire(acp::CancelRequestNotification::new(999_i64))}))
.await?;
let (unknown, _) = client.request("session/not_a_method", json!({})).await?;
assert_eq!(unknown["error"]["code"], -32601);
let (invalid, _) = client
.request("session/set_mode", json!({"modeId":"agent"}))
.await?;
assert_eq!(invalid["error"]["code"], -32602);
let (relative, _) = client
.request("session/new", wire(acp::NewSessionRequest::new("relative/dir")))
.await?;
assert_eq!(relative["error"]["code"], -32602);
let (created, _) = client
.request("session/new", wire(acp::NewSessionRequest::new(&workspace)))
.await?;
let session = created["result"]["sessionId"]
.as_str()
.ok_or_else(|| anyhow!("session/new returned no sessionId"))?
.to_string();
let (mode, _) = client
.request(
"session/set_mode",
wire(acp::SetSessionModeRequest::new(session.clone(), "agent")),
)
.await?;
assert!(mode.get("result").is_some(), "{mode}");
let (option, _) = client
.request(
"session/set_config_option",
wire(acp::SetSessionConfigOptionRequest::new(
session.clone(),
"model",
"fixture-model",
)),
)
.await?;
assert!(option.get("result").is_some(), "{option}");
let (image, _) = client
.request(
"session/prompt",
wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Image(acp::ImageContent::new(
"AAAA",
"image/png",
))],
)),
)
.await?;
assert_eq!(image["error"]["code"], -32602);
let allow = wire(acp::RequestPermissionResponse::new(
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
"allow-once",
)),
));
let (done, frames) = prompt_to_end(
&mut client,
wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Text(acp::TextContent::new(
"plan, then write under Ask",
))],
)),
allow,
)
.await?;
assert_eq!(done["result"]["stopReason"], "end_turn");
assert!(!updates(&frames, "tool_call").is_empty());
assert_eq!(text_of(&frames, "agent_message_chunk"), "settled after approval");
client.next_id += 1;
let cancel_id = client.next_id;
client
.send(json!({"jsonrpc":"2.0","id":cancel_id,"method":"session/prompt","params":wire(acp::PromptRequest::new(
session.clone(),
vec![acp::ContentBlock::Text(acp::TextContent::new("write then cancel"))],
))}))
.await?;
let (permission, _) = client.until_permission().await?;
client
.send(json!({"jsonrpc":"2.0","method":"session/cancel","params":wire(acp::CancelNotification::new(session.clone()))}))
.await?;
client
.send(json!({"jsonrpc":"2.0","id":permission["id"],"result":wire(acp::RequestPermissionResponse::new(acp::RequestPermissionOutcome::Cancelled))}))
.await?;
let (cancelled, _) = client.until_response(cancel_id).await?;
assert_eq!(cancelled["result"]["stopReason"], "cancelled");
let (listed, _) = client
.request(
"session/list",
wire(acp::ListSessionsRequest::new().cwd(workspace.clone())),
)
.await?;
assert!(listed["result"]["sessions"].is_array());
Ok::<_, anyhow::Error>(session)
}
.await;
let _ = client.write.shutdown().await;
result
};
let (served, session) = tokio::join!(first_connection, first_script);
served?;
let session = session?;
for (sent, frame) in client.log.take().unwrap_or_default() {
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
}
let (mut client, agent_read, agent_write) = Client::connect(true);
let second_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let second_script = async {
let result = async {
client
.request(
"initialize",
wire(acp::InitializeRequest::new(ProtocolVersion::V1)),
)
.await?;
let (missing, _) = client
.request(
"session/load",
wire(acp::LoadSessionRequest::new("no-such-session", &workspace)),
)
.await?;
assert_eq!(missing["error"]["code"], -32602);
let (loaded, replay) = client
.request(
"session/load",
wire(acp::LoadSessionRequest::new(session.clone(), &workspace)),
)
.await?;
assert!(loaded.get("result").is_some(), "{loaded}");
assert!(text_of(&replay, "user_message_chunk").contains("plan, then write under Ask"));
assert!(!updates(&replay, "tool_call_update").is_empty());
Ok::<_, anyhow::Error>(())
}
.await;
let _ = client.write.shutdown().await;
result
};
let (served, script) = tokio::join!(second_connection, second_script);
served?;
script?;
for (sent, frame) in client.log.take().unwrap_or_default() {
observed.observe(if sent { Side::Client } else { Side::Agent }, &frame);
}
manager.shutdown_and_wait().await?;
assert!(
observed.frames() > 20,
"only {} frames observed",
observed.frames()
);
observed.assert_only(KNOWN);
Ok(())
}
+7 -119
View File
@@ -1,3 +1,6 @@
mod acp_support;
use acp_support::{Client, fixture_config, text_of, updates};
use anyhow::{Result, anyhow};
use codewhale_app_server::acp_server::capture_frontend;
use codewhale_engine::llm_client::mock::{MockLlmClient, canned};
@@ -5,110 +8,10 @@ use codewhale_engine::runtime_threads::RuntimeTurnStatus;
use codewhale_engine::runtime_threads::manager::{
RuntimeThreadManager, RuntimeThreadManagerConfig,
};
use codewhale_runtime::config::{Config, ProviderConfig, ProvidersConfig, SnapshotsConfig};
use serde_json::{Value, json};
use std::collections::HashMap;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf};
const STEP: Duration = Duration::from_secs(30);
fn fixture_config() -> Config {
let mut config = Config::default();
config.provider = Some("acp-fixture".into());
config.providers = Some(ProvidersConfig {
custom: HashMap::from([(
"acp-fixture".into(),
ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://127.0.0.1:18181/v1".into()),
model: Some("fixture-model".into()),
api_key: Some("local-test-key".into()),
..Default::default()
},
)]),
..Default::default()
});
config.snapshots = Some(SnapshotsConfig {
enabled: false,
..Default::default()
});
config.set_feature("mcp", false).unwrap();
config.set_feature("subagents", false).unwrap();
config
}
struct Client {
lines: tokio::io::Lines<BufReader<ReadHalf<DuplexStream>>>,
write: WriteHalf<DuplexStream>,
next_id: u64,
}
impl Client {
async fn send(&mut self, message: Value) -> Result<()> {
let mut line = serde_json::to_vec(&message)?;
line.push(b'\n');
self.write.write_all(&line).await?;
self.write.flush().await?;
Ok(())
}
async fn recv(&mut self) -> Result<Value> {
let line = tokio::time::timeout(STEP, self.lines.next_line())
.await
.map_err(|_| anyhow!("agent produced no frame within {STEP:?}"))??
.ok_or_else(|| anyhow!("agent closed the transport"))?;
Ok(serde_json::from_str(&line)?)
}
async fn request(&mut self, method: &str, params: Value) -> Result<(Value, Vec<Value>)> {
self.next_id += 1;
let id = self.next_id;
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
.await?;
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
async fn until_permission(&mut self) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["method"] == "session/request_permission" {
return Ok((frame, frames));
}
frames.push(frame);
}
}
async fn until_response(&mut self, id: u64) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
}
fn updates<'a>(frames: &'a [Value], kind: &str) -> Vec<&'a Value> {
frames
.iter()
.filter(|frame| frame.pointer("/params/update/sessionUpdate") == Some(&json!(kind)))
.collect()
}
fn text_of(frames: &[Value], kind: &str) -> String {
updates(frames, kind)
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/text")?.as_str())
.collect()
}
use tokio::io::{AsyncWriteExt, BufReader};
#[test]
fn editor_drives_a_session_through_permission_cancel_and_load() -> Result<()> {
@@ -177,22 +80,7 @@ async fn journey() -> Result<()> {
None,
)?;
let connect = || {
let (client, agent) = tokio::io::duplex(1 << 20);
let (agent_read, agent_write) = tokio::io::split(agent);
let (client_read, client_write) = tokio::io::split(client);
(
Client {
lines: BufReader::new(client_read).lines(),
write: client_write,
next_id: 0,
},
agent_read,
agent_write,
)
};
let (mut client, agent_read, agent_write) = connect();
let (mut client, agent_read, agent_write) = Client::connect(false);
let first_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let first_script = async {
@@ -354,7 +242,7 @@ async fn journey() -> Result<()> {
assert_eq!(detail.turns[0].status, RuntimeTurnStatus::Completed);
assert_eq!(detail.turns[1].status, RuntimeTurnStatus::Interrupted);
let (mut client, agent_read, agent_write) = connect();
let (mut client, agent_read, agent_write) = Client::connect(false);
let second_connection =
frontend.serve(Box::new(BufReader::new(agent_read)), Box::new(agent_write));
let second_script = async {
+120
View File
@@ -0,0 +1,120 @@
use anyhow::{Result, anyhow};
use codewhale_runtime::config::{Config, ProviderConfig, ProvidersConfig, SnapshotsConfig};
use serde_json::{Value, json};
use std::collections::HashMap;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, ReadHalf, WriteHalf};
pub const STEP: Duration = Duration::from_secs(30);
pub fn fixture_config() -> Config {
let mut config = Config::default();
config.provider = Some("acp-fixture".into());
config.providers = Some(ProvidersConfig {
custom: HashMap::from([(
"acp-fixture".into(),
ProviderConfig {
kind: Some("openai-compatible".into()),
base_url: Some("http://127.0.0.1:18181/v1".into()),
model: Some("fixture-model".into()),
api_key: Some("local-test-key".into()),
..Default::default()
},
)]),
..Default::default()
});
config.snapshots = Some(SnapshotsConfig {
enabled: false,
..Default::default()
});
config.set_feature("mcp", false).unwrap();
config.set_feature("subagents", false).unwrap();
config
}
pub struct Client {
pub lines: tokio::io::Lines<BufReader<ReadHalf<DuplexStream>>>,
pub write: WriteHalf<DuplexStream>,
pub next_id: u64,
pub log: Option<Vec<(bool, Value)>>,
}
impl Client {
pub fn connect(record: bool) -> (Self, ReadHalf<DuplexStream>, WriteHalf<DuplexStream>) {
let (client, agent) = tokio::io::duplex(1 << 20);
let (agent_read, agent_write) = tokio::io::split(agent);
let (client_read, client_write) = tokio::io::split(client);
(
Client {
lines: BufReader::new(client_read).lines(),
write: client_write,
next_id: 0,
log: record.then(Vec::new),
},
agent_read,
agent_write,
)
}
pub async fn send(&mut self, message: Value) -> Result<()> {
let mut line = serde_json::to_vec(&message)?;
line.push(b'\n');
self.write.write_all(&line).await?;
self.write.flush().await?;
if let Some(log) = &mut self.log {
log.push((true, message));
}
Ok(())
}
pub async fn recv(&mut self) -> Result<Value> {
let line = tokio::time::timeout(STEP, self.lines.next_line())
.await
.map_err(|_| anyhow!("agent produced no frame within {STEP:?}"))??
.ok_or_else(|| anyhow!("agent closed the transport"))?;
let frame: Value = serde_json::from_str(&line)?;
if let Some(log) = &mut self.log {
log.push((false, frame.clone()));
}
Ok(frame)
}
pub async fn request(&mut self, method: &str, params: Value) -> Result<(Value, Vec<Value>)> {
self.next_id += 1;
let id = self.next_id;
self.send(json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
.await?;
self.until_response(id).await
}
pub async fn until_permission(&mut self) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["method"] == "session/request_permission" {
return Ok((frame, frames));
}
frames.push(frame);
}
}
pub async fn until_response(&mut self, id: u64) -> Result<(Value, Vec<Value>)> {
let mut frames = Vec::new();
loop {
let frame = self.recv().await?;
if frame["id"] == json!(id) && frame.get("method").is_none() {
return Ok((frame, frames));
}
frames.push(frame);
}
}
}
pub fn updates<'a>(frames: &'a [Value], kind: &str) -> Vec<&'a Value> {
frames
.iter()
.filter(|frame| frame.pointer("/params/update/sessionUpdate") == Some(&json!(kind)))
.collect()
}
pub fn text_of(frames: &[Value], kind: &str) -> String {
updates(frames, kind)
.iter()
.filter_map(|frame| frame.pointer("/params/update/content/text")?.as_str())
.collect()
}
+36 -3
View File
@@ -1188,6 +1188,9 @@
"context_window": 1000000,
"max_output": 131072
},
"qwen4-max-preview": {
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
},
"step-3.5-flash": {
"context_window": 256000,
"modalities": [
@@ -4177,6 +4180,12 @@
"added_at": "2026-08-02",
"aliases": []
},
{
"id": "qwen4-max-preview",
"label": "Qwen",
"added_at": "2026-11-10",
"aliases": []
},
{
"id": "kimi-for-coding-highspeed",
"label": "Moonshot/Kimi",
@@ -4779,7 +4788,8 @@
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
},
"transports": [
{
@@ -5052,6 +5062,17 @@
"default_for_provider": false,
"source": "preserved reviewed exact transport snapshot; see source_receipts"
},
{
"provider": "modelstudio-token-plan",
"id": "qwen4-max-preview",
"endpoint_key": "chat",
"default_for_provider": false,
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
"limits": {},
"capabilities": {
"image_input": "supported"
}
},
{
"provider": "opencode-go",
"id": "qwen3.8-flash",
@@ -6870,10 +6891,22 @@
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"modelstudio-coding-plan-anthropic": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"mistral": [
"$DEFAULT_MISTRAL_MODEL",
+36 -3
View File
@@ -8800,6 +8800,9 @@
"context_window": 1000000,
"max_output": 131072
},
"qwen4-max-preview": {
"source": "Token Plan Supported Models table, https://qwencloud.com/pricing/token-plan, verified 2026-11-11: id qwen4-max-preview (New), capabilities Text Generation, Reasoning Model, Visual Understanding; see source_receipts"
},
"step-3.5-flash": {
"context_window": 256000,
"modalities": [
@@ -11789,6 +11792,12 @@
"added_at": "2026-08-02",
"aliases": []
},
{
"id": "qwen4-max-preview",
"label": "Qwen",
"added_at": "2026-11-10",
"aliases": []
},
{
"id": "kimi-for-coding-highspeed",
"label": "Moonshot/Kimi",
@@ -12391,7 +12400,8 @@
"https://api.deepinfra.com/models/list": "verified 2026-10-20: deepseek-ai/DeepSeek-V4.1-Flash-1017, deepseek-ai/DeepSeek-V4.1-Flash, deepseek-ai/DeepSeek-V4-Pro-0813 and deepseek-ai/DeepSeek-V4-Flash-0731 are listed and not deprecated, each with max_tokens 1048576 and the tools tag; both V4.1 Flash ids are tagged multimodal and reasoning, V4-Flash-0731 reasoning; V4-Pro-0813 carries can-disable-reasoning beside a non-reasoning tag, while its model page documents low, high and max reasoning effort and the Models.dev deepseek/deepseek-v4-pro-0813 row marks reasoning, so reasoning is recorded; the V4-Pro and V4-Flash previews are still listed; no separate output limit is published, so none is recorded",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4-Pro-0813": "verified 2026-10-20: the ModelScope card describes DeepSeek-V4-Pro-0813 as the official release of DeepSeek-V4-Pro on the preview model structure, whose card lists a 1M context length; no separate ModelScope API-Inference context limit is published there",
"https://modelscope.cn/api/v1/models/deepseek-ai/DeepSeek-V4.1-Flash": "verified 2026-10-20: the ModelScope card states contexts of up to one million tokens and a recommended 1M context_window; no separate ModelScope API-Inference context limit is published there",
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred"
"https://platform.claude.com/docs/en/models/haiku-4-5/overview": "verified 2026-10-22: claude-haiku-4-5 supports manual extended thinking and image input, with 200K context and 64K output; no adaptive-thinking or default-effort support inferred",
"https://qwencloud.com/pricing/token-plan": "verified 2026-11-11: the Supported Models table lists qwen4-max-preview marked New with Text Generation, Reasoning Model and Visual Understanding, so image input is recorded; the page gives no context window, maximum output, tool calling or thinking control, so none is recorded and Codewhale sends no thinking switch"
},
"transports": [
{
@@ -12664,6 +12674,17 @@
"default_for_provider": false,
"source": "preserved reviewed exact transport snapshot; see source_receipts"
},
{
"provider": "modelstudio-token-plan",
"id": "qwen4-max-preview",
"endpoint_key": "chat",
"default_for_provider": false,
"source": "Token Plan Supported Models table, verified 2026-11-11; see source_receipts",
"limits": {},
"capabilities": {
"image_input": "supported"
}
},
{
"provider": "opencode-go",
"id": "qwen3.8-flash",
@@ -14482,10 +14503,22 @@
"@MODELSTUDIO_TEXT_MODELS"
],
"modelstudio-coding-plan": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"modelstudio-coding-plan-anthropic": [
"@MODELSTUDIO_TEXT_MODELS"
"qwen3.8-max",
"qwen3.7-plus",
"qwen3.7-max",
"qwen3.6-flash",
"deepseek-v4-pro",
"deepseek-v4-flash-0731",
"glm-5.2"
],
"mistral": [
"$DEFAULT_MISTRAL_MODEL",
+1 -1
View File
@@ -614,7 +614,7 @@ mod tests {
let reviewed = bundled_reviewed();
reviewed.validate().expect("committed data");
assert_eq!(reviewed.selections.len(), 188);
assert_eq!(reviewed.transports.len(), 122);
assert_eq!(reviewed.transports.len(), 123);
let go = reviewed_transport("opencode-go", "gpt-5.6-luna").unwrap();
assert_eq!(go.endpoint_key, "responses");
assert_eq!(
+2
View File
@@ -73,6 +73,7 @@ path = "src/lib.rs"
doctest = false
[dependencies]
agent-client-protocol.workspace = true
http = "1.5"
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3" }
codewhale-sanitize = { path = "../sanitize", version = "0.10.3" }
@@ -179,6 +180,7 @@ wiremock = { version = "0.6", optional = true }
codewhale-build-support = { path = "../build-support", version = "0.10.3" }
[dev-dependencies]
agent-client-protocol-schema.workspace = true
tokio = { workspace = true, features = ["test-util"] }
codewhale-app-server-protocol = { path = "../app-server-protocol", version = "0.10.3", features = ["schema"] }
codewhale-skills = { path = "../skills", version = "0.10.3", features = ["test-support"] }
@@ -0,0 +1,92 @@
import { Service } from '@deepseek-ai/cordis'
import { ErrorCode } from '../protocol.ts'
import { RpcError } from '../rpc.ts'
import type { AdapterHost, DshAdapter } from './adapters.ts'
export const CREDENTIAL_HANDLE_PREFIX = 'codewhale-credential:'
interface BrokeredResponse {
status: number
url: string
redirected: boolean
headers: [string, string][]
body_base64: string
}
function credentialRef(ref: unknown): string {
if (typeof ref !== 'string' || ref.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential ref is a non-empty string')
return ref
}
function brokered(value: unknown): BrokeredResponse {
const response = value as Partial<BrokeredResponse> | null
if (response === null || typeof response !== 'object' || typeof response.status !== 'number' || typeof response.url !== 'string'
|| typeof response.body_base64 !== 'string' || !Array.isArray(response.headers)) {
throw new RpcError(ErrorCode.Internal, 'invalid network broker response')
}
return response as BrokeredResponse
}
async function requestBody(body: unknown): Promise<string | undefined> {
if (body === undefined || body === null) return undefined
if (typeof body === 'string') return Buffer.from(body, 'utf8').toString('base64')
if (body instanceof ArrayBuffer) return Buffer.from(body).toString('base64')
if (ArrayBuffer.isView(body)) return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString('base64')
if (body instanceof URLSearchParams) return Buffer.from(body.toString(), 'utf8').toString('base64')
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer()).toString('base64')
throw new RpcError(ErrorCode.InvalidParams, 'the network broker sends string, binary, URLSearchParams or Blob bodies')
}
export async function brokeredFetch(host: AdapterHost, ctx: unknown, input: string | URL | Request, init: RequestInit = {}): Promise<Response> {
const request = input instanceof Request ? input : undefined
const url = request?.url ?? String(input)
const headers = new Headers(request?.headers)
new Headers(init.headers).forEach((value, name) => headers.set(name, value))
const body = init.body !== undefined ? init.body : request !== undefined && request.body !== null ? await request.arrayBuffer() : undefined
const result = brokered(await host.broker(ctx, 'network', 'fetch', {
url,
method: init.method ?? request?.method ?? 'GET',
headers: [...headers.entries()],
body_base64: await requestBody(body),
}))
const bytes = Buffer.from(result.body_base64, 'base64')
const nullBody = [101, 204, 205, 304].includes(result.status)
const response = new Response(nullBody ? null : bytes, { status: result.status, headers: result.headers })
Object.defineProperty(response, 'url', { value: result.url })
Object.defineProperty(response, 'redirected', { value: result.redirected === true })
return response
}
export const credentialsAdapter: DshAdapter = {
service: 'credentials',
define(host) {
class CredentialsService extends Service {
constructor(ctx: any) { super(ctx, 'credentials') }
async resolve(ref: unknown): Promise<{ value: string } | undefined> {
const result = await host.broker(this.ctx, 'network', 'credentials.resolve', { ref: credentialRef(ref) }) as { value?: unknown } | null
const value = result?.value
if (value === null || value === undefined) return undefined
if (typeof value !== 'string' || !value.startsWith(CREDENTIAL_HANDLE_PREFIX)) throw new RpcError(ErrorCode.Internal, 'invalid credential handle')
return { value }
}
async describe(ref: unknown): Promise<{ ref: string, set: boolean }> {
const result = await host.broker(this.ctx, 'network', 'credentials.describe', { ref: credentialRef(ref) }) as { ref?: unknown, set?: unknown } | null
if (result === null || typeof result !== 'object' || typeof result.set !== 'boolean') throw new RpcError(ErrorCode.Internal, 'invalid credential description')
return { ref: credentialRef(ref), set: result.set }
}
async set(ref: unknown, value: unknown): Promise<void> {
if (typeof value !== 'string' || value.length === 0) throw new RpcError(ErrorCode.InvalidParams, 'a credential value is a non-empty string')
await host.broker(this.ctx, 'network', 'credentials.set', { ref: credentialRef(ref), value })
}
fetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
return brokeredFetch(host, this.ctx, input, init)
}
}
Object.freeze(CredentialsService.prototype)
return CredentialsService
},
}
@@ -0,0 +1,88 @@
import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const here = dirname(fileURLToPath(import.meta.url))
let credentialsAdapter
let brokeredFetch
before(async () => {
const dir = mkdtempSync(join(here, '..', 'node_modules', '.cw-network-'))
const outfile = join(dir, 'network.mjs')
await build({ entryPoints: [join(here, '../src/dsh/network.ts')], outfile, bundle: true, platform: 'node', format: 'esm', packages: 'external', absWorkingDir: join(here, '..'), logLevel: 'silent' })
;({ credentialsAdapter, brokeredFetch } = await import(pathToFileURL(outfile).href))
process.on('exit', () => rmSync(dir, { recursive: true, force: true }))
})
function brokerHost(answers) {
const calls = []
return {
calls,
host: {
tier: 'plugin',
broker: async (ctx, service, method, input) => {
calls.push({ service, method, input })
const answer = answers[method]
if (answer instanceof Error) throw answer
return typeof answer === 'function' ? answer(input) : answer
},
},
}
}
test('credentials resolve to an opaque handle and go only to the network broker', async () => {
const { host, calls } = brokerHost({
'credentials.resolve': { ref: 'KEY', value: 'codewhale-credential:KEY' },
'credentials.describe': { ref: 'KEY', set: true },
'credentials.set': { ref: 'KEY', set: true },
})
const Credentials = credentialsAdapter.define(host)
const service = Object.create(Credentials.prototype)
service.ctx = {}
assert.deepEqual(await service.resolve('KEY'), { value: 'codewhale-credential:KEY' })
assert.deepEqual(await service.describe('KEY'), { ref: 'KEY', set: true })
await service.set('KEY', 'sk-secret')
assert.deepEqual(calls.map((c) => [c.service, c.method]), [
['network', 'credentials.resolve'], ['network', 'credentials.describe'], ['network', 'credentials.set'],
])
await assert.rejects(service.resolve(''), /non-empty/)
})
test('a resolve answer that is not a handle is refused rather than passed on', async () => {
const { host } = brokerHost({ 'credentials.resolve': { value: 'sk-raw' } })
const service = Object.create(credentialsAdapter.define(host).prototype)
service.ctx = {}
await assert.rejects(service.resolve('KEY'), /invalid credential handle/)
const unset = Object.create(credentialsAdapter.define(brokerHost({ 'credentials.resolve': { value: null } }).host).prototype)
unset.ctx = {}
assert.equal(await unset.resolve('KEY'), undefined)
})
test('brokeredFetch sends a typed fetch and rebuilds a Response from the broker answer', async () => {
const { host, calls } = brokerHost({
fetch: {
status: 201, url: 'https://api.example.com/v1/x', redirected: true,
headers: [['content-type', 'application/json']],
body_base64: Buffer.from('{"ok":true}').toString('base64'),
},
})
const response = await brokeredFetch(host, {}, 'https://api.example.com/v1/x', {
method: 'POST', headers: { authorization: 'Bearer codewhale-credential:KEY' }, body: 'hi',
})
assert.equal(response.status, 201)
assert.equal(response.url, 'https://api.example.com/v1/x')
assert.equal(response.redirected, true)
assert.deepEqual(await response.json(), { ok: true })
assert.equal(calls[0].service, 'network')
assert.equal(calls[0].method, 'fetch')
assert.deepEqual(calls[0].input.headers, [['authorization', 'Bearer codewhale-credential:KEY']])
assert.equal(Buffer.from(calls[0].input.body_base64, 'base64').toString(), 'hi')
})
test('a broker refusal reaches the plugin as the refusal', async () => {
const { host } = brokerHost({ fetch: new Error('egress refused: `evil.example` is not one of this plugin\'s reviewed network hosts') })
await assert.rejects(brokeredFetch(host, {}, 'https://evil.example/'), /egress refused/)
})
+12
View File
@@ -1942,6 +1942,18 @@ mod tests {
Some("disabled"),
"{body}"
);
let mut preview_client = modelstudio_test_client(
"https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic",
);
preview_client.route_facts = preview_client.catalog_facts_for_model("qwen4-max-preview");
for effort in [None, Some("high"), Some("max"), Some("off")] {
let mut request = request_with("qwen4-max-preview", effort, None, None);
request.max_tokens = 64_000;
let body = preview_client.build_anthropic_body(&request, true);
assert!(body.get("thinking").is_none(), "{effort:?}: {body}");
assert!(body.get("output_config").is_none(), "{effort:?}: {body}");
}
}
#[test]
+1
View File
@@ -5443,6 +5443,7 @@ mod alias_thinking_detection_tests {
"foo-reasoner",
"new-model-reasoning",
"acme-reasoner-v2",
"qwen4-max-preview",
] {
assert!(
!should_replay_reasoning_content_for_provider_on_route(
+143 -20
View File
@@ -67,6 +67,7 @@
//! tool's `core/call`s run beside it, not through the lock.
use std::collections::HashMap;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -264,7 +265,8 @@ pub struct Invocation {
calls: CodemodeInvoker,
gate: NestedCallGate,
root_caller: Option<SessionCheckpointCaller>,
broker_session: Option<String>,
broker: Option<BrokerScope>,
broker_core_calls: AtomicU32,
tickets: Vec<Ticket>,
caller_tools: Vec<String>,
activations: Mutex<
@@ -282,6 +284,103 @@ pub struct Invocation {
cancel: CancellationToken,
}
struct BrokerScope {
session_id: String,
context: Arc<ToolContext>,
}
pub struct BrokerContext {
invocation: Arc<Invocation>,
tool: Arc<ToolContext>,
service: DshService,
cancel: CancellationToken,
}
impl BrokerContext {
#[must_use]
pub fn tool(&self) -> &ToolContext {
&self.tool
}
#[must_use]
pub fn cancel(&self) -> &CancellationToken {
&self.cancel
}
pub async fn core_call(
&self,
name: &str,
input: Value,
) -> Result<ToolCallResponse, BrokerRefusal> {
if self.cancel.is_cancelled() {
return Err(BrokerRefusal::nested(
self.service,
NestedFailure::Unavailable("the broker call was withdrawn".to_string()),
));
}
if self
.invocation
.broker_core_calls
.fetch_add(1, Ordering::SeqCst)
>= MAX_CALLS_PER_INVOCATION
{
return Err(BrokerRefusal::nested(
self.service,
NestedFailure::Rejected {
decision: NestedDecision::Refused,
message: format!(
"an invocation's brokers may make at most {MAX_CALLS_PER_INVOCATION} core tool calls"
),
},
));
}
self.invocation
.calls
.call(name.to_string(), input, Some(&self.cancel))
.await
.map_err(|failure| BrokerRefusal::nested(self.service, failure))
}
}
#[cfg(test)]
impl BrokerContext {
pub fn detached_for_test(owner: &OwnerRef, service: DshService) -> Self {
let tool = ToolContext::new(std::path::PathBuf::from("."));
let gate = NestedCallGate::admitting_for_test();
let calls = CodemodeInvoker::for_extension(
Vec::new(),
tool.clone(),
gate.clone(),
String::new(),
|_, _, _| None,
);
let invocation = Arc::new(Invocation {
tier: HostTier::Plugin,
host_generation: 0,
owner: owner.clone(),
scope: None,
plugins: None,
content_hash: String::new(),
calls,
gate,
root_caller: None,
broker: None,
broker_core_calls: AtomicU32::new(0),
tickets: Vec::new(),
caller_tools: Vec::new(),
activations: Mutex::new(HashMap::new()),
session_call_lock: tokio::sync::Mutex::new(()),
cancel: CancellationToken::new(),
});
Self {
invocation,
tool: Arc::new(tool),
service,
cancel: CancellationToken::new(),
}
}
}
/// The tickets and invocations of one manager.
#[derive(Default)]
pub struct CoreCalls {
@@ -416,23 +515,36 @@ impl CoreCalls {
let broker_tickets: Vec<(DshService, Ticket)> = broker_session
.iter()
.flat_map(|session| {
DshService::ALL.into_iter().map(move |service| {
(
service,
self.tickets.mint(Grant {
kind: TicketKind::Invocation,
tier,
host_generation,
owner: owner.clone(),
method: dsh_broker::ticket_method(service),
target: json!({ "call_id": call_id, "session_id": session }),
ttl: TICKET_TTL,
uses: MAX_CALLS_PER_INVOCATION,
}),
)
})
DshService::ALL
.into_iter()
.filter(|service| dsh_broker::granted(*service))
.map(move |service| {
(
service,
self.tickets.mint(Grant {
kind: TicketKind::Invocation,
tier,
host_generation,
owner: owner.clone(),
method: dsh_broker::ticket_method(service),
target: json!({ "call_id": call_id, "session_id": session }),
ttl: TICKET_TTL,
uses: MAX_CALLS_PER_INVOCATION,
}),
)
})
})
.collect();
let broker = broker_session
.filter(|_| !broker_tickets.is_empty())
.map(|session_id| {
let mut context = context.clone();
context.execution.nested_call_gate = None;
BrokerScope {
session_id,
context: Arc::new(context),
}
});
let caller_tools = specs
.iter()
.map(|spec| spec.name().to_string())
@@ -453,7 +565,8 @@ impl CoreCalls {
calls,
gate: gate.clone(),
root_caller,
broker_session,
broker,
broker_core_calls: AtomicU32::new(0),
tickets: std::iter::once(ticket.clone())
.chain(checkpoint_ticket.iter().cloned())
.chain(question_ticket.iter().cloned())
@@ -1154,10 +1267,14 @@ impl CoreCalls {
&cx,
)
.map_err(|error| dsh_broker::coded(error, dsh_broker::TICKET_REFUSED))?;
let session_id = invocation
.broker_session
.clone()
let (session_id, tool) = invocation
.broker
.as_ref()
.map(|scope| (scope.session_id.clone(), Arc::clone(&scope.context)))
.ok_or_else(|| refused("dsh/broker requires an invocation in a session".into()))?;
if !dsh_broker::granted(service) {
return Err(BrokerRefusal::NotAvailable(service).wire());
}
let arm = dsh_broker::arm_in(table, service)
.ok_or_else(|| BrokerRefusal::NotAvailable(service).wire())?;
let withdraw = invocation.cancel.child_token();
@@ -1168,6 +1285,12 @@ impl CoreCalls {
service,
method: params.method,
input: params.input,
context: BrokerContext {
invocation: Arc::clone(&invocation),
tool,
service,
cancel: withdraw.clone(),
},
};
let outcome = tokio::select! {
biased;
@@ -625,6 +625,7 @@ struct Fixture {
other: OwnerRef,
context: ToolContext,
gate: NestedCallGate,
server: Arc<Server>,
}
fn fixture(uses_server: bool) -> Fixture {
@@ -651,13 +652,15 @@ fn fixture(uses_server: bool) -> Fixture {
let (tx_event, mut rx_event) = mpsc::channel(8);
tokio::spawn(async move { while rx_event.recv().await.is_some() {} });
let (gate, requests) = NestedCallGate::new(None, tx_event, Duration::from_secs(60));
let server = Arc::new(Server::default());
if uses_server {
serve(requests, Arc::new(Server::default()));
serve(requests, Arc::clone(&server));
}
Fixture {
manager,
owner,
other,
server,
context: ToolContext::new(&workspace),
gate: gate.for_extension(
ExtensionCaller {
@@ -1741,6 +1744,21 @@ impl Fixture {
input: Value,
) -> Result<Value, RpcErrorWire> {
let (cx, _violations, _cancel) = HostRequestContext::for_test(40);
self.broker_in(cx, table, owner, ticket, service, method, input)
.await
}
#[allow(clippy::too_many_arguments)]
async fn broker_in(
&self,
cx: HostRequestContext,
table: &[(super::protocol::DshService, super::dsh_broker::Arm)],
owner: &OwnerRef,
ticket: &str,
service: &str,
method: &str,
input: Value,
) -> Result<Value, RpcErrorWire> {
self.manager
.shared
.core_calls
@@ -1779,6 +1797,7 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
use super::protocol::DshService;
use std::collections::BTreeMap;
let _grants = super::dsh_broker::TestGrants::only(&DshService::ALL);
let mut f = fixture(true);
let outside = f.begin(7, &f.owner);
assert!(
@@ -1799,7 +1818,10 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
let echo: &[(DshService, super::dsh_broker::Arm)] =
&[(DshService::Workspace, echo_arm as super::dsh_broker::Arm)];
for service in DshService::ALL {
for service in DshService::ALL
.into_iter()
.filter(|s| *s != DshService::Network)
{
let ticket = &tickets_a[service.name()];
let result = f
.broker(
@@ -1987,3 +2009,562 @@ async fn dsh_broker_tickets_bind_plugin_service_and_session_and_every_refusal_is
);
}
}
fn broker_ticket(guard: &super::core_call::InvocationGuard, service: &str) -> Option<String> {
guard
.broker_tickets()?
.into_iter()
.find(|wire| wire.service == service)
.map(|wire| wire.ticket)
}
fn every_service(
arm: super::dsh_broker::Arm,
) -> Vec<(super::protocol::DshService, super::dsh_broker::Arm)> {
super::protocol::DshService::ALL
.into_iter()
.map(|service| (service, arm))
.collect()
}
#[tokio::test]
async fn dsh_broker_services_are_granted_by_one_check_and_an_ungranted_service_is_refused() {
use super::dsh_broker::{TABLE, TestGrants};
use super::protocol::DshService;
let mut f = fixture(true);
f.in_session("grant-session");
let echo = every_service(echo_arm);
assert!(
DshService::ALL
.into_iter()
.all(|service| !super::dsh_broker::granted(service))
);
let none = f.begin(7, &f.owner);
assert!(
none.broker_tickets().is_none(),
"nothing granted, no broker ticket"
);
drop(none);
for service in DshService::ALL {
let grants = TestGrants::only(&[service]);
let guard = f.begin(7, &f.owner);
let tickets = guard.broker_tickets().unwrap();
assert_eq!(
tickets
.iter()
.map(|wire| wire.service.as_str())
.collect::<Vec<_>>(),
vec![service.name()],
"only the granted service is ticketed"
);
let ticket = &tickets[0].ticket;
let served = f
.broker(
&echo,
&f.owner,
ticket,
service.name(),
"echo",
json!({"text": "ok"}),
)
.await
.unwrap();
assert_eq!(served["service"], service.name());
for other in DshService::ALL
.into_iter()
.filter(|other| *other != service)
{
let result = f
.broker(
&echo,
&f.owner,
ticket,
other.name(),
"echo",
json!({"text": "x"}),
)
.await;
assert_eq!(
dsh_refusal(&result),
(error_code::REFUSED, "ticket_refused".into()),
"{} with a {} ticket",
other.name(),
service.name()
);
}
if service != DshService::Network {
let result = f
.broker(
TABLE,
&f.owner,
ticket,
service.name(),
"echo",
json!({"text": "x"}),
)
.await;
assert_eq!(
dsh_refusal(&result),
(error_code::NOT_AVAILABLE, "not_available".into()),
"the landed {} stub still refuses",
service.name()
);
}
drop(grants);
let withdrawn = f
.broker(
&echo,
&f.owner,
ticket,
service.name(),
"echo",
json!({"text": "x"}),
)
.await;
assert_eq!(
dsh_refusal(&withdrawn),
(error_code::NOT_AVAILABLE, "not_available".into()),
"a grant withdrawn after minting refuses {} before the arm runs",
service.name()
);
assert_eq!(
withdrawn.unwrap_err().message,
format!("the {} broker is not available yet", service.name()),
"the refusal names no grant"
);
drop(guard);
}
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
fn context_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
Box::pin(async move {
let tool = call.context.tool();
Ok::<_, super::dsh_broker::BrokerRefusal>(json!({
"session": call.session_id,
"plugin": call.owner.plugin_id,
"workspace": tool.workspace.display().to_string(),
"tool_session": tool.session_objects.as_ref().map(|session| session.session_id.clone()),
"approval_mode": format!("{:?}", tool.approval_mode),
"trust_mode": tool.trust_mode,
"disallowed": tool.disallowed_tools,
"nested_gate_visible": tool.nested_call_gate.is_some(),
}))
})
}
#[tokio::test]
async fn a_broker_arm_acts_with_its_invocations_context_whatever_the_plugin_sends() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Workspace]);
let table = every_service(context_arm);
let mut f = fixture(true);
f.in_session("context-a");
f.context.approval_mode = codewhale_execpolicy::ApprovalMode::Never;
f.context.trust_mode = false;
f.context.disallowed_tools = vec!["exec_shell".into()];
let workspace_a = f.context.workspace.display().to_string();
let guard_a = f.begin(7, &f.owner);
let ticket_a = broker_ticket(&guard_a, "workspace").unwrap();
let other_dir = tempfile::tempdir().unwrap();
let mut other = f.context.clone();
other.workspace = other_dir.path().to_path_buf();
other.approval_mode = codewhale_execpolicy::ApprovalMode::Bypass;
other.trust_mode = true;
other.disallowed_tools = Vec::new();
let saved = std::mem::replace(&mut f.context, other);
f.in_session("context-b");
let guard_b = f.begin(7, &f.owner);
let ticket_b = broker_ticket(&guard_b, "workspace").unwrap();
f.context = saved;
let forged = json!({
"workspace": "/etc",
"session_id": "context-b",
"approval_mode": "Bypass",
"trust_mode": true,
"owner": {"plugin_id": "b"},
"ticket": ticket_b,
});
let seen_a = f
.broker(
&table,
&f.owner,
&ticket_a,
"workspace",
"anything",
forged.clone(),
)
.await
.unwrap();
assert_eq!(
seen_a,
json!({
"session": "context-a",
"plugin": "a",
"workspace": workspace_a,
"tool_session": "context-a",
"approval_mode": "Never",
"trust_mode": false,
"disallowed": ["exec_shell"],
"nested_gate_visible": false,
})
);
let seen_b = f
.broker(&table, &f.owner, &ticket_b, "workspace", "anything", forged)
.await
.unwrap();
assert_eq!(seen_b["session"], "context-b");
assert_eq!(seen_b["workspace"], other_dir.path().display().to_string());
assert_eq!(seen_b["approval_mode"], "Bypass");
assert_eq!(seen_b["trust_mode"], true);
let foreign = f
.broker(
&table,
&f.other,
&ticket_a,
"workspace",
"anything",
json!({}),
)
.await;
assert_eq!(
dsh_refusal(&foreign),
(error_code::REFUSED, "ticket_refused".into()),
"another plugin cannot borrow this invocation's context"
);
drop(guard_a);
drop(guard_b);
}
static CANCEL_ARM_STARTED: AtomicUsize = AtomicUsize::new(0);
static CANCEL_ARM_SAW: AtomicUsize = AtomicUsize::new(0);
fn cancel_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
let cancel = call.context.cancel().clone();
tokio::spawn(async move {
cancel.cancelled().await;
CANCEL_ARM_SAW.fetch_add(1, Ordering::SeqCst);
});
Box::pin(async move {
assert!(!call.context.cancel().is_cancelled());
CANCEL_ARM_STARTED.fetch_add(1, Ordering::SeqCst);
std::future::pending::<Result<Value, super::dsh_broker::BrokerRefusal>>().await
})
}
#[tokio::test]
async fn a_broker_arm_sees_its_call_cancelled_by_the_host_the_invocation_and_the_owner() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Terminal]);
let table = every_service(cancel_arm);
let mut f = fixture(true);
f.in_session("cancel-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "terminal").unwrap();
let (cx, _violations, host_cancel) = HostRequestContext::for_test(41);
let (result, ()) = tokio::join!(
f.broker_in(cx, &table, &f.owner, &ticket, "terminal", "run", json!({})),
async {
until("the arm to start", || {
CANCEL_ARM_STARTED.load(Ordering::SeqCst) == 1
})
.await;
host_cancel.cancel();
}
);
assert_eq!(
dsh_refusal(&result),
(error_code::CANCELLED, "cancelled".into())
);
until("the arm to see the host's cancel", || {
CANCEL_ARM_SAW.load(Ordering::SeqCst) == 1
})
.await;
let (cx, _violations, _host_cancel) = HostRequestContext::for_test(42);
let (result, ()) = tokio::join!(
f.broker_in(cx, &table, &f.owner, &ticket, "terminal", "run", json!({})),
async {
until("the arm to start again", || {
CANCEL_ARM_STARTED.load(Ordering::SeqCst) == 2
})
.await;
drop(guard);
}
);
assert_eq!(
dsh_refusal(&result),
(error_code::CANCELLED, "cancelled".into())
);
until("the arm to see the invocation end", || {
CANCEL_ARM_SAW.load(Ordering::SeqCst) == 2
})
.await;
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "terminal").unwrap();
let (cx, _violations, _host_cancel) = HostRequestContext::for_test(43);
let (result, ()) = tokio::join!(
f.broker_in(cx, &table, &f.owner, &ticket, "terminal", "run", json!({})),
async {
until("the arm to start a third time", || {
CANCEL_ARM_STARTED.load(Ordering::SeqCst) == 3
})
.await;
f.manager.shared.core_calls.revoke_owner("a");
}
);
assert_eq!(
dsh_refusal(&result),
(error_code::CANCELLED, "cancelled".into())
);
until("the arm to see the owner revoked", || {
CANCEL_ARM_SAW.load(Ordering::SeqCst) == 3
})
.await;
drop(guard);
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
#[derive(serde::Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum NestedMethod {
#[serde(rename = "core")]
Core { name: String, input: Value },
}
impl super::dsh_broker::BrokerMethods for NestedMethod {
const NAMES: &'static [&'static str] = &["core"];
}
fn nested_arm(call: super::dsh_broker::BrokerCall) -> super::dsh_broker::BrokerFuture {
Box::pin(async move {
let NestedMethod::Core { name, input } = super::dsh_broker::decode::<NestedMethod>(&call)?;
let response = call.context.core_call(&name, input).await?;
Ok(json!({ "ok": response.ok, "result": response.result }))
})
}
#[tokio::test]
async fn a_brokers_nested_core_call_goes_through_the_same_gate_as_a_direct_call() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Workspace]);
let table = every_service(nested_arm);
let mut f = fixture(true);
f.in_session("nested-session");
let guard = f.begin(7, &f.owner);
let ticket = broker_ticket(&guard, "workspace").unwrap();
let ran = f
.broker(
&table,
&f.owner,
&ticket,
"workspace",
"core",
json!({"name": "read_file", "input": {"path": "x"}}),
)
.await
.unwrap();
assert_eq!(ran["ok"], true, "{ran}");
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
1,
"the gate was asked"
);
for refused_name in ["remember", "execute_tools", "tool_search", "mcp__any__tool"] {
let result = f
.broker(
&table,
&f.owner,
&ticket,
"workspace",
"core",
json!({"name": refused_name, "input": {}}),
)
.await;
assert_eq!(
dsh_refusal(&result),
(error_code::REFUSED, "refused".into()),
"{refused_name}"
);
}
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
1,
"a refused name never reaches the gate, as with core/call"
);
f.server.hold.store(true, Ordering::SeqCst);
let (cx, _violations, host_cancel) = HostRequestContext::for_test(44);
let (result, ()) = tokio::join!(
f.broker_in(
cx,
&table,
&f.owner,
&ticket,
"workspace",
"core",
json!({"name": "read_file", "input": {"path": "x"}}),
),
async {
until("the card to open", || {
f.server.asked.load(Ordering::SeqCst) == 2
})
.await;
host_cancel.cancel();
}
);
assert_eq!(
dsh_refusal(&result),
(error_code::CANCELLED, "cancelled".into())
);
until("the open card to be withdrawn", || {
f.server.withdrawn.load(Ordering::SeqCst) == 1
})
.await;
f.server.hold.store(false, Ordering::SeqCst);
let receipts = guard.receipts().expect("nested calls are receipted");
assert!(receipts["total"].as_u64().unwrap() >= 2, "{receipts}");
drop(guard);
}
#[tokio::test]
async fn an_invocations_brokers_share_one_cap_on_nested_core_calls() {
use super::dsh_broker::TestGrants;
use super::protocol::DshService;
let _grants = TestGrants::only(&[DshService::Workspace, DshService::Terminal]);
let table = every_service(nested_arm);
let mut f = fixture(true);
f.in_session("cap-session");
let guard = f.begin(7, &f.owner);
let workspace = broker_ticket(&guard, "workspace").unwrap();
let terminal = broker_ticket(&guard, "terminal").unwrap();
let call = json!({"name": "read_file", "input": {"path": "x"}});
for n in 0..MAX_CALLS_PER_INVOCATION {
let (ticket, service) = if n % 2 == 0 {
(&workspace, "workspace")
} else {
(&terminal, "terminal")
};
let ran = f
.broker(&table, &f.owner, ticket, service, "core", call.clone())
.await
.unwrap();
assert_eq!(ran["ok"], true);
}
let capped = f
.broker(
&table,
&f.owner,
&workspace,
"workspace",
"core",
call.clone(),
)
.await;
assert_eq!(
dsh_refusal(&capped),
(error_code::REFUSED, "refused".into())
);
assert_eq!(
f.server.asked.load(Ordering::SeqCst),
MAX_CALLS_PER_INVOCATION as usize
);
drop(guard);
}
#[tokio::test]
async fn dsh_network_arm_refuses_foreign_tickets_unknown_methods_and_revoked_owners() {
use super::dsh_broker::TABLE;
use super::protocol::DshService;
use std::collections::BTreeMap;
let _grants =
super::dsh_broker::TestGrants::only(&[DshService::Workspace, DshService::Network]);
let mut f = fixture(true);
f.in_session("net-session-a");
let guard_a = f.begin(7, &f.owner);
let tickets: BTreeMap<String, String> = guard_a
.broker_tickets()
.unwrap()
.into_iter()
.map(|wire| (wire.service, wire.ticket))
.collect();
let network = tickets["network"].clone();
let fetch = json!({"url": "https://example.com/"});
let unknown = f
.broker(TABLE, &f.owner, &network, "network", "connect", json!({}))
.await;
assert_eq!(
dsh_refusal(&unknown),
(error_code::INVALID_PARAMS, "unknown_method".into())
);
let withheld = f
.broker(TABLE, &f.owner, &network, "network", "fetch", fetch.clone())
.await;
assert_eq!(
dsh_refusal(&withheld),
(error_code::NOT_AVAILABLE, "not_available".into())
);
for (owner, ticket) in [
(&f.other, network.as_str()),
(&f.owner, tickets["workspace"].as_str()),
(&f.owner, guard_a.ticket()),
] {
let result = f
.broker(TABLE, owner, ticket, "network", "fetch", fetch.clone())
.await;
assert_eq!(
dsh_refusal(&result),
(error_code::REFUSED, "ticket_refused".into())
);
}
f.in_session("net-session-b");
let guard_b = f.begin(7, &f.owner);
let network_b = guard_b
.broker_tickets()
.unwrap()
.into_iter()
.find(|wire| wire.service == "network")
.unwrap()
.ticket;
drop(guard_a);
let other_session = f
.broker(TABLE, &f.owner, &network, "network", "fetch", fetch.clone())
.await;
assert_eq!(
dsh_refusal(&other_session),
(error_code::REFUSED, "ticket_refused".into())
);
f.manager.shared.core_calls.revoke_owner("a");
let revoked = f
.broker(TABLE, &f.owner, &network_b, "network", "fetch", fetch)
.await;
assert_eq!(
dsh_refusal(&revoked),
(error_code::REFUSED, "ticket_refused".into())
);
drop(guard_b);
assert_eq!(f.manager.shared.core_calls.live_tickets(), 0);
}
@@ -9,6 +9,9 @@ use super::protocol::{DshService, RpcErrorWire, error_code};
use super::{
dsh_catalog, dsh_llm, dsh_network, dsh_remote, dsh_settings, dsh_terminal, dsh_workspace,
};
use crate::tools::codemode::{NestedDecision, NestedFailure};
pub use super::core_call::BrokerContext;
pub struct BrokerCall {
pub owner: OwnerRef,
@@ -16,6 +19,7 @@ pub struct BrokerCall {
pub service: DshService,
pub method: String,
pub input: Value,
pub context: BrokerContext,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -30,6 +34,14 @@ pub enum BrokerRefusal {
message: String,
},
NotAvailable(DshService),
Denied {
service: DshService,
message: String,
},
Refused {
service: DshService,
message: String,
},
}
impl BrokerRefusal {
@@ -40,6 +52,8 @@ impl BrokerRefusal {
Self::UnknownMethod { .. } => "unknown_method",
Self::InvalidInput { .. } => "invalid_input",
Self::NotAvailable(_) => "not_available",
Self::Denied { .. } => "denied",
Self::Refused { .. } => "refused",
}
}
@@ -71,6 +85,16 @@ impl BrokerRefusal {
format!("the {} broker is not available yet", service.name()),
Value::String(service.name().into()),
),
Self::Denied { service, message } => (
error_code::DENIED,
message,
Value::String(service.name().into()),
),
Self::Refused { service, message } => (
error_code::REFUSED,
message,
Value::String(service.name().into()),
),
};
RpcErrorWire {
code: rpc,
@@ -78,6 +102,19 @@ impl BrokerRefusal {
data: Some(json!({ "dsh_refusal": code, "service": service })),
}
}
#[must_use]
pub fn nested(service: DshService, failure: NestedFailure) -> Self {
match failure {
NestedFailure::Rejected {
decision: NestedDecision::Denied,
message,
} => Self::Denied { service, message },
NestedFailure::Rejected { message, .. } | NestedFailure::Unavailable(message) => {
Self::Refused { service, message }
}
}
}
}
pub const TICKET_REFUSED: &str = "ticket_refused";
@@ -102,6 +139,41 @@ pub const TABLE: &[(DshService, Arm)] = &[
(DshService::Remote, dsh_remote::serve as Arm),
];
const GRANTED: &[DshService] = &[];
#[cfg(test)]
thread_local! {
static TEST_GRANTS: std::cell::RefCell<Option<Vec<DshService>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub struct TestGrants(Option<Vec<DshService>>);
#[cfg(test)]
impl TestGrants {
pub fn only(services: &[DshService]) -> Self {
Self(TEST_GRANTS.with(|grants| grants.replace(Some(services.to_vec()))))
}
}
#[cfg(test)]
impl Drop for TestGrants {
fn drop(&mut self) {
let previous = self.0.take();
TEST_GRANTS.with(|grants| *grants.borrow_mut() = previous);
}
}
#[must_use]
pub fn granted(service: DshService) -> bool {
#[cfg(test)]
if let Some(granted) = TEST_GRANTS.with(|grants| grants.borrow().clone()) {
return granted.contains(&service);
}
GRANTED.contains(&service)
}
#[must_use]
pub fn arm_in(table: &[(DshService, Arm)], service: DshService) -> Option<Arm> {
table
+841 -4
View File
@@ -1,6 +1,843 @@
use super::dsh_broker::{BrokerCall, BrokerFuture, not_available};
use super::protocol::DshService;
use std::collections::BTreeSet;
use std::time::{Duration, Instant};
pub(super) fn serve(_call: BrokerCall) -> BrokerFuture {
not_available(DshService::Network)
use base64::{Engine as _, engine::general_purpose::STANDARD};
use codewhale_secrets::Secrets;
use serde::Deserialize;
use serde_json::{Value, json};
use super::dsh_broker::{BrokerCall, BrokerFuture, BrokerMethods, BrokerRefusal, decode};
use super::protocol::DshService;
use crate::tools::spec::ToolContext;
use crate::tools::web::guard::validate_fetch_target;
pub const CREDENTIAL_HANDLE_PREFIX: &str = "codewhale-credential:";
const MAX_REF_LEN: usize = 128;
const MAX_REDIRECTS: usize = 5;
const MAX_HEADERS: usize = 64;
const MAX_HEADER_BYTES: usize = 16 * 1024;
const MAX_REQUEST_BYTES: usize = 8 * 1024 * 1024;
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_TIMEOUT: Duration = Duration::from_secs(120);
const AUDIT_LABEL: &str = "dsh_network";
const FORBIDDEN_HEADERS: &[&str] = &[
"host",
"connection",
"content-length",
"transfer-encoding",
"te",
"trailer",
"upgrade",
"keep-alive",
"proxy-authorization",
"proxy-connection",
];
const METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
#[derive(Deserialize)]
#[serde(tag = "method", content = "input", deny_unknown_fields)]
enum NetworkMethod {
#[serde(rename = "fetch")]
Fetch(FetchInput),
#[serde(rename = "credentials.describe")]
Describe(CredentialRef),
#[serde(rename = "credentials.resolve")]
Resolve(CredentialRef),
#[serde(rename = "credentials.set")]
Set(CredentialWrite),
}
impl BrokerMethods for NetworkMethod {
const NAMES: &'static [&'static str] = &[
"fetch",
"credentials.describe",
"credentials.resolve",
"credentials.set",
];
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct FetchInput {
url: String,
#[serde(default)]
method: Option<String>,
#[serde(default)]
headers: Vec<(String, String)>,
#[serde(default)]
body_base64: Option<String>,
#[serde(default)]
timeout_ms: Option<u64>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CredentialRef {
#[serde(rename = "ref")]
name: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CredentialWrite {
#[serde(rename = "ref")]
name: String,
value: String,
}
pub(super) struct NetworkAuthority {
pub hosts: BTreeSet<String>,
pub tool: Option<ToolContext>,
pub vault: Option<Secrets>,
}
impl NetworkAuthority {
fn withheld() -> Self {
Self {
hosts: BTreeSet::new(),
tool: None,
vault: None,
}
}
}
pub(super) fn serve(call: BrokerCall) -> BrokerFuture {
serve_with(call, NetworkAuthority::withheld())
}
pub(super) fn serve_with(call: BrokerCall, authority: NetworkAuthority) -> BrokerFuture {
Box::pin(async move {
let method = decode::<NetworkMethod>(&call)?;
let plugin = call.owner.plugin_id.clone();
match method {
NetworkMethod::Fetch(input) => {
let Some(tool) = authority.tool.as_ref() else {
return Err(BrokerRefusal::NotAvailable(DshService::Network));
};
fetch(
&plugin,
&authority.hosts,
tool,
authority.vault.as_ref(),
input,
)
.await
}
NetworkMethod::Describe(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
let set = read_secret(vault, &plugin, name)?.is_some();
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential described");
Ok(json!({ "ref": name, "set": set, "handle": handle(name) }))
}
NetworkMethod::Resolve(CredentialRef { name }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
let set = read_secret(vault, &plugin, name)?.is_some();
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, set, "credential resolved to its handle");
Ok(json!({ "ref": name, "value": set.then(|| handle(name)) }))
}
NetworkMethod::Set(CredentialWrite { name, value }) => {
let vault = vault(&authority)?;
let name = checked_ref(&name)?;
if value.is_empty() || value.contains(CREDENTIAL_HANDLE_PREFIX) {
return Err(invalid(
"a credential value must be a non-empty secret, not a handle",
));
}
vault
.set(&vault_slot(&plugin, name), &value)
.map_err(|_| invalid("the credential vault refused the write"))?;
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, credential = %name, "credential stored");
Ok(json!({ "ref": name, "set": true, "handle": handle(name) }))
}
}
})
}
fn invalid(message: impl Into<String>) -> BrokerRefusal {
BrokerRefusal::InvalidInput {
service: DshService::Network,
message: message.into(),
}
}
fn vault(authority: &NetworkAuthority) -> Result<&Secrets, BrokerRefusal> {
authority
.vault
.as_ref()
.ok_or(BrokerRefusal::NotAvailable(DshService::Network))
}
fn checked_ref(name: &str) -> Result<&str, BrokerRefusal> {
let ok = !name.is_empty()
&& name.len() <= MAX_REF_LEN
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'));
if ok {
Ok(name)
} else {
Err(invalid(format!(
"a credential ref is 1 to {MAX_REF_LEN} letters, digits, `_`, `-` or `.`"
)))
}
}
fn handle(name: &str) -> String {
format!("{CREDENTIAL_HANDLE_PREFIX}{name}")
}
fn vault_slot(plugin: &str, name: &str) -> String {
format!("dsh-plugin:{plugin}:{name}")
}
fn read_secret(vault: &Secrets, plugin: &str, name: &str) -> Result<Option<String>, BrokerRefusal> {
vault
.get(&vault_slot(plugin, name))
.map(|value| value.filter(|value| !value.is_empty()))
.map_err(|_| invalid("the credential vault could not be read"))
}
fn normalized_host(host: &str) -> String {
host.trim()
.trim_start_matches('[')
.trim_end_matches(']')
.trim_end_matches('.')
.to_ascii_lowercase()
}
fn same_origin(a: &reqwest::Url, b: &reqwest::Url) -> bool {
a.scheme() == b.scheme()
&& a.host_str().map(normalized_host) == b.host_str().map(normalized_host)
&& a.port_or_known_default() == b.port_or_known_default()
}
fn egress_host(url: &reqwest::Url, hosts: &BTreeSet<String>) -> Result<String, BrokerRefusal> {
if url.scheme() != "http" && url.scheme() != "https" {
return Err(invalid(
"egress refused: only http and https URLs are brokered",
));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(invalid("egress refused: URLs may not carry userinfo"));
}
let host = url
.host_str()
.map(normalized_host)
.ok_or_else(|| invalid("egress refused: the URL has no host"))?;
if hosts.iter().any(|allowed| normalized_host(allowed) == host) {
Ok(host)
} else {
Err(invalid(format!(
"egress refused: `{}` is not one of this plugin's reviewed network hosts",
host.chars().take(253).collect::<String>()
)))
}
}
struct Substituted {
headers: Vec<(reqwest::header::HeaderName, reqwest::header::HeaderValue)>,
secrets: Vec<(String, String)>,
}
fn substitute_headers(
plugin: &str,
vault: Option<&Secrets>,
headers: &[(String, String)],
) -> Result<Substituted, BrokerRefusal> {
let mut out = Vec::with_capacity(headers.len());
let mut secrets: Vec<(String, String)> = Vec::new();
for (name, value) in headers {
let mut resolved = String::with_capacity(value.len());
let mut rest = value.as_str();
while let Some(at) = rest.find(CREDENTIAL_HANDLE_PREFIX) {
resolved.push_str(&rest[..at]);
let tail = &rest[at + CREDENTIAL_HANDLE_PREFIX.len()..];
let end = tail
.find(|c: char| !(c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.')))
.unwrap_or(tail.len());
let reference = checked_ref(&tail[..end])?;
let vault = vault.ok_or(BrokerRefusal::NotAvailable(DshService::Network))?;
let secret = read_secret(vault, plugin, reference)?.ok_or_else(|| {
invalid(format!(
"credential `{reference}` is not set for this plugin"
))
})?;
resolved.push_str(&secret);
if !secrets.iter().any(|(_, known)| *known == secret) {
secrets.push((handle(reference), secret));
}
rest = &tail[end..];
}
resolved.push_str(rest);
let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|_| invalid("a request header name is not valid"))?;
let header_value = reqwest::header::HeaderValue::from_str(&resolved).map_err(|_| {
invalid(format!(
"request header `{header_name}` has an invalid value"
))
})?;
out.push((header_name, header_value));
}
Ok(Substituted {
headers: out,
secrets,
})
}
fn scrub(bytes: &[u8], secrets: &[(String, String)]) -> Vec<u8> {
let mut out = bytes.to_vec();
for (handle, secret) in secrets {
let needle = secret.as_bytes();
if needle.is_empty() {
continue;
}
let mut next = Vec::with_capacity(out.len());
let mut i = 0;
while i < out.len() {
if out[i..].starts_with(needle) {
next.extend_from_slice(handle.as_bytes());
i += needle.len();
} else {
next.push(out[i]);
i += 1;
}
}
out = next;
}
out
}
async fn fetch(
plugin: &str,
hosts: &BTreeSet<String>,
tool: &ToolContext,
vault: Option<&Secrets>,
input: FetchInput,
) -> Result<Value, BrokerRefusal> {
let mut url = reqwest::Url::parse(&input.url).map_err(|_| invalid("the URL is not valid"))?;
if input.url.contains(CREDENTIAL_HANDLE_PREFIX) {
return Err(invalid(
"credential handles are substituted only in request header values",
));
}
let mut method = input
.method
.as_deref()
.unwrap_or("GET")
.to_ascii_uppercase();
if !METHODS.contains(&method.as_str()) {
return Err(invalid(
"the request method is not one the network broker sends",
));
}
if input.headers.len() > MAX_HEADERS
|| input
.headers
.iter()
.map(|(name, value)| name.len() + value.len())
.sum::<usize>()
> MAX_HEADER_BYTES
{
return Err(invalid("too many or too large request headers"));
}
if let Some((name, _)) = input.headers.iter().find(|(name, _)| {
FORBIDDEN_HEADERS
.iter()
.any(|forbidden| name.eq_ignore_ascii_case(forbidden))
}) {
return Err(invalid(format!(
"request header `{}` is set by the broker, not the plugin",
name.chars().take(64).collect::<String>()
)));
}
let mut body = match input.body_base64.as_deref() {
Some(encoded) => {
let bytes = STANDARD
.decode(encoded)
.map_err(|_| invalid("body_base64 is not valid base64"))?;
if bytes.len() > MAX_REQUEST_BYTES {
return Err(invalid("the request body is too large"));
}
Some(bytes)
}
None => None,
};
let timeout = input
.timeout_ms
.map_or(DEFAULT_TIMEOUT, Duration::from_millis)
.min(MAX_TIMEOUT);
let deadline = Instant::now() + timeout;
let origin = url.clone();
let host = egress_host(&url, hosts)?;
let mut redirects = 0usize;
let mut substituted: Option<Substituted> = None;
let response = loop {
egress_host(&url, hosts)?;
let pin = validate_fetch_target(&url, tool, AUDIT_LABEL)
.await
.map_err(|error| invalid(format!("egress refused: {error}")))?;
let substitution = match substituted.take() {
Some(done) => done,
None => substitute_headers(plugin, vault, &input.headers)?,
};
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(invalid("the request timed out"));
}
let mut builder = codewhale_net::guard::guarded_reqwest_client_builder()
.timeout(remaining)
.redirect(reqwest::redirect::Policy::none());
if let Some((name, ip)) = pin {
builder = builder.resolve(&name, std::net::SocketAddr::new(ip, 0));
}
let client = builder
.build()
.map_err(|_| invalid("the network broker could not build its client"))?;
let verb = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|_| invalid("the request method is not valid"))?;
let mut request = client.request(verb, url.clone());
for (name, value) in &substitution.headers {
request = request.header(name, value);
}
if let Some(bytes) = body.clone() {
request = request.body(bytes);
}
let response = request.send().await.map_err(|error| {
invalid(format!(
"the request failed: {}",
if error.is_timeout() {
"timed out"
} else if error.is_connect() {
"could not connect"
} else {
"transport error"
}
))
})?;
substituted = Some(substitution);
let status = response.status();
if !status.is_redirection() {
break response;
}
let Some(location) = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
else {
break response;
};
if redirects >= MAX_REDIRECTS {
return Err(invalid("the request exceeded the five-redirect limit"));
}
let next = url
.join(location)
.map_err(|_| invalid("the redirect location is not valid"))?;
if !same_origin(&origin, &next) {
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, host = %host, "cross-origin redirect refused");
return Err(invalid(
"egress refused: a redirect may not leave the requested origin",
));
}
if status == reqwest::StatusCode::SEE_OTHER
|| (matches!(status.as_u16(), 301 | 302) && method == "POST")
{
method = "GET".into();
body = None;
}
url = next;
redirects += 1;
};
let secrets = substituted.map(|done| done.secrets).unwrap_or_default();
let status = response.status().as_u16();
let final_url = response.url().to_string();
let headers: Vec<(String, String)> = response
.headers()
.iter()
.filter_map(|(name, value)| {
let value = String::from_utf8(scrub(value.as_bytes(), &secrets)).ok()?;
Some((name.as_str().to_string(), value))
})
.collect();
let mut response = response;
let mut bytes = Vec::new();
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
let chunk = tokio::time::timeout(remaining, response.chunk())
.await
.map_err(|_| invalid("the response timed out"))?
.map_err(|_| invalid("the response body could not be read"))?;
let Some(chunk) = chunk else { break };
if bytes.len() + chunk.len() > MAX_RESPONSE_BYTES {
return Err(invalid(
"the response body is larger than the broker returns",
));
}
bytes.extend_from_slice(&chunk);
}
tracing::info!(target: "codewhale::dsh_network", plugin = %plugin, host = %host, status, redirects, "brokered fetch");
Ok(json!({
"status": status,
"url": final_url,
"redirected": redirects > 0,
"headers": headers,
"body_base64": STANDARD.encode(scrub(&bytes, &secrets)),
}))
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_config::OwnerRef;
use codewhale_net::network_policy::{Decision, NetworkPolicy, NetworkPolicyDecider};
use codewhale_secrets::InMemoryKeyringStore;
use std::sync::Arc;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const SECRET: &str = "sk-live-0123456789abcdef";
fn owner(plugin: &str) -> OwnerRef {
OwnerRef {
plugin_id: plugin.into(),
generation: 1,
owner_token: "t".into(),
}
}
fn call(plugin: &str, method: &str, input: Value) -> BrokerCall {
BrokerCall {
owner: owner(plugin),
session_id: "s".into(),
service: DshService::Network,
method: method.into(),
input,
context: super::super::dsh_broker::BrokerContext::detached_for_test(
&owner(plugin),
DshService::Network,
),
}
}
fn policy(allow: &[&str], deny: &[&str]) -> ToolContext {
let policy = NetworkPolicy {
default: Decision::Deny.into(),
allow: allow.iter().map(|h| (*h).to_string()).collect(),
deny: deny.iter().map(|h| (*h).to_string()).collect(),
proxy: Vec::new(),
proxy_fake_ip_cidrs: Vec::new(),
audit: false,
};
ToolContext::new(std::path::PathBuf::from("."))
.with_network_policy(NetworkPolicyDecider::new(policy, None))
}
fn authority(hosts: &[&str], tool: ToolContext, vault: &Secrets) -> NetworkAuthority {
NetworkAuthority {
hosts: hosts.iter().map(|h| (*h).to_string()).collect(),
tool: Some(tool),
vault: Some(vault.clone()),
}
}
fn vault() -> Secrets {
Secrets::new(Arc::new(InMemoryKeyringStore::new()))
}
fn code(result: &Result<Value, BrokerRefusal>) -> &'static str {
result.as_ref().expect_err("a refusal").code()
}
fn message(result: &Result<Value, BrokerRefusal>) -> String {
format!("{:?}", result.as_ref().expect_err("a refusal"))
}
struct Echo;
impl Respond for Echo {
fn respond(&self, request: &Request) -> ResponseTemplate {
let auth = request
.headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
ResponseTemplate::new(200)
.insert_header("x-echo", auth.as_str())
.set_body_string(format!("you sent {auth}"))
}
}
#[tokio::test]
async fn withheld_authority_refuses_typed_and_unknown_methods_are_named() {
let unknown = serve(call("a", "connect", json!({}))).await;
assert_eq!(code(&unknown), "unknown_method");
let bad = serve(call("a", "fetch", json!({"url": 1}))).await;
assert_eq!(code(&bad), "invalid_input");
for (method, input) in [
("fetch", json!({"url": "https://example.com/"})),
("credentials.resolve", json!({"ref": "K"})),
("credentials.describe", json!({"ref": "K"})),
("credentials.set", json!({"ref": "K", "value": "v"})),
] {
assert_eq!(
code(&serve(call("a", method, input)).await),
"not_available",
"{method}"
);
}
}
#[tokio::test]
async fn credentials_never_leave_rust_and_are_scoped_to_their_plugin() {
let vault = vault();
let tool = policy(&[], &[]);
let set = serve_with(
call(
"a",
"credentials.set",
json!({"ref": "OPENAI_API_KEY", "value": SECRET}),
),
authority(&[], tool.clone(), &vault),
)
.await
.unwrap();
assert!(!set.to_string().contains(SECRET));
let resolved = serve_with(
call("a", "credentials.resolve", json!({"ref": "OPENAI_API_KEY"})),
authority(&[], tool.clone(), &vault),
)
.await
.unwrap();
assert_eq!(resolved["value"], "codewhale-credential:OPENAI_API_KEY");
assert!(!resolved.to_string().contains(SECRET));
let foreign = serve_with(
call("b", "credentials.resolve", json!({"ref": "OPENAI_API_KEY"})),
authority(&[], tool.clone(), &vault),
)
.await
.unwrap();
assert_eq!(foreign["value"], Value::Null);
let traversal = serve_with(
call(
"b",
"credentials.describe",
json!({"ref": "../a:OPENAI_API_KEY"}),
),
authority(&[], tool, &vault),
)
.await;
assert_eq!(code(&traversal), "invalid_input");
}
#[tokio::test]
async fn allowed_host_gets_the_secret_and_the_wire_back_never_carries_it() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/v1/models"))
.and(header("authorization", format!("Bearer {SECRET}").as_str()))
.respond_with(Echo)
.expect(1)
.mount(&server)
.await;
let vault = vault();
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
let result = serve_with(
call(
"a",
"fetch",
json!({
"url": format!("{}/v1/models", server.uri()),
"headers": [["authorization", "Bearer codewhale-credential:KEY"]],
}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await
.unwrap();
assert_eq!(result["status"], 200);
let wire = result.to_string();
assert!(!wire.contains(SECRET), "{wire}");
let body = STANDARD
.decode(result["body_base64"].as_str().unwrap())
.unwrap();
assert_eq!(
String::from_utf8(body).unwrap(),
"you sent Bearer codewhale-credential:KEY"
);
assert!(
result["headers"]
.as_array()
.unwrap()
.iter()
.any(|pair| pair[0] == "x-echo" && pair[1] == "Bearer codewhale-credential:KEY")
);
}
#[tokio::test]
async fn unreviewed_denied_and_restricted_hosts_are_refused_before_any_connection() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let vault = vault();
let url = format!("{}/x", server.uri());
let unreviewed = serve_with(
call("a", "fetch", json!({"url": url})),
authority(&["api.example.com"], policy(&["127.0.0.1"], &[]), &vault),
)
.await;
assert_eq!(code(&unreviewed), "invalid_input");
assert!(message(&unreviewed).contains("reviewed network hosts"));
let denied = serve_with(
call("a", "fetch", json!({"url": url})),
authority(&["127.0.0.1"], policy(&[], &["127.0.0.1"]), &vault),
)
.await;
assert!(
message(&denied).contains("blocked by network policy"),
"{}",
message(&denied)
);
let unapproved = serve_with(
call("a", "fetch", json!({"url": url})),
authority(&["127.0.0.1"], policy(&[], &[]), &vault),
)
.await;
assert!(message(&unapproved).contains("egress refused"));
let metadata = serve_with(
call(
"a",
"fetch",
json!({"url": "http://169.254.169.254/latest"}),
),
authority(
&["169.254.169.254"],
policy(&["169.254.169.254"], &[]),
&vault,
),
)
.await;
assert!(
message(&metadata).contains("restricted"),
"{}",
message(&metadata)
);
let rebound = serve_with(
call("a", "fetch", json!({"url": "http://localhost/"})),
authority(
&["localhost"],
ToolContext::new(std::path::PathBuf::from(".")),
&vault,
),
)
.await;
assert!(
message(&rebound).contains("localhost"),
"{}",
message(&rebound)
);
let in_url = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{url}?k=codewhale-credential:KEY")}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await;
assert_eq!(code(&in_url), "invalid_input");
let host_header = serve_with(
call(
"a",
"fetch",
json!({"url": url, "headers": [["Host", "evil.example"]]}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await;
assert_eq!(code(&host_header), "invalid_input");
}
#[tokio::test]
async fn redirects_stay_on_origin_and_never_carry_the_secret_elsewhere() {
let server = MockServer::start().await;
let elsewhere = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&elsewhere)
.await;
Mock::given(path("/away"))
.respond_with(
ResponseTemplate::new(302)
.insert_header("location", format!("{}/steal", elsewhere.uri()).as_str()),
)
.mount(&server)
.await;
Mock::given(path("/rename"))
.respond_with(ResponseTemplate::new(302).insert_header(
"location",
server.uri().replace("127.0.0.1", "localhost").as_str(),
))
.mount(&server)
.await;
Mock::given(path("/hop"))
.respond_with(ResponseTemplate::new(307).insert_header("location", "/landing"))
.mount(&server)
.await;
Mock::given(path("/landing"))
.and(header("authorization", SECRET))
.respond_with(ResponseTemplate::new(200).set_body_string("ok"))
.expect(1)
.mount(&server)
.await;
let vault = vault();
vault.set(&vault_slot("a", "KEY"), SECRET).unwrap();
let headers = json!([["authorization", "codewhale-credential:KEY"]]);
let both = authority(
&["127.0.0.1", "localhost"],
policy(&["127.0.0.1", "localhost"], &[]),
&vault,
);
let away = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{}/away", server.uri()), "headers": headers}),
),
both,
)
.await;
assert!(message(&away).contains("may not leave the requested origin"));
let renamed = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{}/rename", server.uri()), "headers": headers}),
),
authority(
&["127.0.0.1", "localhost"],
policy(&["127.0.0.1", "localhost"], &[]),
&vault,
),
)
.await;
assert!(message(&renamed).contains("may not leave the requested origin"));
let hop = serve_with(
call(
"a",
"fetch",
json!({"url": format!("{}/hop", server.uri()), "headers": headers}),
),
authority(&["127.0.0.1"], policy(&["127.0.0.1"], &[]), &vault),
)
.await
.unwrap();
assert_eq!(hop["status"], 200);
assert_eq!(hop["redirected"], true);
}
}
@@ -0,0 +1,162 @@
#[path = "support/acp_conformance.rs"]
mod conformance;
use codewhale_engine::fleet::acp::{AcpClient, PermissionPolicy};
use codewhale_engine::fleet::host::{
FleetHostAdapter, FleetWorkerCommand, FleetWorkerStartRequest, FleetWorkerTransport,
LocalProcessFleetHostAdapter,
};
use conformance::{Allowed, Conformance, Side};
use std::path::{Path, PathBuf};
use std::time::Duration;
const KNOWN: &[Allowed] = &[
Allowed {
side: Side::Agent,
frame: "initialize response",
problem: "field `.agentCapabilities.sessionCapabilities.fork` is not in the official type",
reason: "options fixture advertises session/fork, which is unstable_session_fork, not stable v1",
},
Allowed {
side: Side::Agent,
frame: "session/prompt response",
problem: "field `.usage` is not in the official type",
reason: "options fixture reports PromptResponse.usage, which is unstable_end_turn_token_usage, not stable v1",
},
];
const STEP: Duration = Duration::from_secs(20);
fn fixture(name: &str) -> String {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
.display()
.to_string()
}
fn captured(
agent: &str,
workspace: &Path,
log: &Path,
drive: impl FnOnce(&mut AcpClient),
) -> Vec<(Side, serde_json::Value)> {
let mut host = LocalProcessFleetHostAdapter::new(workspace);
let mut request = FleetWorkerStartRequest::new(
"w-acp-conformance",
FleetWorkerCommand::new(
"node",
[
fixture("acp-tee.mjs"),
log.display().to_string(),
fixture(agent),
],
),
);
request.transport = FleetWorkerTransport::JsonRpc;
request.cwd = Some(workspace.to_path_buf());
host.start_worker(request).unwrap();
let mut client = AcpClient::new(host.take_worker_io("w-acp-conformance").unwrap());
drive(&mut client);
host.stop_worker("w-acp-conformance").unwrap();
std::fs::read_to_string(log)
.unwrap()
.lines()
.map(|line| {
let (tag, frame) = line.split_once(' ').unwrap();
let side = if tag == "C" {
Side::Client
} else {
Side::Agent
};
(side, serde_json::from_str(frame).unwrap())
})
.collect()
}
#[test]
fn the_acp_client_and_its_fixture_agents_speak_official_v1_shapes() {
let has_node = std::process::Command::new("node")
.arg("--version")
.output()
.is_ok_and(|output| output.status.success());
if !has_node {
eprintln!("skipped: node is not installed");
return;
}
let capture = tempfile::tempdir().unwrap();
let workspace = tempfile::tempdir().unwrap();
let mut frames = captured(
"acp-agent.mjs",
workspace.path(),
&capture.path().join("agent.log"),
|client| {
let identity = client.initialize(STEP).unwrap();
let session = client
.new_session(&identity, workspace.path(), &[], STEP)
.unwrap()
.session_id;
client
.set_config_option(&session, "model", "fixture-model", STEP)
.unwrap();
for policy in [
PermissionPolicy::AllowOnce,
PermissionPolicy::RejectOnce,
PermissionPolicy::Cancel,
] {
client.prompt(&session, "conform", policy, STEP).unwrap();
}
client.cancel(&session).unwrap();
client
.reopen_session(&identity, &session, workspace.path(), &[], STEP)
.unwrap();
},
);
let options_workspace = tempfile::tempdir().unwrap();
frames.extend(captured(
"acp-options-agent.mjs",
options_workspace.path(),
&capture.path().join("options.log"),
|client| {
let identity = client.initialize(STEP).unwrap();
let created = client
.new_session(&identity, options_workspace.path(), &[], STEP)
.unwrap();
let mut applied = 0;
for control in &created.offer.controls {
if control.write.is_none() || control.unavailable.is_some() {
continue;
}
if let Some(choice) = control.choices.last() {
client
.apply_control(&created.session_id, control, &choice.value, STEP)
.unwrap();
applied += 1;
}
}
assert!(
applied >= 2,
"options fixture offered {applied} writable controls"
);
client
.prompt(
&created.session_id,
"conform",
PermissionPolicy::AllowOnce,
STEP,
)
.unwrap();
},
));
let mut observed = Conformance::checking(&[Side::Client, Side::Agent]);
for (side, frame) in &frames {
observed.observe(*side, frame);
}
assert!(
observed.frames() > 30,
"only {} frames observed",
observed.frames()
);
observed.assert_only(KNOWN);
}
+33
View File
@@ -0,0 +1,33 @@
import { spawn } from 'node:child_process';
import { appendFileSync } from 'node:fs';
const [logPath, agentScript] = process.argv.slice(2);
const child = spawn(process.execPath, [agentScript], { stdio: ['pipe', 'pipe', 'inherit'], env: process.env });
function tap(tag) {
let buffer = '';
return (chunk) => {
buffer += chunk;
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index);
buffer = buffer.slice(index + 1);
if (line.trim()) appendFileSync(logPath, `${tag} ${line}\n`);
}
};
}
const fromClient = tap('C');
const fromAgent = tap('A');
process.stdin.setEncoding('utf8');
child.stdout.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
fromClient(chunk);
child.stdin.write(chunk);
});
process.stdin.on('end', () => child.stdin.end());
child.stdout.on('data', (chunk) => {
fromAgent(chunk);
process.stdout.write(chunk);
});
child.on('exit', (code) => process.exit(code ?? 0));
@@ -0,0 +1,314 @@
use agent_client_protocol_schema::v1 as acp;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::collections::{BTreeSet, HashMap};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Side {
Client,
Agent,
}
impl Side {
fn name(self) -> &'static str {
match self {
Side::Client => "client",
Side::Agent => "agent",
}
}
fn peer(self) -> Side {
match self {
Side::Client => Side::Agent,
Side::Agent => Side::Client,
}
}
}
pub struct Allowed {
pub side: Side,
pub frame: &'static str,
pub problem: &'static str,
pub reason: &'static str,
}
pub struct Conformance {
checked: Vec<Side>,
pending: HashMap<(Side, String), String>,
findings: BTreeSet<(Side, String, String)>,
frames: usize,
}
fn check<T: DeserializeOwned + Serialize>(value: &Value) -> Vec<String> {
let typed: T = match serde_json::from_value(value.clone()) {
Ok(typed) => typed,
Err(error) => {
let text = error.to_string();
let text = text.split(" at line ").next().unwrap_or(&text);
return vec![format!("not a valid {}: {text}", short_type::<T>())];
}
};
let back = match serde_json::to_value(&typed) {
Ok(back) => back,
Err(error) => return vec![format!("does not re-serialize: {error}")],
};
let mut problems = Vec::new();
diff("", value, &back, &mut problems);
problems
}
fn short_type<T>() -> &'static str {
let name = std::any::type_name::<T>();
name.rsplit("::").next().unwrap_or(name)
}
fn skipped_default(key: &str, value: &Value) -> bool {
match value {
Value::Null => true,
Value::Array(items) => items.is_empty(),
Value::Object(map) => key == "env" && map.is_empty(),
Value::String(text) => matches!(
(key, text.as_str()),
("kind", "other") | ("status", "pending")
),
_ => false,
}
}
fn diff(path: &str, ours: &Value, official: &Value, problems: &mut Vec<String>) {
match (ours, official) {
(Value::Object(ours), Value::Object(official)) => {
for (key, value) in ours {
if key == "_meta" {
continue;
}
let at = format!("{path}.{key}");
match official.get(key) {
Some(back) => diff(&at, value, back, problems),
None if skipped_default(key, value) => {}
None => problems.push(format!("field `{at}` is not in the official type")),
}
}
}
(Value::Array(ours), Value::Array(official)) if ours.len() == official.len() => {
for (value, back) in ours.iter().zip(official) {
diff(&format!("{path}[]"), value, back, problems);
}
}
(Value::Number(a), Value::Number(b)) if a.as_f64() == b.as_f64() => {}
_ if ours == official => {}
_ => problems.push(format!(
"value at `{}` reads back as {official} instead of {ours}",
if path.is_empty() { "." } else { path }
)),
}
}
fn request_params(side: Side, method: &str, params: &Value) -> Option<Vec<String>> {
Some(match (side, method) {
(Side::Client, "initialize") => check::<acp::InitializeRequest>(params),
(Side::Client, "authenticate") => check::<acp::AuthenticateRequest>(params),
(Side::Client, "session/new") => check::<acp::NewSessionRequest>(params),
(Side::Client, "session/load") => check::<acp::LoadSessionRequest>(params),
(Side::Client, "session/resume") => check::<acp::ResumeSessionRequest>(params),
(Side::Client, "session/list") => check::<acp::ListSessionsRequest>(params),
(Side::Client, "session/close") => check::<acp::CloseSessionRequest>(params),
(Side::Client, "session/delete") => check::<acp::DeleteSessionRequest>(params),
(Side::Client, "session/set_mode") => check::<acp::SetSessionModeRequest>(params),
(Side::Client, "session/set_config_option") => {
check::<acp::SetSessionConfigOptionRequest>(params)
}
(Side::Client, "session/prompt") => check::<acp::PromptRequest>(params),
(Side::Agent, "session/request_permission") => {
check::<acp::RequestPermissionRequest>(params)
}
(Side::Agent, "fs/read_text_file") => check::<acp::ReadTextFileRequest>(params),
(Side::Agent, "fs/write_text_file") => check::<acp::WriteTextFileRequest>(params),
(Side::Agent, "terminal/create") => check::<acp::CreateTerminalRequest>(params),
(Side::Agent, "terminal/output") => check::<acp::TerminalOutputRequest>(params),
(Side::Agent, "terminal/release") => check::<acp::ReleaseTerminalRequest>(params),
(Side::Agent, "terminal/wait_for_exit") => check::<acp::WaitForTerminalExitRequest>(params),
(Side::Agent, "terminal/kill") => check::<acp::KillTerminalRequest>(params),
(Side::Agent, "elicitation/create") => check::<acp::CreateElicitationRequest>(params),
_ => return None,
})
}
fn response_result(method: &str, result: &Value) -> Option<Vec<String>> {
Some(match method {
"initialize" => check::<acp::InitializeResponse>(result),
"authenticate" => check::<acp::AuthenticateResponse>(result),
"session/new" => check::<acp::NewSessionResponse>(result),
"session/load" => check::<acp::LoadSessionResponse>(result),
"session/resume" => check::<acp::ResumeSessionResponse>(result),
"session/list" => check::<acp::ListSessionsResponse>(result),
"session/close" => check::<acp::CloseSessionResponse>(result),
"session/delete" => check::<acp::DeleteSessionResponse>(result),
"session/set_mode" => check::<acp::SetSessionModeResponse>(result),
"session/set_config_option" => check::<acp::SetSessionConfigOptionResponse>(result),
"session/prompt" => check::<acp::PromptResponse>(result),
"session/request_permission" => check::<acp::RequestPermissionResponse>(result),
"fs/read_text_file" => check::<acp::ReadTextFileResponse>(result),
"fs/write_text_file" => check::<acp::WriteTextFileResponse>(result),
"terminal/create" => check::<acp::CreateTerminalResponse>(result),
"terminal/output" => check::<acp::TerminalOutputResponse>(result),
"terminal/release" => check::<acp::ReleaseTerminalResponse>(result),
"terminal/wait_for_exit" => check::<acp::WaitForTerminalExitResponse>(result),
"terminal/kill" => check::<acp::KillTerminalResponse>(result),
"elicitation/create" => check::<acp::CreateElicitationResponse>(result),
_ => return None,
})
}
fn notification_params(side: Side, method: &str, params: &Value) -> Option<Vec<String>> {
Some(match (side, method) {
(_, "$/cancel_request") => check::<acp::CancelRequestNotification>(params),
(Side::Client, "session/cancel") => check::<acp::CancelNotification>(params),
(Side::Agent, "session/update") => check::<acp::SessionNotification>(params),
(Side::Agent, "elicitation/complete") => {
check::<acp::CompleteElicitationNotification>(params)
}
_ => return None,
})
}
fn envelope(frame: &Value, allowed: &[&str]) -> Vec<String> {
let mut problems = Vec::new();
if frame.get("jsonrpc").and_then(Value::as_str) != Some("2.0") {
problems.push("envelope lacks `jsonrpc: \"2.0\"`".to_string());
}
if let Some(map) = frame.as_object() {
for key in map.keys() {
if !allowed.contains(&key.as_str()) && key != "jsonrpc" {
problems.push(format!("envelope carries `{key}`"));
}
}
}
problems
}
impl Conformance {
pub fn checking(sides: &[Side]) -> Self {
Self {
checked: sides.to_vec(),
pending: HashMap::new(),
findings: BTreeSet::new(),
frames: 0,
}
}
pub fn observe(&mut self, side: Side, frame: &Value) {
self.frames += 1;
let method = frame.get("method").and_then(Value::as_str);
let id = frame.get("id");
let params = frame.get("params").cloned().unwrap_or(Value::Null);
let (label, mut problems) = match (method, id) {
(Some(method), Some(id)) => {
self.pending
.insert((side, id.to_string()), method.to_string());
let mut problems = envelope(frame, &["id", "method", "params"]);
match request_params(side, method, &params) {
Some(found) => problems.extend(found),
None if method.starts_with('_') => {}
None => problems.push(format!(
"`{method}` is not a {} request in official v1",
side.name()
)),
}
(format!("{method} request"), problems)
}
(Some(method), None) => {
let mut problems = envelope(frame, &["method", "params"]);
match notification_params(side, method, &params) {
Some(found) => problems.extend(found),
None if method.starts_with('_') => {}
None => problems.push(format!(
"`{method}` is not a {} notification in official v1",
side.name()
)),
}
(format!("{method} notification"), problems)
}
(None, Some(id)) => {
let answered = self.pending.remove(&(side.peer(), id.to_string()));
let label = match &answered {
Some(method) => format!("{method} response"),
None => "response".to_string(),
};
let mut problems = Vec::new();
if let Some(error) = frame.get("error") {
problems.extend(envelope(frame, &["id", "error"]));
problems.extend(check::<acp::Error>(error));
} else if let Some(result) = frame.get("result") {
problems.extend(envelope(frame, &["id", "result"]));
if let Some(found) = answered
.as_deref()
.and_then(|method| response_result(method, result))
{
problems.extend(found);
}
} else {
problems.push("response has neither result nor error".to_string());
}
if answered.is_none() {
problems.push(format!("answers no outstanding request (id {id})"));
}
(label, problems)
}
(None, None) => (
"frame".to_string(),
vec!["neither a request, a notification nor a response".to_string()],
),
};
let label = match frame
.pointer("/params/update/sessionUpdate")
.and_then(Value::as_str)
{
Some(kind) => format!("{label} ({kind})"),
None => label,
};
if self.checked.contains(&side) {
for problem in problems.drain(..) {
self.findings.insert((side, label.clone(), problem));
}
}
}
pub fn assert_only(&self, allowed: &[Allowed]) {
assert!(self.frames > 0, "no frames were observed");
let mut used = vec![false; allowed.len()];
let mut unexpected = Vec::new();
for (side, frame, problem) in &self.findings {
match allowed.iter().position(|entry| {
entry.side == *side && entry.frame == frame && problem.contains(entry.problem)
}) {
Some(index) => used[index] = true,
None => unexpected.push(format!("{} {frame}: {problem}", side.name())),
}
}
let stale: Vec<_> = allowed
.iter()
.zip(&used)
.filter(|(_, used)| !**used)
.map(|(entry, _)| {
format!(
"{} {}: {} ({})",
entry.side.name(),
entry.frame,
entry.problem,
entry.reason
)
})
.collect();
assert!(
unexpected.is_empty() && stale.is_empty(),
"ACP frames differ from the official v1 schema.\nnew deviations:\n {}\nallowlisted deviations no longer seen (remove them):\n {}",
unexpected.join("\n "),
stale.join("\n ")
);
}
pub fn frames(&self) -> usize {
self.frames
}
}
+5
View File
@@ -10,6 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Website home page:** the page is reorganized around what a visitor does: a dated OrcaRouter offer strip with a visible referral tag, one proof strip (release, stars, contributors), a models section with the newest first-party models, one capabilities section that includes Computer Use, an "Everything is a plugin" teaser and a community panel whose live GitHub numbers fill in from the browser and hide when unavailable. Unused home copy was removed from all 18 site languages.
- **Website models and plugins:** /models leads with recently added models, three ways to connect (DeepSeek, first-party providers, any OpenAI-compatible endpoint) and a catalog grouped by maker; Ollama and OpenRouter are listed but no longer featured. /plugins leads with "Everything is a plugin" (credited to DeepSeek Harness) and shows MCP connectors from the connection catalog.
- **Model catalog:** Claude Fable 5.2 (`claude-fable-5-2`) and Gemini 4 Carbon (`gemini-4-carbon`) are in the bundled catalog, and a daily catalog-refresh workflow proposes new first-party models from models.dev as a pull request. The workflow has not run in GitHub Actions yet.
- **Gemini 4 output limit:** Gemini 4 Carbon and Gemini 4 Argon now allow 65,536 output tokens per reply, up from 32,768, matching Google's published limit.
- **Anthropic xhigh effort:** Choosing xhigh effort on an Anthropic model that documents it, such as Claude Fable 5.2 or Claude Opus 4.8, now sends xhigh instead of max, so the model uses and bills the level you picked.
- **Public model list:** The public model list gains GPT-6.1 Terra, Kimi K3.1 Pro (and its high-speed variant), MiniMax M3.1 Flash and Mistral Large 4, each marked unreviewed.
- **Model Studio Token Plan:** Token Plan users can now pick `qwen4-max-preview` on both the OpenAI and Anthropic dialects. It was added from the model table Alibaba publishes on its Token Plan pricing page and has not been exercised against the live endpoint. Alibaba publishes no context window, output limit, tool calling or thinking control for it, so Codewhale records image input only and sends it no thinking switch on either dialect. The Coding Plan does not list it.
- **Website brand and app pages:** the header, footer and favicon use the black-and-white Codewhale mark, keeping the colored whale for character moments. New /app and /docs/account pages say plainly that the desktop app is a development build, the browser app is not open yet, and how sign-in works where it exists. The terminal demo was recaptured from the 0.10.3 source.
- **DSH plugin client review:** the DSH importer now reviews a plugin's client (`dsh.client`) files: the review lists every file the client loads with its SHA-256 and which modules the host must provide, and says exactly why a client stays disabled (for example an npm package that is not in the bundle, or a module loaded by a computed specifier). Plugin UI is still not shown; that needs the Engine client bootstrap.
- **`codewhale exec` prompt input:** `codewhale exec "prompt"` no longer hangs when stdin is an open pipe that sends nothing (it continues after 2 s and says so, and a slow producer can be included by piping the prompt as well), and a flag typed after the prompt in full-width or typographic-dash form is rejected like `--auto` instead of being sent to the model as text. Tested on Unix only; no Windows, hosted CI or real-provider run.
@@ -52,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The install guide's note for a first message that gets no answer now describes v0.10.2: connect a model with `/provider`, and restart if messages stay saved for later after you connect. It described v0.10.0's key screen. The Chinese guide carries the same correction.
- Project activity on the site now comes from a server-side snapshot with a freshness record, the browser no longer calls GitHub, and count sentences use the right plural form in every language.
- The install page now gives the exact steps for the released version on every platform, including what the installer adds to PATH, how to turn off usage counting, and how to remove it, and the install command no longer gets cut off on phones.
- After choosing "explore offline" on first run, connecting a provider or Custom endpoint with /provider now takes effect immediately; messages are no longer held as "Saved for later" until restart, and a message held while offline returns to the composer.
- The pet browser view opens with a one-time code instead of the long-lived owner token, so the token no longer appears in the URL or the launcher command line. The code lasts 30 seconds and works once; the owner token itself is still stored in the cookie and connection file and is not rotated. Not run in a real browser.
- On Windows with Caps Lock on, Ctrl+E moves to the end of the line again instead of opening the file tree; the file tree now needs an explicit Shift (Ctrl+Shift+E or Cmd+Shift+E), so a terminal that reports an uppercase E with Ctrl but no Shift flag can no longer toggle it. The Caps Lock key event is simulated from the input library source; no native Windows run.
- External agents installed through npm on Windows (`claude.cmd`, `codex.cmd`, ...) are now found instead of the unrunnable shell shim, `GET /v1/agents/external` says whether an agent answered with an unsupported protocol rather than reporting every failure as no answer, and a hung `--version` or handshake probe no longer leaves the agent's child processes running. Verified on macOS and Linux; the Windows code has never been compiled or run, and no agent row is qualified by this change.
+15 -2
View File
@@ -7080,10 +7080,23 @@ impl App {
///
/// Returns `true` when the composer state was mutated.
pub fn pop_last_queued_into_draft(&mut self) -> bool {
let Some(last) = self.queued_messages.len().checked_sub(1) else {
return false;
};
self.pop_queued_into_draft(last)
}
/// Same as [`Self::pop_last_queued_into_draft`] for the oldest held
/// message, so a backlog stays in the order it was typed.
pub fn pop_first_queued_into_draft(&mut self) -> bool {
self.pop_queued_into_draft(0)
}
fn pop_queued_into_draft(&mut self, index: usize) -> bool {
if !self.input.is_empty() || self.queued_draft.is_some() {
return false;
}
let Some(msg) = self.queued_messages.pop_back() else {
let Some(msg) = self.remove_queued_message(index) else {
return false;
};
self.held_restored_queue = self.held_restored_queue.min(self.queued_messages.len());
@@ -7096,7 +7109,7 @@ impl App {
}
/// Stop editing a queued follow-up and put the original queued message back
/// at the tail where [`Self::pop_last_queued_into_draft`] took it from.
/// at the tail of the queue.
pub fn cancel_queued_draft_edit(&mut self) -> bool {
let Some(draft) = self.queued_draft.take() else {
return false;
+54 -3
View File
@@ -507,12 +507,20 @@ pub fn choose_offline_explore(app: &mut App) {
app.needs_redraw = true;
}
/// Clear the offline-explore label once a real route is activated (#3927).
/// Retire the offline-explore state once a real route is activated (#3927).
///
/// This is the *only* thing that retires the label: it is not time-based and
/// not cleared by dismissing a screen.
/// This is the *only* thing that retires the label and the offline queueing it
/// armed: it is not time-based and not cleared by dismissing a screen. A
/// message held while offline is returned to the composer so the person can
/// send it on the route that just became active; any further held messages
/// stay queued behind it in order and drain after that turn completes.
pub fn clear_offline_explore_on_route_activation(app: &mut App) {
app.onboarding_explore_offline = false;
if app.offline_mode {
app.offline_mode = false;
app.pop_first_queued_into_draft();
app.needs_redraw = true;
}
}
/// Finish first run from the ready screen and land in the real composer,
@@ -699,6 +707,49 @@ mod tests {
clear_offline_explore_on_route_activation(&mut app);
assert!(!app.onboarding_explore_offline);
assert!(!app.offline_mode);
assert_eq!(
app.decide_submit_disposition(),
crate::tui::app::SubmitDisposition::Immediate
);
}
#[test]
fn route_activation_returns_the_held_message_to_the_composer() {
let mut app = test_app_with_locale(Locale::En);
app.trust_mode = true;
choose_offline_explore(&mut app);
app.input.clear();
app.queue_message(crate::tui::app::QueuedMessage::new(
"first task".to_string(),
None,
));
assert_eq!(
app.decide_submit_disposition(),
crate::tui::app::SubmitDisposition::Queue
);
clear_offline_explore_on_route_activation(&mut app);
assert_eq!(app.input, "first task");
assert!(app.queued_messages.is_empty());
}
#[test]
fn route_activation_keeps_the_oldest_held_message_first() {
let mut app = test_app_with_locale(Locale::En);
app.trust_mode = true;
choose_offline_explore(&mut app);
app.input.clear();
for text in ["first task", "second task"] {
app.queue_message(crate::tui::app::QueuedMessage::new(text.to_string(), None));
}
clear_offline_explore_on_route_activation(&mut app);
assert_eq!(app.input, "first task");
assert_eq!(app.queued_messages.len(), 1);
assert_eq!(app.queued_messages[0].display, "second task");
}
// ── Conditional required steps: the counter ──────────────────────────
+34
View File
@@ -11043,6 +11043,40 @@ async fn provider_switch_clears_turn_cache_history() {
assert!(app.session.turn_cache_history.is_empty());
}
#[tokio::test]
async fn route_activation_after_offline_explore_sends_instead_of_holding() {
let _home = SettingsHomeGuard::new();
let mut app = create_test_app();
app.trust_mode = true;
crate::tui::onboarding::choose_offline_explore(&mut app);
app.input.clear();
app.queue_message(QueuedMessage::new("first task".to_string(), None));
assert_eq!(
app.decide_submit_disposition(),
crate::tui::app::SubmitDisposition::Queue
);
let mut engine = mock_engine_handle();
let mut config = Config::default();
let identity = (config).test_identity_for_kind(ProviderKind::Ollama);
switch_provider(
&mut app,
&mut engine.handle,
&mut config,
identity,
Some("fixture-local:tag".to_string()),
)
.await;
assert!(!app.offline_mode);
assert_eq!(
app.decide_submit_disposition(),
crate::tui::app::SubmitDisposition::Immediate
);
assert_eq!(app.input, "first task");
assert!(app.queued_messages.is_empty());
}
/// #6566: re-selecting the provider already in use (first-run key entry)
/// reads as a connection, not as a switch from a provider to itself.
#[tokio::test]
+140
View File
@@ -0,0 +1,140 @@
use tokio::io::{AsyncBufRead, AsyncBufReadExt as _};
pub const MAX_FRAME_BYTES: usize = 8 * 1024 * 1024;
#[derive(Debug, thiserror::Error)]
pub enum FrameRefusal {
#[error("control frame exceeds the 8 MiB transport limit")]
TooLarge,
#[error("control frame is not UTF-8")]
InvalidUtf8,
}
pub fn is_frame_refusal(error: &std::io::Error) -> bool {
error
.get_ref()
.is_some_and(|source| source.is::<FrameRefusal>())
}
/// Cancellation-safe, allocation-bounded newline framing shared by every
/// local control transport. Consumed partial bytes remain owned by the reader.
pub struct BoundedLines<R> {
reader: R,
pending: Vec<u8>,
discarding: bool,
}
impl<R: AsyncBufRead + Unpin> BoundedLines<R> {
pub fn new(reader: R) -> Self {
Self {
reader,
pending: Vec::new(),
discarding: false,
}
}
pub fn into_inner(self) -> R {
self.reader
}
pub async fn next_line(&mut self) -> std::io::Result<Option<String>> {
loop {
let available = self.reader.fill_buf().await?;
if available.is_empty() {
self.discarding = false;
if self.pending.is_empty() {
return Ok(None);
}
if self.pending.len() > MAX_FRAME_BYTES {
self.pending.clear();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
let available = &available[..available.len().min(8192)];
let newline = available.iter().position(|&byte| byte == b'\n');
let count = newline.unwrap_or(available.len());
if self.discarding {
self.reader.consume(count + usize::from(newline.is_some()));
if newline.is_some() {
self.discarding = false;
} else {
tokio::task::yield_now().await;
}
continue;
}
if count > (MAX_FRAME_BYTES + 1).saturating_sub(self.pending.len()) {
self.pending.clear();
self.reader.consume(count + usize::from(newline.is_some()));
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
// Do not let Vec's geometric growth double an almost-full frame.
self.pending.reserve_exact(count);
self.pending.extend_from_slice(&available[..count]);
self.reader.consume(count + usize::from(newline.is_some()));
if self.pending.len() > MAX_FRAME_BYTES && self.pending.last() != Some(&b'\r') {
self.pending.clear();
self.discarding = newline.is_none();
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
FrameRefusal::TooLarge,
));
}
if newline.is_some() {
if self.pending.last() == Some(&b'\r') {
self.pending.pop();
}
let bytes = std::mem::take(&mut self.pending);
return String::from_utf8(bytes).map(Some).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidData, FrameRefusal::InvalidUtf8)
});
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::{AsyncWriteExt as _, BufReader};
#[tokio::test]
async fn bounded_lines_refuses_oversize_before_retaining_extra_bytes() {
let input = vec![b'x'; MAX_FRAME_BYTES + 4096];
let mut reader = BoundedLines::new(BufReader::with_capacity(8192, input.as_slice()));
assert_eq!(
reader.next_line().await.unwrap_err().kind(),
std::io::ErrorKind::InvalidData
);
assert!(reader.pending.len() <= MAX_FRAME_BYTES + 1);
assert!(reader.pending.capacity() <= MAX_FRAME_BYTES + 1);
}
#[tokio::test]
async fn bounded_lines_keeps_consumed_prefix_when_read_future_is_cancelled() {
let (mut input, output) = tokio::io::duplex(64);
input.write_all(b"partial").await.unwrap();
let mut reader = BoundedLines::new(BufReader::new(output));
std::future::poll_fn(|cx| {
let mut read = std::pin::pin!(reader.next_line());
assert!(std::future::Future::poll(read.as_mut(), cx).is_pending());
std::task::Poll::Ready(())
})
.await;
assert_eq!(reader.pending, b"partial");
input.write_all(b"-continued\n").await.unwrap();
assert_eq!(
reader.next_line().await.unwrap().as_deref(),
Some("partial-continued")
);
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod bounded_lines;
pub mod bundle_file;
pub mod files;
pub mod fs_confined;
+4 -3
View File
@@ -1004,9 +1004,9 @@ overlay and lets DSH resolve its own keys.
| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`; default `https://api.concentrate.ai/v1` | `deepseek-v4-pro` (default; a plain catalog id lets the gateway pick the upstream provider); `provider/model` ids such as `openai/gpt-5.6-sol` pin one upstream; `concentrate/auto` is the gateway's own router; unauthenticated live `/v1/models` catalog | Concentrate OpenAI Responses-compatible gateway (`POST /v1/responses`, bearer Universal API key). Opt-in and BYOK only: your key, your Concentrate bill, zero Codewhale fee, no managed default. Requests carry only documented fields (the system prompt rides as a `system` input item). Contract: [API introduction](https://concentrate.ai/docs/api-reference/introduction), [request parameters](https://concentrate.ai/docs/api-reference/endpoint/request-parameters), [streaming](https://concentrate.ai/docs/api-reference/endpoint/streaming), [errors](https://concentrate.ai/docs/api-reference/endpoint/errors). See [Concentrate Notes](#concentrate-notes). |
| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`; default `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro` (default), `anthropic/claude-sonnet-5`, `openai/gpt-5.6` are offline bootstrap rows only; the authenticated `GET /v1/models` listing of the account's connected providers is the catalog authority | Codewhale API: account-backed model access over the provider keys the customer connected to their Codewhale account. One `cwc_key_…` account API key with the `models:infer` scope authenticates every model. Model ids are `provider/model` exactly as the account catalog returns them, and each row states its protocol (`chat-completions` → `/v1/chat/completions`, `anthropic-messages` → `/v1/messages`, `responses` → `/v1/responses`); every protocol uses `Authorization: Bearer`, never `x-api-key`. `CODEWHALE_API_BASE` must be HTTPS except on loopback. Connect provider keys with `codewhale account keys set <provider>`. Provider aliases: `codewhale-api`, `cw-api`, `codewhale-cloud`. |
| `xai` | `[providers.xai]` | `XAI_API_KEY`, Codewhale-owned device OAuth, or explicit read-only Grok CLI consent | `XAI_BASE_URL`; default `https://api.x.ai/v1` | `grok-4.7` (default), `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI-compatible Chat Completions route. The default is the current same-line model in xAI's own list, [Grok 4.7](https://docs.x.ai/docs/models/grok-4.7): 500K context, text/image input, function calls, structured output, the `low`/`medium`/`high`/`xhigh` ladder, and $2.00 / $0.50 cached / $6.00. Grok 4.6 keeps the same window and rates and is the model with documented server-side web search; Codewhale does not claim web search for 4.7. Grok reasoning cannot be disabled ([reasoning guide](https://docs.x.ai/docs/guides/reasoning)). Its standard rates double when the prompt reaches 200K tokens; the same 2x long-context rule applies to `grok-4.5` (500K context, $2.00 / $0.30 cached / $6.00) and `grok-4.3` (1M context, $1.25 / $0.20 cached / $2.50) per their [model pages](https://docs.x.ai/docs/models/grok-4.5). There is no documented `latest`/`fast` alias and no published numeric output limit. **API-key** (default): Bearer token from console.x.ai via `XAI_API_KEY` / keyring / `api_key`. **OAuth**: `codewhale auth xai-device` uses SSH-friendly device login and Codewhale-owned storage, which may refresh itself. Existing Grok CLI credentials require `codewhale auth external-consent --provider xai --mode read-only`; the granted external file is never refreshed or rewritten. OAuth may return HTTP 403 on some SuperGrok tiers — keep API-key as the reliable fallback. `XAI_MODEL` is accepted. Provider aliases: `x-ai`, `x_ai`, `grok`. |
| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the Coding Plan and Anthropic routes do not inherit that capability. Alibaba retired `qwen3.8-max-preview` on 2026-08-05 and routes it to `qwen3.8-max`, so it is no longer listed. All listed models are reasoning-capable text/coding models. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |
| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`; default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max` (default), `qwen4-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | Alibaba Cloud Model Studio Token Plan OpenAI-compatible Chat Completions route. Token Plan Personal and Team share this endpoint. `qwen3.8-max`, `qwen3.7-plus`, and `qwen3.7-max` can use provider-native web search through the Token Plan Responses Harness; the Coding Plan and Anthropic routes do not inherit that capability. Alibaba retired `qwen3.8-max-preview` on 2026-08-05 and routes it to `qwen3.8-max`, so it is no longer listed. All listed models except `qwen4-max-preview` are reasoning-capable text/coding models. Alibaba's table calls `qwen4-max-preview` a reasoning model with visual understanding but publishes no context window, output limit, tool calling or thinking control, so Codewhale records image input only and sends it no thinking switch on either dialect. DeepSeek and GLM entries are provider-scoped and do not collide with first-party routes. `MODELSTUDIO_TOKEN_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`. |
| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-token-plan` | Token Plan Anthropic-compatible Messages route (`/apps/anthropic`). Same API key as the OpenAI dialect. Provider aliases: `modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`. |
| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same catalog as Token Plan | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |
| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`; default `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max` (default); same ids as Token Plan except `qwen4-max-preview`, which is Token Plan only | Alibaba Cloud Model Studio Coding Plan OpenAI-compatible Chat Completions route. `MODELSTUDIO_CODING_PLAN_MODEL` is accepted. Provider aliases: `modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`. |
| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | default `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | Same model catalog as `modelstudio-coding-plan` | Coding Plan Anthropic-compatible Messages route (`/apps/anthropic`). Provider aliases: `modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`. |
StepFun's four coding models are available through both its standard API and
@@ -1348,7 +1348,8 @@ See [Catalog refresh](CATALOG_REFRESH.md) for freshness and estimate boundaries.
| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | yes | yes for `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, and `grok-4.20-0309-reasoning` |
| `google` | `gemini-4-carbon`, `gemini-4-argon`, `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.8-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | yes | yes except `gemini-3.5-flash-lite` |
| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | yes | yes for Medium and Small (`reasoning_effort` `none` or `high` on exact first-party routes); deprecated native Magistral remains an always-on explicit compatibility ID; no for Code and Large |
| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |
| `modelstudio-token-plan` | `qwen3.8-max`, `qwen4-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes except `qwen4-max-preview` (not published) | yes except `qwen4-max-preview` |
| `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | yes | yes |
| `csdn` | `glm_for_coding`; other marketplace model IDs pass through | yes | yes |
AtlasCloud keeps the same default model as the config layer and adds
+4 -3
View File
@@ -568,9 +568,9 @@ Kimi 仍然仅支持 API 密钥;对 Kimi 的外部授权被拒绝。
| `concentrate` | `[providers.concentrate]` | `CONCENTRATE_API_KEY` | `CONCENTRATE_BASE_URL`;默认 `https://api.concentrate.ai/v1` | `deepseek-v4-pro`(默认;使用普通目录 ID 时由网关选择上游提供商);形如 `openai/gpt-5.6-sol` 的 `provider/model` ID 会固定一个上游;`concentrate/auto` 是网关自己的路由器;无需认证即可读取的实时 `/v1/models` 目录 | Concentrate OpenAI Responses 兼容网关(`POST /v1/responses`,Bearer 认证,使用 Universal API 密钥)。需主动启用,且仅支持自带密钥(BYOK):用你自己的密钥、走你自己的 Concentrate 账单,Codewhale 不收任何费用,也没有托管的默认路由。请求只携带文档中记载的字段(系统提示词作为 `system` 输入项传递)。约定见:[API 简介](https://concentrate.ai/docs/api-reference/introduction)、[请求参数](https://concentrate.ai/docs/api-reference/endpoint/request-parameters)、[流式传输](https://concentrate.ai/docs/api-reference/endpoint/streaming)、[错误](https://concentrate.ai/docs/api-reference/endpoint/errors)。参见 [Concentrate 说明](#concentrate-说明)。 |
| `codewhale` | `[providers.codewhale]` | `CODEWHALE_API_KEY` | `CODEWHALE_API_BASE`;默认 `https://api.codewhale.net/v1` | `deepseek/deepseek-v4-pro`(默认)、`anthropic/claude-sonnet-5`、`openai/gpt-5.6` 仅是离线引导行;目录以已认证的 `GET /v1/models` 返回的、账户已连接的提供商列表为准 | Codewhale API:基于客户已连接到其 Codewhale 账户的提供商密钥,提供账户级的模型访问。一个带 `models:infer` 权限范围的 `cwc_key_…` 账户 API 密钥即可认证所有模型。模型 ID 就是账户目录返回的 `provider/model` 原样字符串,每一行都会声明自己的协议(`chat-completions` → `/v1/chat/completions`,`anthropic-messages` → `/v1/messages`,`responses` → `/v1/responses`);所有协议都使用 `Authorization: Bearer`,绝不使用 `x-api-key`。`CODEWHALE_API_BASE` 必须是 HTTPS,回环地址除外。用 `codewhale account keys set <provider>` 连接提供商密钥。提供商别名:`codewhale-api`, `cw-api`, `codewhale-cloud`。 |
| `xai` | `[providers.xai]` | `XAI_API_KEY`、Codewhale 自有的设备 OAuth,或显式的只读 Grok CLI 授权 | `XAI_BASE_URL`;默认 `https://api.x.ai/v1` | `grok-4.7`(默认), `grok-4.6`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | xAI/Grok OpenAI 兼容 Chat Completions 路由。Grok 4.6 有 500K 上下文窗口、文本/图像输入、函数调用、结构化输出、服务端 web 搜索和 `low`/`medium`/`high`/`xhigh` 推理(默认 `high`)。[Grok 4.7](https://docs.x.ai/docs/models/grok-4.7) 有相同的 500K 窗口、文本/图像输入、函数调用、结构化输出、推理阶梯和 $2.00 / $0.50 缓存 / $6.00 费率;xAI 尚未记录其 web 搜索支持,因此 Codewhale 不声明。Grok 推理无法关闭([推理指南](https://docs.x.ai/docs/guides/reasoning))。提示达到 200K token 时其标准费率翻倍;同样的 2 倍长上下文规则适用于 `grok-4.5`(500K 上下文,$2.00 / $0.30 缓存 / $6.00)和 `grok-4.3`(1M 上下文,$1.25 / $0.20 缓存 / $2.50),按其[模型页](https://docs.x.ai/docs/models/grok-4.5)。没有文档化的 `latest`/`fast` 别名,也没有公布的数字输出上限。**API 密钥**(默认):来自 console.x.ai 的 Bearer token,通过 `XAI_API_KEY` / keyring / `api_key`。**OAuth**:`codewhale auth xai-device` 使用 SSH 友好的设备登录和 Codewhale 自有的存储,它可能自行刷新。现有的 Grok CLI 凭据需要 `codewhale auth external-consent --provider xai --mode read-only`;被授予的外部文件从不刷新或重写。OAuth 在某些 SuperGrok 层级上可能返回 HTTP 403——把 API 密钥作为可靠回退。接受 `XAI_MODEL`。提供商别名:`x-ai`, `x_ai`, `grok`。 |
| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`;默认 `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max`(默认), `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | 阿里云 Model Studio Token Plan OpenAI 兼容 Chat Completions 路由。Token Plan 个人版和团队版共享该端点。`qwen3.8-max`、`qwen3.7-plus` 和 `qwen3.7-max` 可以通过 Token Plan Responses Harness 使用提供商原生网页搜索;预览版、Coding Plan 和 Anthropic 路由不会继承该能力。列出的所有模型都是可推理的文本/编码模型。DeepSeek 和 GLM 条目是提供商范围的,不与第一方路由冲突。接受 `MODELSTUDIO_TOKEN_PLAN_MODEL`。提供商别名:`modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`。 |
| `modelstudio-token-plan` | `[providers.modelstudio_token_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_TOKEN_PLAN_BASE_URL`;默认 `https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1` | `qwen3.8-max`(默认), `qwen4-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | 阿里云 Model Studio Token Plan OpenAI 兼容 Chat Completions 路由。Token Plan 个人版和团队版共享该端点。`qwen3.8-max`、`qwen3.7-plus` 和 `qwen3.7-max` 可以通过 Token Plan Responses Harness 使用提供商原生网页搜索;预览版、Coding Plan 和 Anthropic 路由不会继承该能力。除 `qwen4-max-preview` 外,列出的所有模型都是可推理的文本/编码模型。阿里云的表格称 `qwen4-max-preview` 为推理模型并支持视觉理解,但未公布上下文窗口、输出上限、工具调用或思考控制,因此 Codewhale 只记录图像输入,并且在两种协议上都不向它发送思考开关。DeepSeek 和 GLM 条目是提供商范围的,不与第一方路由冲突。接受 `MODELSTUDIO_TOKEN_PLAN_MODEL`。提供商别名:`modelstudio-token-plan`, `alibaba-token-plan`, `dashscope-token-plan`。 |
| `modelstudio-token-plan-anthropic` | `[providers.modelstudio_token_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | 默认 `https://token-plan.ap-southeast-1.maas.aliyuncs.com/apps/anthropic` | 与 `modelstudio-token-plan` 相同的模型目录 | Token Plan Anthropic 兼容 Messages 路由(`/apps/anthropic`)。与 OpenAI 方言相同的 API 密钥。提供商别名:`modelstudio-token-plan-anthropic`, `alibaba-token-plan-anthropic`。 |
| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`;默认 `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max`(默认);与 Token Plan 相同的目录 | 阿里云 Model Studio Coding Plan OpenAI 兼容 Chat Completions 路由。接受 `MODELSTUDIO_CODING_PLAN_MODEL`。提供商别名:`modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`。 |
| `modelstudio-coding-plan` | `[providers.modelstudio_coding_plan]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | `MODELSTUDIO_CODING_PLAN_BASE_URL`;默认 `https://coding-intl.dashscope.aliyuncs.com/v1` | `qwen3.8-max`(默认);与 Token Plan 相同,但不含 `qwen4-max-preview`(仅限 Token Plan) | 阿里云 Model Studio Coding Plan OpenAI 兼容 Chat Completions 路由。接受 `MODELSTUDIO_CODING_PLAN_MODEL`。提供商别名:`modelstudio-coding-plan`, `alibaba-coding-plan`, `dashscope-coding-plan`。 |
| `modelstudio-coding-plan-anthropic` | `[providers.modelstudio_coding_plan_anthropic]` | `MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY` | 默认 `https://coding-intl.dashscope.aliyuncs.com/apps/anthropic` | 与 `modelstudio-coding-plan` 相同的模型目录 | Coding Plan Anthropic 兼容 Messages 路由(`/apps/anthropic`)。提供商别名:`modelstudio-coding-plan-anthropic`, `alibaba-coding-plan-anthropic`。 |
StepFun 的四个编码模型既可通过其标准 API 使用,也可通过 [Step Plan](https://platform.stepfun.ai/docs/en/step-plan/integrations/reasoning-api) 使用。请先在 `/provider` 中选择计费路由,再在 `/model` 中选择模型;已有的 Step 3.7 选择保持不变。[Step 5 Preview](https://platform.stepfun.ai/docs/en/guides/models/step-5-preview) 拥有 1M 上下文窗口。Step 5 和 Step 3.7 支持 low/medium/high 三档推理;Step 3.5 Flash 2603 支持 low/high;基础的 Step 3.5 使用提供商的默认推理。[公布的 API 价格](https://platform.stepfun.ai/docs/en/guides/pricing/details)只适用于已验证的按量付费(PAYG)路由;Step Plan 显示的是订阅额度。语音、音乐和图像生成模型使用单独的接口,不会作为编码模型呈现。提供商的视频能力元数据并不意味着每个 Codewhale 客户端都能附加视频。
@@ -738,7 +738,8 @@ OpenRouter completions 和静态注册表行包括自 2026 年 4 月起通过 Op
| `xai` | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning` | 是 | `grok-4.6`, `grok-4.7`, `grok-4.5`, `grok-4.3`, `grok-build` 和 `grok-4.20-0309-reasoning` 是 |
| `google` | `gemini-4-carbon`, `gemini-4-argon`, `gemini-3.1-pro-preview`, `gemini-3-pro-preview`, `gemini-3.8-flash`, `gemini-3.6-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | 是 | 除 `gemini-3.5-flash-lite` 外都是 |
| `mistral` | `mistral-code-latest`, `mistral-medium-latest`, `mistral-small-latest`, `mistral-large-latest` | 是 | Medium 和 Small 是(在精确的第一方路由上 `reasoning_effort` 为 `none` 或 `high`);已弃用的原生 Magistral 仍然是始终启用的显式兼容 ID;Code 和 Large 否 |
| `modelstudio-token-plan`, `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | 是 | 是 |
| `modelstudio-token-plan` | `qwen3.8-max`, `qwen4-max-preview`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | 是,`qwen4-max-preview` 除外(未公布) | 是,`qwen4-max-preview` 除外 |
| `modelstudio-coding-plan` | `qwen3.8-max`, `qwen3.7-plus`, `qwen3.7-max`, `qwen3.6-flash`, `deepseek-v4-pro`, `deepseek-v4-flash-0731`, `glm-5.2` | 是 | 是 |
| `csdn` | `glm_for_coding`;其他市场模型 ID 原样通过 | 是 | 是 |
AtlasCloud 与配置层保持相同的默认模型,并为 Pro 和 Flash 行添加提供商范围的别名。其他 AtlasCloud 模型 ID 仍应通过 `ATLASCLOUD_MODEL`、配置或可用的实时模型列表来选择。
+6 -6
View File
@@ -42,8 +42,8 @@
},
{
"path": "docs/PROVIDERS.md",
"sha256": "4c8dd80a09f2e0435aace49fafd9d350a778ba10d1eae62a30a078fe2fdeb390",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nThe `/provider` and `/model` pickers recommend OrcaRouter as a partner option.\nClick the recommendation or press F7 to view the Codewhale × OrcaRouter offer:\na **30% top-up match for DeepSeek V4.1 Flash**. The pager includes the\n[redemption link](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67)\nand can copy its text. This link is part of a referral campaign; Codewhale may\nearn a referral reward on top-ups made through it.\nCurrent eligibility and terms are owned by the linked offer page. Viewing it\npreserves the current provider, model and credentials; redemption happens on\nOrcaRouter's site.\nThe recommendation is not shown when the interface is in Simplified Chinese or\nthe computer is set to a mainland China time zone or locale.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Where available, offerings come from live\n`GET /v1/models` plus the Codewhale catalog (#5350, #6289).\n\nBundled compatible hosts. These ship as descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model IDs come from configured models, the\nCodewhale catalog, and a live `GET /v1/models` when supported. The example model\nis only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| Thinking Machines Tinker (beta) | `https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1` | `thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4` | `TINKER_API_KEY` |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n| Wafer | `https://pass.wafer.ai/v1` | `GLM-5.3` (default), `GLM-5.2`, `Kimi-K3`, `DeepSeek-V4-Pro` | `WAFER_API_KEY` |\n| Runcrate | `https://api.runcrate.ai/v1` | `deepseek-ai/DeepSeek-V4.1-Flash`, `zai-org/GLM-5.3`, `openai/gpt-oss-120b` | `RUNCRATE_API_KELine truncated
"sha256": "31191a680fba30296dfd37a6f49bea46589848f94d8ba7ea813068be5b8f80a5",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nThe `/provider` and `/model` pickers recommend OrcaRouter as a partner option.\nClick the recommendation or press F7 to view the Codewhale × OrcaRouter offer:\na **30% top-up match for DeepSeek V4.1 Flash**. The pager includes the\n[redemption link](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67)\nand can copy its text. This link is part of a referral campaign; Codewhale may\nearn a referral reward on top-ups made through it.\nCurrent eligibility and terms are owned by the linked offer page. Viewing it\npreserves the current provider, model and credentials; redemption happens on\nOrcaRouter's site.\nThe recommendation is not shown when the interface is in Simplified Chinese or\nthe computer is set to a mainland China time zone or locale.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Where available, offerings come from live\n`GET /v1/models` plus the Codewhale catalog (#5350, #6289).\n\nBundled compatible hosts. These ship as descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model IDs come from configured models, the\nCodewhale catalog, and a live `GET /v1/models` when supported. The example model\nis only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| Thinking Machines Tinker (beta) | `https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1` | `thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4` | `TINKER_API_KEY` |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n| Wafer | `https://pass.wafer.ai/v1` | `GLM-5.3` (default), `GLM-5.2`, `Kimi-K3`, `DeepSeek-V4-Pro` | `WAFER_API_KEY` |\n| Runcrate | `https://api.runcrate.ai/v1` | `deepseek-ai/DeepSeek-V4.1-Flash`, `zai-org/GLM-5.3`, `openai/gpt-oss-120b` | `RUNCRATE_API_KELine truncated
},
{
"path": "docs/CODEWHALE_AGENT.md",
@@ -63,8 +63,8 @@
},
{
"path": "docs/PROVIDERS.md",
"sha256": "4c8dd80a09f2e0435aace49fafd9d350a778ba10d1eae62a30a078fe2fdeb390",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nThe `/provider` and `/model` pickers recommend OrcaRouter as a partner option.\nClick the recommendation or press F7 to view the Codewhale × OrcaRouter offer:\na **30% top-up match for DeepSeek V4.1 Flash**. The pager includes the\n[redemption link](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67)\nand can copy its text. This link is part of a referral campaign; Codewhale may\nearn a referral reward on top-ups made through it.\nCurrent eligibility and terms are owned by the linked offer page. Viewing it\npreserves the current provider, model and credentials; redemption happens on\nOrcaRouter's site.\nThe recommendation is not shown when the interface is in Simplified Chinese or\nthe computer is set to a mainland China time zone or locale.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Where available, offerings come from live\n`GET /v1/models` plus the Codewhale catalog (#5350, #6289).\n\nBundled compatible hosts. These ship as descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model IDs come from configured models, the\nCodewhale catalog, and a live `GET /v1/models` when supported. The example model\nis only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| Thinking Machines Tinker (beta) | `https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1` | `thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4` | `TINKER_API_KEY` |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n| Wafer | `https://pass.wafer.ai/v1` | `GLM-5.3` (default), `GLM-5.2`, `Kimi-K3`, `DeepSeek-V4-Pro` | `WAFER_API_KEY` |\n| Runcrate | `https://api.runcrate.ai/v1` | `deepseek-ai/DeepSeek-V4.1-Flash`, `zai-org/GLM-5.3`, `openai/gpt-oss-120b` | `RUNCRATE_API_KELine truncated
"sha256": "31191a680fba30296dfd37a6f49bea46589848f94d8ba7ea813068be5b8f80a5",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nThe `/provider` and `/model` pickers recommend OrcaRouter as a partner option.\nClick the recommendation or press F7 to view the Codewhale × OrcaRouter offer:\na **30% top-up match for DeepSeek V4.1 Flash**. The pager includes the\n[redemption link](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67)\nand can copy its text. This link is part of a referral campaign; Codewhale may\nearn a referral reward on top-ups made through it.\nCurrent eligibility and terms are owned by the linked offer page. Viewing it\npreserves the current provider, model and credentials; redemption happens on\nOrcaRouter's site.\nThe recommendation is not shown when the interface is in Simplified Chinese or\nthe computer is set to a mainland China time zone or locale.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Where available, offerings come from live\n`GET /v1/models` plus the Codewhale catalog (#5350, #6289).\n\nBundled compatible hosts. These ship as descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model IDs come from configured models, the\nCodewhale catalog, and a live `GET /v1/models` when supported. The example model\nis only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| Thinking Machines Tinker (beta) | `https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1` | `thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4` | `TINKER_API_KEY` |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n| Wafer | `https://pass.wafer.ai/v1` | `GLM-5.3` (default), `GLM-5.2`, `Kimi-K3`, `DeepSeek-V4-Pro` | `WAFER_API_KEY` |\n| Runcrate | `https://api.runcrate.ai/v1` | `deepseek-ai/DeepSeek-V4.1-Flash`, `zai-org/GLM-5.3`, `openai/gpt-oss-120b` | `RUNCRATE_API_KELine truncated
}
]
},
@@ -74,8 +74,8 @@
"sources": [
{
"path": "docs/PROVIDERS.md",
"sha256": "4c8dd80a09f2e0435aace49fafd9d350a778ba10d1eae62a30a078fe2fdeb390",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nThe `/provider` and `/model` pickers recommend OrcaRouter as a partner option.\nClick the recommendation or press F7 to view the Codewhale × OrcaRouter offer:\na **30% top-up match for DeepSeek V4.1 Flash**. The pager includes the\n[redemption link](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67)\nand can copy its text. This link is part of a referral campaign; Codewhale may\nearn a referral reward on top-ups made through it.\nCurrent eligibility and terms are owned by the linked offer page. Viewing it\npreserves the current provider, model and credentials; redemption happens on\nOrcaRouter's site.\nThe recommendation is not shown when the interface is in Simplified Chinese or\nthe computer is set to a mainland China time zone or locale.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Where available, offerings come from live\n`GET /v1/models` plus the Codewhale catalog (#5350, #6289).\n\nBundled compatible hosts. These ship as descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model IDs come from configured models, the\nCodewhale catalog, and a live `GET /v1/models` when supported. The example model\nis only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| Thinking Machines Tinker (beta) | `https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1` | `thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4` | `TINKER_API_KEY` |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n| Wafer | `https://pass.wafer.ai/v1` | `GLM-5.3` (default), `GLM-5.2`, `Kimi-K3`, `DeepSeek-V4-Pro` | `WAFER_API_KEY` |\n| Runcrate | `https://api.runcrate.ai/v1` | `deepseek-ai/DeepSeek-V4.1-Flash`, `zai-org/GLM-5.3`, `openai/gpt-oss-120b` | `RUNCRATE_API_KELine truncated
"sha256": "31191a680fba30296dfd37a6f49bea46589848f94d8ba7ea813068be5b8f80a5",
"content": "# Provider Registry\n\n> 阅读简体中文版:[zh_hans/PROVIDERS.md](zh_hans/PROVIDERS.md)\n\nSpeech recognition, synthesis, translation and video have separate execution\npaths. See [media models](MEDIA_MODELS.md) for current support and reviewed\nupstream candidates; a chat catalogue row alone does not add a media backend.\n\nThis registry describes provider behavior that is wired into the current\nCodewhale codebase. It is intentionally conservative: shipped entries are\nlimited to provider IDs, config keys, auth paths, base URLs, model resolution,\nand capability metadata that the code already knows about.\n\nThe `/provider` and `/model` pickers recommend OrcaRouter as a partner option.\nClick the recommendation or press F7 to view the Codewhale × OrcaRouter offer:\na **30% top-up match for DeepSeek V4.1 Flash**. The pager includes the\n[redemption link](https://www.orcarouter.ai/redeem/1a6250545fdc4e0aaccb0a4470159e67)\nand can copy its text. This link is part of a referral campaign; Codewhale may\nearn a referral reward on top-ups made through it.\nCurrent eligibility and terms are owned by the linked offer page. Viewing it\npreserves the current provider, model and credentials; redemption happens on\nOrcaRouter's site.\nThe recommendation is not shown when the interface is in Simplified Chinese or\nthe computer is set to a mainland China time zone or locale.\n\nDeepSeek remains the default provider, but every entry in `ProviderKind::ALL`\nis a first-class selectable provider route. `ALL` is the catalog/picker\nsurface — one identity per vendor. Dual-wire dialect kinds (`*Anthropic`, e.g.\n`deepseek-anthropic`) and the Model Studio plan variants stay on the enum for\nserde and `provider_for_kind` but are deliberately **not** catalog rows:\na plan is `mode`/`base_url` and a dialect is `wire = openai|anthropic` on the\nprimary provider config (`crates/config/src/provider_kind.rs:221-226`). Hosted\nroutes, generic OpenAI-compatible endpoints, the OpenAI Codex/ChatGPT route,\nnative Anthropic, and local runtimes all run the same terminal harness against\nthe selected provider/model/base URL.\n\nA host reached over plain Chat Completions is an ordinary named provider,\nnot a `ProviderKind`: enum variants are reserved for distinct *wires*\n(Anthropic Messages, Codex Responses, Google thought signatures). Any such\nhost is a `[providers.<name>]` table with a base URL, a model, and a key env\n(`docs/CONFIGURATION.md`); `/provider` and `/setup` keep a \"paste a Base URL\nand a key\" path for exactly this. Where available, offerings come from live\n`GET /v1/models` plus the Codewhale catalog (#5350, #6289).\n\nBundled compatible hosts. These ship as descriptor rows in\n`crates/config/assets/provider_descriptors.json` (compiled in by\n`crates/config/src/descriptors.rs`): each row says how to reach the host — wire,\nbase URL, key env, aliases — while model IDs come from configured models, the\nCodewhale catalog, and a live `GET /v1/models` when supported. The example model\nis only a bootstrap hint. Verify\nagainst the vendor's own docs before trusting any value here:\n\n| Host | Base URL | Example models | API key env |\n| --- | --- | --- | --- |\n| Thinking Machines Tinker (beta) | `https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1` | `thinkingmachines/Inkling-2-Small:peft:262144:sampling-nvfp4` | `TINKER_API_KEY` |\n| SenseNova | `https://token.sensenova.cn/v1` | `deepseek-v4-flash` | `SENSENOVA_API_KEY` |\n| Baseten | `https://inference.baseten.co/v1` | `deepseek-ai/DeepSeek-V4-Pro` | `BASETEN_API_KEY` |\n| Groq | `https://api.groq.com/openai/v1` | `llama-3.3-70b-versatile` | `GROQ_API_KEY` |\n| Cerebras | `https://api.cerebras.ai/v1` | `llama-3.3-70b` | `CEREBRAS_API_KEY` |\n| Command Code | `https://api.commandcode.ai/provider/v1` | `deepseek/deepseek-v4-flash` | `COMMAND_CODE_API_KEY` |\n| Alibaba Model Studio (DashScope) | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3.8-flash` | `DASHSCOPE_API_KEY` |\n| AICraft | `https://aicraftapi.com/v1` | `claude-4.6-sonnet`; DeepSeek / Claude / Gemini / Qwen / GLM / MiniMax / Doubao families | `AICRAFT_API_KEY` |\n| Tsubasa | `https://api.tsubasa.sh/v1` | `tsubasa-pro`, `tsubasa-fast` (32,768-token context) | `TSUBASA_API_KEY` |\n| Cheaper Inference | `https://api.cheaperinference.com/v1` | `gpt-5.4-mini`, `claude-sonnet-5`, `gemini-3.1-pro` | `CHEAPER_INFERENCE_API_KEY` |\n| Yolo-Auto | `https://yolo-auto.com/v1` | `qwen3.8-flash`; `yolo` / `yolo-small` | `YOLO_AUTO_API_KEY` |\n| Standard Compute | `https://api.stdcmpt.com/v1` | `standardcompute` (router); `deepseek/deepseek-v4-pro`, `anthropic/claude-sonnet-5.5` | `STANDARDCOMPUTE_API_KEY` |\n| Wafer | `https://pass.wafer.ai/v1` | `GLM-5.3` (default), `GLM-5.2`, `Kimi-K3`, `DeepSeek-V4-Pro` | `WAFER_API_KEY` |\n| Runcrate | `https://api.runcrate.ai/v1` | `deepseek-ai/DeepSeek-V4.1-Flash`, `zai-org/GLM-5.3`, `openai/gpt-oss-120b` | `RUNCRATE_API_KELine truncated
},
{
"path": "docs/MODEL_LAB.md",
+9 -1
View File
@@ -42,7 +42,7 @@ export interface RepoFacts {
}
export const FACTS: RepoFacts = {
"generatedAt": "2026-11-11T04:33:00.951Z",
"generatedAt": "2026-11-11T04:36:18.685Z",
"sourceRevision": null,
"sourceCommittedAt": null,
"version": "0.10.3",
@@ -359,6 +359,14 @@ export const FACTS: RepoFacts = {
"reasoning": true,
"addedAt": "2026-11-10"
},
{
"id": "qwen4-max-preview",
"provider": "Qwen",
"contextWindow": null,
"maxOutput": null,
"reasoning": false,
"addedAt": "2026-11-10"
},
{
"id": "claude-fable-5-2",
"provider": "Anthropic",