- Add #[allow(dead_code)] on test-only Delta struct (content field
used for deserialization but not read in assertion)
- Remove duplicated #[test] attribute on
assistant_message_without_tool_calls_omits_tool_calls_field
Zero warnings in cargo test --workspace.
claw --output-format json --resume <session> (no command) was printing:
'Restored session from <path> (N messages).'
to stdout as prose, regardless of output format.
Now emits:
{"kind":"restored","session_id":"...","path":"...","message_count":N}
159 CLI tests pass.
/session list previously returned 'unsupported resumed slash command' in
--output-format json --resume mode. It only reads the sessions directory
so does not need a live runtime session.
Adds a Session{action:"list"} arm in run_resume_command() before the
unsupported catchall. Emits:
{kind:session_list, sessions:[...ids], active:<current-session-id>}
159 CLI tests pass.
'failed to restore session' errors from both the path-resolution step
and the JSONL-load step now check output_format and emit:
{"type":"error","error":"failed to restore session: <detail>"}
instead of bare eprintln prose.
Covers: session not found, corrupt JSONL, permission errors.
/stats was accidentally listed in STUB_COMMANDS (both in the original list
and overlooked in 1e14d59). Since SlashCommand::Stats is fully implemented
with REPL and resume dispatch, it should not be intercepted as unimplemented.
/tokens and /cache alias to Stats and were already working correctly.
/stats now works again in all modes.
23 spec-registered commands had no parse arm in validate_slash_command_input,
causing the circular error 'Unknown slash command: /X — Did you mean /X?'
when users typed them in --resume mode.
Two fixes:
1. Add the 23 confirmed parse-armless commands to STUB_COMMANDS (excluded
from REPL completions and help output).
2. In resume dispatch, intercept STUB_COMMANDS before SlashCommand::parse
and emit a clean '{error: "/X is not yet implemented in this build"}'
instead of the confusing error from the Err parse path.
Affected: /allowed-tools, /bookmarks, /workspace, /reasoning, /budget,
/rate-limit, /changelog, /diagnostics, /metrics, /tool-details, /focus,
/unfocus, /pin, /unpin, /language, /profile, /max-tokens, /temperature,
/system-prompt, /notifications, /telemetry, /env, /project, plus ~40
additional unreachable spec names.
159 CLI tests pass.
/export now emits: {kind:export, file:<path>, message_count:<n>}
/agents now emits: {kind:agents, text:<agents report>}
Previously both returned json:None and fell through to prose output even
in --output-format json --resume mode. 159 CLI tests pass.
Two integration tests expected 'model':'restored-session' in the /status
JSON output but dc4fa55 changed resume mode to emit null for model.
Updated both assertions to assert model is null (correct behavior).
Also remove unused 'estimate_session_tokens' import in compact.rs tests
(surfaced as warning in CI, kept failing CI green noise).
All workspace tests pass.
/help now emits: {kind:help, text:<full help text>}
/diff now emits:
- no git repo: {kind:diff, result:no_git_repo, detail:...}
- clean tree: {kind:diff, result:clean, staged:'', unstaged:''}
- changes: {kind:diff, result:changes, staged:..., unstaged:...}
Previously both returned json:None and fell through to prose output even
in --output-format json --resume mode. 159 CLI tests pass.
Before: doctor reported 'loaded 0/5' and listed 5 'Discovered file'
entries for paths that don't exist on disk. This looked like 5 files
failed to load, when in fact they are just standard search locations.
After: only paths that actually exist on disk are shown as 'Discovered
file'. 'loaded N/M' denominator is now the count of present files, not
candidate paths. With no config files present: 'loaded 0/0' +
'Discovered files <none> (defaults active)'.
159 CLI tests pass.
Two bugs in --output-format json --resume /status:
1. 'model' field emitted 'restored-session' (a run-mode label) instead of
the actual model or null. Fixed: status_json_value now takes Option<&str>
for model; resume path passes None; live REPL path passes Some(model).
2. 'session_id' extracted parent dir name ('sessions') instead of the file
stem. Session files are session-<id>.jsonl directly under .claw/sessions/,
not in a subdirectory. Fixed: extract file_stem() instead of
parent().file_name().
159 CLI tests pass.
Adds sanitize_tool_message_pairing() called from build_chat_completion_request()
after translate_message() runs. Drops any role:"tool" message whose
immediately-preceding non-tool message is role:"assistant" but has no
tool_calls entry matching the tool_call_id.
This is the second layer of the tool-pairing invariant defense:
- 6e301c8: compaction boundary fix (producer layer)
- this commit: request-builder sanitizer (sender layer)
Together these close the 400-error loop for resumed/compacted multi-turn
tool sessions on OpenAI-compatible backends.
Sanitization only fires when preceding message is role:assistant (not
user/system) to avoid dropping valid translation artifacts from mixed
user-message content blocks.
Regression tests: sanitize_drops_orphaned_tool_messages covers valid pair,
orphaned tool (no tool_calls in preceding assistant), mismatched id, and
two tool results both referencing the same assistant turn.
116 api + 159 CLI + 431 runtime tests pass. Fmt clean.
/compact now emits: {kind:compact, skipped, removed_messages, kept_messages}
/clear now emits: {kind:clear, previous_session_id, new_session_id, backup, session_file}
/clear (no --confirm) now emits: {kind:error, error:..., hint:...}
Previously both returned json:None and fell through to prose output even
in --output-format json --resume mode. 159 CLI tests pass.
Before:
error: failed to extract manifests: No such file or directory (os error 2)
After:
error: failed to extract manifests: No such file or directory (os error 2)
looked in: /Users/yeongyu/clawd/claw-code/rust
The workspace_dir is computed from CARGO_MANIFEST_DIR at compile time and
only resolves correctly when running from the build tree. Surfacing the
resolved path lets users understand immediately why it fails outside the
build context.
ROADMAP #45 root cause (build-tree-only path) remains open.
Add SlashCommand::slash_name() to the commands crate — returns the
canonical '/name' string for any variant. Used in the REPL's stub
catch-all arm to surface which command was typed instead of printing
the opaque 'Command registered but not yet implemented.'
Before: typing /rewind → 'Command registered but not yet implemented.'
After: typing /rewind → '/rewind is not yet implemented in this build.'
Also update the compacts_sessions_via_slash_command test assertion to
tolerate the boundary-guard fix from 6e301c8 (removed_message_count
can be 1 or 2 depending on whether the boundary falls on a tool-result
pair). All 159 CLI + 431 runtime + 115 api tests pass.
Two fixes:
1. compact.rs: When the compaction boundary falls at the start of a
tool-result turn, the preceding assistant turn with ToolUse would be
removed — leaving an orphaned role:tool message with no preceding
assistant tool_calls. OpenAI-compat backends reject this with 400.
Fix: after computing raw_keep_from, walk the boundary back until the
first preserved message is not a ToolResult (or its preceding assistant
has been included). Regression test added:
compaction_does_not_split_tool_use_tool_result_pair.
Source: gaebal-gajae multi-turn tool-call 400 repro 2026-04-09.
2. /cost resume: add JSON output:
{kind:cost, input_tokens, output_tokens, cache_creation_input_tokens,
cache_read_input_tokens, total_tokens}
159 CLI + 431 runtime tests pass. Fmt clean.
Two gaps closed:
1. /memory (resume): json field was None, emitting prose regardless of
--output-format json. Now emits:
{kind:memory, cwd, instruction_files:N, files:[{path,lines,preview}...]}
2. /providers (resume): had a spec entry but no parse arm, producing the
circular 'Unknown slash command: /providers — Did you mean /providers'.
Added 'providers' as an alias for 'doctor' in the parse match so
/providers dispatches to the same structured diagnostic output.
3. /doctor (resume): also wired json_value() so --output-format json
returns the structured doctor report instead of None.
Continues ROADMAP #26 resumed-command JSON parity track.
159 CLI tests pass, fmt clean.
Same fix as ff416ff but for the streaming path. Some backends embed an
error JSON object in an SSE data: frame:
data: {"error":{"message":"context too long","code":400}}
parse_sse_frame() was attempting to deserialize this as ChatCompletionChunk
and failing with 'missing field' / 'invalid type', hiding the actual
backend error message.
Fix: check for an 'error' key before full chunk deserialization, same as
the non-streaming path in ff416ff. Symmetric pair:
- ff416ff: non-streaming path (response body)
- this: streaming path (SSE data: frame)
115 api + 159 CLI tests pass. Fmt clean.
When a local/proxy OpenAI-compatible backend returns an error object:
{"error":{"message":"...","type":"...","code":...}}
claw was trying to deserialize it as a ChatCompletionResponse and
failing with the cryptic 'failed to parse OpenAI response: missing
field id', completely hiding the actual backend error message.
Fix: before full deserialization, check if the parsed JSON has an
'error' key and promote it directly to ApiError::Api so the user
sees the real error (e.g. 'The number of tokens to keep from the
initial prompt is greater than the context length').
Source: devilayu in #claw-code 2026-04-09 — local LM Studio context
limit error was invisible; user saw 'missing field id' instead.
159 CLI + 115 api tests pass. Fmt clean.
When serializing a multi-turn conversation for the OpenAI-compatible path,
assistant messages with no tool calls were always emitting 'tool_calls: []'.
Some providers reject requests where a prior assistant turn carries an
explicit empty tool_calls array (400 on subsequent turns after a plain
text assistant response).
Fix: only include 'tool_calls' in the serialized assistant message when
the vec is non-empty. Empty case omits the field entirely.
This is a companion fix to fd7aade (null tool_calls in stream delta).
The two bugs are symmetric: fd7aade handled inbound null -> empty vec;
this handles outbound empty vec -> field omitted.
Two regression tests added:
- assistant_message_without_tool_calls_omits_tool_calls_field
- assistant_message_with_tool_calls_includes_tool_calls_field
115 api tests pass. Fmt clean.
Source: gaebal-gajae repro 2026-04-09 (400 on multi-turn, companion to
null tool_calls stream-delta fix).
/config resumed returned json:None, falling back to prose output even in
--output-format json mode. Adds render_config_json() that produces:
{
"kind": "config",
"cwd": "...",
"loaded_files": N,
"merged_keys": N,
"files": [{"path":"...","source":"user|project|local","loaded":true|false}, ...]
}
Wires it into the SlashCommand::Config resume arm alongside the existing
prose render. Continues the resumed-command JSON parity track (ROADMAP #26).
159 CLI tests pass, fmt clean.
Some OpenAI-compatible providers emit 'tool_calls: null' in streaming
delta chunks instead of omitting the field or using an empty array:
"delta": {"content":"","function_call":null,"tool_calls":null}
serde's #[serde(default)] only handles absent keys — an explicit null
value still fails deserialization with:
'invalid type: null, expected a sequence'
Fix: replace #[serde(default)] with a custom deserializer helper
deserialize_null_as_empty_vec() that maps null -> Vec::default(),
keeping the existing absent-key default behaviour.
Regression test added: delta_with_null_tool_calls_deserializes_as_empty_vec
uses the exact provider response shape from gaebal-gajae's repro (2026-04-09).
112 api lib tests pass. Fmt clean.
Companion to gaebal-gajae's local 448cf2c — independently reproduced
and landed on main.
Dogfood found that /tokens and /cache had spec entries (resume_supported:
true) but no parse arms in the command parser, resulting in:
'Unknown slash command: /tokens — Did you mean /tokens'
(the suggestion engine found the spec entry but parsing always failed)
Fix three things:
1. Add 'tokens' | 'cache' as aliases for 'stats' in the parse match so
the commands actually resolve to SlashCommand::Stats
2. Implement SlashCommand::Stats in the REPL dispatch — previously fell
through to 'Command registered but not yet implemented'. Now shows
cumulative token usage for the session.
3. Implement SlashCommand::Stats in run_resume_command — previously
returned 'unsupported resumed slash command'. Now emits:
text: Cost / Input tokens / Output tokens / Cache create / Cache read
json: {kind:stats, input_tokens, output_tokens, cache_*, total_tokens}
159 CLI tests pass, fmt clean.
Users launching claw from their home directory (or /) have no project
boundary — the agent can read/search the entire machine, often far beyond
the intended scope. kapcomunica in #claw-code reported exactly this:
'it searched my entire computer.'
Add warn_if_broad_cwd() called at prompt and REPL startup:
- checks if CWD == $HOME or CWD has no parent (fs root)
- prints a clear warning to stderr:
Warning: claw is running from a very broad directory (/home/user).
The agent can read and search everything under this path.
Consider running from inside your project: cd /path/to/project && claw
Warning fires on both claw (REPL) and claw prompt '...' paths.
Does not fire from project subdirectories. Uses std::env::var_os("HOME"),
no extra deps.
159 CLI tests pass, fmt clean.
When claw --output-format json --resume <session> /commit (or /plugins, etc.)
encountered an 'unsupported resumed slash command' error, it called
eprintln!() and exit(2) directly, bypassing both the main() JSON error
handler and the output_format check.
Fix: in both the slash-command parse-error path and the run_resume_command
Err path, check output_format and emit a structured JSON error:
{"type":"error","error":"unsupported resumed slash command","command":"/commit"}
Text mode unchanged (still exits 2 with prose to stderr).
Addresses the resumed-command parity gap (gaebal-gajae ROADMAP #26 track).
159 CLI tests pass, fmt clean.
When stdin is not a terminal (pipe or redirect) and no prompt is given on
the command line, claw was starting the interactive REPL and printing the
startup banner, then consuming the pipe without sending anything to the API.
Fix: in parse_args, when rest.is_empty() and stdin is not a terminal, read
stdin synchronously and dispatch as CliAction::Prompt instead of Repl.
Empty pipe still falls through to Repl (interactive launch with no input).
Before: echo 'hello' | claw -> startup banner + REPL start
After: echo 'hello' | claw -> dispatches as one-shot prompt
159 CLI tests pass, fmt clean.
Previously claw --resume <session> /diff would produce:
'git diff --cached failed: error: unknown option `cached\''
when the CWD was not inside a git project, because git falls back to
--no-index mode which does not support --cached.
Two fixes:
1. render_diff_report_for() checks 'git rev-parse --is-inside-work-tree'
before running git diff, and returns a human-readable message if not
in a git repo:
'Diff\n Result no git repository\n Detail <cwd> is not inside a git project'
2. resume /diff now uses std::env::current_dir() instead of the session
file's parent directory as the CWD for the diff (session parent dir
is the .claw/sessions/<id>/ directory, never a git repo).
159 CLI tests pass, fmt clean.
Previously claw --output-format json --resume <session> /history emitted
prose text regardless of the output format flag. Now emits structured JSON:
{"kind":"history","total":N,"showing":M,"entries":[{"timestamp_ms":...,"text":"..."},...]}
Mirrors the parity pattern established in ROADMAP #26 for other resume commands.
159 CLI tests pass, fmt clean.
When running claw --output-format json --resume <session> /status, the
JSON output had 'session' (full file path) but no 'session_id' field,
making it impossible for scripts to extract the loaded session ID.
Now extracts the session-id directory component from the session path
(e.g. .claw/sessions/<session-id>/session-xxx.jsonl → session-id)
and includes it as 'session_id' in the JSON status envelope.
159 CLI tests pass, fmt clean.
Previously 'claw state' printed an error message but exited 0, making it
impossible for scripts/CI to detect the absence of state without parsing
prose. Now propagates Err() to main() which exits 1 and formats the error
correctly for both text and --output-format json modes.
Text: 'error: no worker state file found at ... — run a worker first'
JSON: {"type":"error","error":"no worker state file found at ..."}
3 new tests in mod tests:
- rejects_invalid_reasoning_effort_value: confirms 'turbo' etc rejected at parse time
- accepts_valid_reasoning_effort_values: confirms low/medium/high accepted and threaded
- stub_commands_absent_from_repl_completions: asserts STUB_COMMANDS are not in completions
156 -> 159 CLI tests pass.
When claw --output-format json hits an error, the error was previously
printed as plain prose to stderr, making it invisible to downstream tooling
that parses JSON output. Now:
{"type":"error","error":"api returned 401 ..."}
Detection: scan argv at process exit for --output-format json or
--output-format=json. Non-JSON error path unchanged. 156 CLI tests pass.
Users were typing skill names (e.g. 'caveman', 'find-skills') directly in
the REPL and getting LLM responses instead of skill invocation. Only
'/skills <name>' triggered dispatch; bare names fell through to run_turn.
Fix: after slash-command parse returns None (bare text), check if the first
token looks like a skill name (alphanumeric/dash/underscore, no slash).
If resolve_skill_invocation() confirms the skill exists, dispatch the full
input as a skill prompt. Unknown words fall through unchanged.
156 CLI tests pass, fmt clean.
Previously any string was accepted and silently forwarded to the API,
which would fail at the provider with an unhelpful error. Now invalid
values produce a clear error at parse time:
invalid value for --reasoning-effort: 'xyz'; must be low, medium, or high
156 CLI tests pass, fmt clean.
The --help slash-command section was listing ~35 unimplemented commands
alongside working ones. Combined with the completions fix (c55c510), the
discovery surface now consistently shows only implemented commands.
Changes:
- commands crate: add render_slash_command_help_filtered(exclude: &[&str])
- move STUB_COMMANDS to module-level const in main.rs (reused by both
completions and help rendering)
- replace render_slash_command_help() with filtered variant at all
help-rendering call sites
156 CLI tests pass, fmt clean.
'claw prompt --help' was triggering an API call instead of showing help
because --help was parsed as part of the prompt args. Now '--help' after
known pass-through subcommands (prompt, login, logout, version, state,
init, export, commit, pr, issue) sets wants_help=true and shows the
top-level help page.
Subcommands that consume their own args (agents, mcp, plugins, skills)
and local help-topic subcommands (status, sandbox, doctor) are excluded
from this interception so their existing --help handling is preserved.
156 CLI tests pass, fmt clean.
Commands registered in the spec list but not yet implemented in this build
were appearing in REPL tab-completions, making the discovery surface
over-promise what actually works. Users (mezz2301) reported 'many features
are not supported' after discovering these through completions.
Add STUB_COMMANDS exclusion list in slash_command_completion_candidates_with_sessions.
Excluded: login logout vim upgrade stats share feedback files fast exit
summary desktop brief advisor stickers insights thinkback release-notes
security-review keybindings privacy-settings plan review tasks theme
voice usage rename copy hooks context color effort branch rewind ide
tag output-style add-dir
These commands still parse and run (with the 'not yet implemented' message
for users who type them directly), but they no longer surface as
tab-completion candidates.
Parse --reasoning-effort <low|medium|high> in parse_args, thread through
CliAction::Prompt and CliAction::Repl, LiveCli::set_reasoning_effort(),
AnthropicRuntimeClient.reasoning_effort field, and MessageRequest.reasoning_effort.
Changes:
- parse_args: new --reasoning-effort / --reasoning-effort=VAL flag arms
- AnthropicRuntimeClient: new reasoning_effort field + set_reasoning_effort() method
- LiveCli: new set_reasoning_effort() that reaches through BuiltRuntime -> ConversationRuntime -> api_client_mut()
- runtime::ConversationRuntime: new pub api_client_mut() accessor
- MessageRequest construction: reasoning_effort: self.reasoning_effort.clone()
- run_repl(): accepts and applies reasoning_effort parameter
- parse_direct_slash_cli_action(): propagates reasoning_effort
All 156 CLI tests pass, all api tests pass, cargo fmt clean.
Adds reasoning_effort: Option<String> to CliAction::Prompt and
CliAction::Repl enum variants. All constructor and pattern sites updated.
All test literals updated with reasoning_effort: None.
156 cli tests pass, fmt clean. The --reasoning-effort flag parse and
propagation to AnthropicRuntimeClient remains as follow-up work.
gpt-5.x models reject requests with max_tokens and require max_completion_tokens.
Detect wire model starting with 'gpt-5' and switch the JSON key accordingly.
Older models (gpt-4o etc.) continue to receive max_tokens unchanged.
Two regression tests added:
- gpt5_uses_max_completion_tokens_not_max_tokens
- non_gpt5_uses_max_tokens
140 api tests pass, cargo fmt clean.
Users of OpenAI-compatible reasoning models (o4-mini, o3, deepseek-r1,
etc.) had no way to control reasoning effort — the field was missing from
MessageRequest and never emitted in the request body.
Changes:
- Add `reasoning_effort: Option<String>` to `MessageRequest` in types.rs
- Annotated with skip_serializing_if = "Option::is_none" for clean JSON
- Accepted values: "low", "medium", "high" (passed through verbatim)
- In `build_chat_completion_request`, emit `"reasoning_effort"` when set
- Two unit tests:
- `reasoning_effort_is_included_when_set`: o4-mini + "high" → field present
- `reasoning_effort_omitted_when_not_set`: gpt-4o, no field → absent
Existing callers use `..Default::default()` and are unaffected.
One struct-literal test that listed all fields explicitly updated with
`reasoning_effort: None`.
The CLI flag to expose this to users is a follow-up (ROADMAP #34 partial).
This commit lands the foundational API-layer plumbing needed for that.
Partial ROADMAP #34.
OpenAI /responses validates tool function schemas strictly:
- object types must have "properties" (at minimum {})
- "additionalProperties": false is required
/chat/completions is lenient and accepts schemas without these fields,
but /responses rejects them with "object schema missing properties" /
"invalid_function_parameters".
Add normalize_object_schema() which recursively walks the JSON Schema
tree and fills in missing "properties"/{} and "additionalProperties":false
on every object-type node. Existing values are not overwritten.
Call it in openai_tool_definition() before building the request payload
so both /chat/completions and /responses receive strict-validator-safe
schemas.
Add unit tests covering:
- bare object schema gets both fields injected
- nested object schemas are normalised recursively
- existing additionalProperties is not overwritten
Fixes the live repro where gpt-5.4 via OpenAI compat accepted connection
and routing but rejected every tool call with schema validation errors.
Closes ROADMAP #33.
web_search_extracts_and_filters_results set CLAWD_WEB_SEARCH_BASE_URL
without holding env_lock(), while the sibling test
web_search_handles_generic_links_and_invalid_base_url always held it.
Under parallel test execution the two tests interleave set_var/remove_var
calls, pointing the search client at the wrong mock server port and
causing assertion failures.
Fix: add env_lock() guard at the top of web_search_extracts_and_filters_results,
matching the serialization pattern already used by every other env-mutating
test in this module.
Root cause of CI flake on run 24127551802.
Identified and fixed during dogfood session.
Add rust/crates/rusty-claude-cli/build.rs that:
- Captures git rev-parse --short HEAD at build time → GIT_SHA env
- Reads Cargo's TARGET env var → TARGET env
- Derives BUILD_DATE from SOURCE_DATE_EPOCH / BUILD_DATE env or
the current date via `date +%Y-%m-%d` fallback
- Registers rerun-if-changed on .git/HEAD and .git/refs so the SHA
stays fresh across commits
Update main.rs DEFAULT_DATE to pick up BUILD_DATE from option_env!()
instead of the hardcoded 2026-03-31 static string.
Before: `claw --version` always showed Git SHA: unknown, Target: unknown,
Build date: 2026-03-31 in local builds.
After: e.g. Git SHA: 7f53d82, Target: aarch64-apple-darwin, Build date: 2026-04-08
Generated by droid (Kimi K2.5 Turbo) via acpx (wrote build.rs),
cleaned up by Jobdori (added BUILD_DATE step, updated main.rs const).
Co-Authored-By: Droid <noreply@factory.ai>
ProviderClient::from_model_with_anthropic_auth was dispatching every
ProviderKind::OpenAi match to OpenAiCompatConfig::openai(), which reads
OPENAI_API_KEY and points at api.openai.com. But DashScope models
(qwen-plus, qwen/qwen3-coder, etc.) also return ProviderKind::OpenAi
from detect_provider_kind because DashScope speaks the OpenAI wire
format. The metadata layer correctly identifies them as needing
DASHSCOPE_API_KEY and the DashScope compatible-mode endpoint, but that
metadata was being ignored at dispatch time.
Result: users running `claw --model qwen-plus` with DASHSCOPE_API_KEY
set would get a "missing OPENAI_API_KEY" error instead of being routed
to DashScope.
Fix: consult providers::metadata_for_model in the OpenAi dispatch arm
and pick dashscope() vs openai() based on metadata.auth_env.
Adds a regression test asserting ProviderClient::from_model("qwen-plus")
builds with the DashScope base URL. Exposes a pub base_url() accessor
on OpenAiCompatClient so the test can verify the routing.
Authored by droid (Kimi K2.5 Turbo) via acpx, cleaned up by Jobdori
(removed unsafe blocks unnecessary under edition 2021, imported
ProviderClient from super, adopted EnvVarGuard pattern from
providers/mod.rs tests).
Co-Authored-By: Droid <noreply@factory.ai>
The CLI entry point (build_runtime_with_plugin_state in main.rs)
was hardcoded to always instantiate AnthropicRuntimeClient with an
AnthropicClient, regardless of what detect_provider_kind(model)
returned. This meant `--model openai/gpt-4` with OPENAI_API_KEY
set and no ANTHROPIC_* vars still failed with "missing Anthropic
credentials" because the CLI never dispatched to the OpenAI-compat
backend that already exists in the api crate.
Root cause: AnthropicRuntimeClient.client was typed as
AnthropicClient (concrete) rather than ApiProviderClient (enum).
The api crate already had a ProviderClient enum with Anthropic /
Xai / OpenAi variants that dispatches correctly via
detect_provider_kind, plus a unified MessageStream enum that wraps
both anthropic::MessageStream and openai_compat::MessageStream
with the same next_event() -> StreamEvent interface. The CLI just
wasn't using it.
Changes (1 file, +59 -7):
- Import api::ProviderClient as ApiProviderClient
- Change AnthropicRuntimeClient.client from AnthropicClient to
ApiProviderClient
- In AnthropicRuntimeClient::new(), dispatch based on
detect_provider_kind(&resolved_model):
* Anthropic: build AnthropicClient directly with
resolve_cli_auth_source() + api::read_base_url() +
PromptCache (preserves ANTHROPIC_BASE_URL override for mock
test harness and the session-scoped prompt cache)
* xAI / OpenAi: delegate to
ApiProviderClient::from_model_with_anthropic_auth which routes
to OpenAiCompatClient::from_env with the matching config
(reads OPENAI_API_KEY/XAI_API_KEY/DASHSCOPE_API_KEY and their
BASE_URL overrides internally)
- Change push_prompt_cache_record to take &ApiProviderClient
(ProviderClient::take_last_prompt_cache_record returns None for
non-Anthropic variants, so the helper is a no-op on
OpenAI-compat providers without extra branching)
What this unlocks for users:
claw --model openai/gpt-4.1-mini prompt 'hello' # OpenAI
claw --model grok-3 prompt 'hello' # xAI
claw --model qwen-plus prompt 'hello' # DashScope
OPENAI_BASE_URL=https://openrouter.ai/api/v1 \
claw --model openai/anthropic/claude-sonnet-4 prompt 'hello' # OpenRouter
All previously broken, now routed correctly by prefix.
Verification:
- cargo build --release -p rusty-claude-cli: clean
- cargo test --release -p rusty-claude-cli: 182 tests, 0 failures
(including compact_output tests that exercise the Anthropic mock)
- cargo fmt --all: clean
- cargo clippy --workspace: warnings-only (pre-existing)
- cargo test --release --workspace: all crates green except one
pre-existing race in runtime::config::tests (passes in isolation)
Source: live users nicma (1491342350960562277) and Jengro
(1491345009021030533) in #claw-code on 2026-04-08.
Two live users in #claw-code on 2026-04-08 hit adjacent auth confusion:
varleg set OPENAI_API_KEY for OpenRouter but prefix routing didn't
activate without openai/ model prefix, and stanley078852 put sk-ant-*
in ANTHROPIC_AUTH_TOKEN (Bearer path) instead of ANTHROPIC_API_KEY
(x-api-key path) and got 401 Invalid bearer token.
Changes:
1. ApiError::MissingCredentials gained optional hint field (error.rs)
2. anthropic_missing_credentials_hint() sniffs OPENAI/XAI/DASHSCOPE
env vars and suggests prefix routing when present (providers/mod.rs)
3. All 4 Anthropic auth paths wire the hint helper (anthropic.rs)
4. 401 + sk-ant-* in bearer token detected and hint appended
5. 'Which env var goes where' section added to USAGE.md
Tests: unit tests for all three improvements (no HTTP calls needed).
Workspace: all tests green, fmt clean, clippy warnings-only.
Source: live users varleg + stanley078852 in #claw-code 2026-04-08.
Co-authored-by: gaebal-gajae <gaebal-gajae@layofflabs.com>