Compare commits

..
Author SHA1 Message Date
Yeachan-Heo 61fd7cfec5 Lock in Linux hook stdin BrokenPipe coverage
Latest main already contains the functional BrokenPipe tolerance in
plugins::hooks::CommandWithStdin::output_with_stdin, but the only
coverage for the original CI failure was the higher-level plugin hook
test. Add a deterministic regression that exercises the exact low-level
EPIPE path by spawning a hook child that closes stdin immediately while
the parent writes an oversized payload.

This keeps the real root cause explicit: Linux surfaced BrokenPipe from
the parent's stdin write after the hook child closed fd 0 early. Missing
execute bits were not the primary bug.

Constraint: Keep the change surgical on top of latest main
Rejected: Re-open the production code path | latest main already contains the runtime fix
Rejected: Inflate HookRunner payloads in the regression | HOOK_* env injection hit ARG_MAX before the pipe path
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep BrokenPipe coverage near CommandWithStdin so future refactors do not regress the Linux EPIPE path
Tested: cargo test -p plugins hooks::tests::collects_and_runs_hooks_from_enabled_plugins -- --exact (10x)
Tested: cargo test -p plugins hooks::tests::output_with_stdin_tolerates_broken_pipe_when_child_closes_stdin_early -- --exact (10x)
Tested: cargo test --workspace
Not-tested: GitHub Actions rerun on the PR branch
2026-04-08 11:04:27 +00:00
Jobdori 5c69713158 docs(roadmap): file OpenAI-compat model-id passthrough gap as #32 2026-04-08 19:48:34 +09:00
Jobdori 939d0dbaa3 docs(roadmap): file OpenAI-compat model-id passthrough gap as #32 2026-04-08 19:48:28 +09:00
Jobdori bfd5772716 docs(roadmap): file OpenAI-compat model-id passthrough gap as #32 2026-04-08 19:48:21 +09:00
Jobdori e0c3ff1673 docs(roadmap): file executor-contract leaks as ROADMAP #31 2026-04-08 18:34:58 +09:00
Jobdori 252536be74 fix(tools): serialize web_search env-var tests with env_lock to prevent race
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.
2026-04-08 18:34:06 +09:00
JobdoriandDroid 275b58546d feat(cli): populate Git SHA, target triple, and build date at compile time via build.rs
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>
2026-04-08 18:11:46 +09:00
Jobdori 7f53d82b17 docs(roadmap): file DashScope routing fix as #30 (done at adcea6b) 2026-04-08 18:05:17 +09:00
JobdoriandDroid adcea6bceb fix(api): route DashScope models to dashscope config, not openai
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>
2026-04-08 18:04:37 +09:00
YeonGyu-Kim b1491791df docs(roadmap): mark #21 and #29 as done
#21 (Resumed /status JSON parity gap): resolved by the broader
Resumed local-command JSON parity gap work tracked as #26. Re-verified
on main HEAD 8dc6580 — the regression test passes.

#29 (CLI provider dispatch hardcoded to Anthropic): landed at 8dc6580.
ApiProviderClient dispatch now routes correctly based on
detect_provider_kind. Original filing preserved as trace record.
2026-04-08 17:43:47 +09:00
YeonGyu-Kim 8dc65805c1 fix(cli): dispatch to correct provider backend based on model prefix — closes ROADMAP #29
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.
2026-04-08 17:29:55 +09:00
YeonGyu-Kim a9904fe693 docs(roadmap): file CLI provider dispatch bug as #29, mark #28 as partial
#28 error-copy improvements landed on ff1df4c but real users (nicma,
Jengro) hit `error: missing Anthropic credentials` within hours when
using `--model openai/gpt-4` with OPENAI_API_KEY set and all
ANTHROPIC_* env vars unset on main.

Traced root cause in build_runtime_with_plugin_state at line ~6244:
AnthropicRuntimeClient::new() is hardcoded. BuiltRuntime is
statically typed as ConversationRuntime<AnthropicRuntimeClient, ...>.
providers::detect_provider_kind() computes the right routing at the
metadata layer but the runtime client is never dispatched.

Files #29 with the detailed trace + a focused action plan:
DynamicApiClient enum wrapping Anthropic + OpenAiCompat variants,
retype BuiltRuntime, dispatch in build_runtime based on
detect_provider_kind, integration test with mock OpenAI-compat
server.

#28 is marked partial — the error-copy improvements are real and
stayed in, but the routing gap they were meant to cover is the
actual bug and needs #29 to land.
2026-04-08 17:01:14 +09:00
YeonGyu-Kimandgaebal-gajae ff1df4c7ac fix(api): auth-provider error copy — prefix-routing hints + sk-ant-* bearer detection — closes ROADMAP #28
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>
2026-04-08 16:29:03 +09:00
YeonGyu-Kim efa24edf21 docs(roadmap): file auth-provider truth pinpoint as backlog #28
Filed from live #claw-code dogfood on 2026-04-08 where two real users
hit adjacent auth confusion within minutes:

- varleg set OPENAI_API_KEY for OpenRouter but prefix routing didn't
  win because the model name wasn't prefixed with openai/; unsetting
  ANTHROPIC_API_KEY then hit MissingApiKey with no hint that the
  OpenAI path was already configured
- stanley078852 put an sk-ant-* key in ANTHROPIC_AUTH_TOKEN instead
  of ANTHROPIC_API_KEY, causing claw to send it as
  Authorization: Bearer sk-ant-..., which Anthropic rejects at the
  edge with 401 Invalid bearer token

Both fixes delivered live in #claw-code as direct replies, but the
pattern is structural: the error surface doesn't bridge HTTP-layer
symptoms back to env-var choice.

Action block spells out a single main-side PR with three
improvements: (a) MissingCredentials hint when an adjacent
provider's env var is already set, (b) 401-on-Anthropic hint when
bearer token starts with sk-ant-, (c) 'which env var goes where'
paragraph in both README matrices mapping sk-ant-* -> x-api-key and
OAuth access token -> Authorization: Bearer.

All three improvements are unit-testable against ApiError::fmt
output with no HTTP calls required.
2026-04-08 15:58:46 +09:00
YeonGyu-Kim 8339391611 docs(roadmap): correct #25 root cause — BrokenPipe tolerance, not chmod
The original ROADMAP #25 entry claimed the root cause was missing
exec bits on generated hook scripts. That was wrong — a chmod-only
fix (4f7b674) still failed CI. The actual bug was output_with_stdin
unconditionally propagating BrokenPipe from write_all when the child
exits before the parent finishes writing stdin.

Updated per gaebal-gajae's direction: actual fix, hygiene hardening,
and regression guard are now clearly separated. Added a meta-lesson
about Broken pipe ambiguity in fork/exec paths so future investigators
don't cargo-cult the same wrong first theory.
2026-04-08 15:53:26 +09:00
YeonGyu-Kimandgaebal-gajae 172a2ad50a fix(plugins): chmod +x generated hook scripts + tolerate BrokenPipe in stdin write — closes ROADMAP #25 hotfix lane
Two bugs found in the plugin hook test harness that together caused
Linux CI to fail on 'hooks::tests::collects_and_runs_hooks_from_enabled_plugins'
with 'Broken pipe (os error 32)'. Three reproductions plus one rerun
failure on main today: 24120271422, 24120538408, 24121392171.

Root cause 1 (chmod, defense-in-depth): write_hook_plugin writes
pre.sh/post.sh/failure.sh via fs::write without setting the execute
bit. While the runtime hook runner invokes hooks via 'sh <path>' (so
the script file does not strictly need +x), missing exec perms can
cause subtle fork/exec races on Linux in edge cases.

Root cause 2 (the actual CI failure): output_with_stdin unconditionally
propagated write_all errors on the child's stdin pipe, including
BrokenPipe. A hook script that runs to completion in microseconds
(e.g. a one-line printf) can exit and close its stdin before the parent
finishes writing the JSON payload. Linux pipes surface this as EPIPE
immediately; macOS pipes happen to buffer the small payload, so the
race only shows on ubuntu CI runners. The parent's write_all raised
BrokenPipe, which output_with_stdin returned as Err, which run_command
classified as 'failed to start', making the test assertion fail.

Fix: (a) make_executable helper sets mode 0o755 via PermissionsExt on
each generated hook script, with a #[cfg(unix)] gate and a no-op
#[cfg(not(unix))] branch. (b) output_with_stdin now matches the
write_all result and swallows BrokenPipe specifically (the child still
ran; wait_with_output still captures stdout/stderr/exit code), while
propagating all other write errors. (c) New regression guard
generated_hook_scripts_are_executable under #[cfg(unix)] asserts each
generated .sh file has at least one execute bit set.

Surgical scope per gaebal-gajae's direction: chmod + pipe tolerance +
regression guard only. The deeper plugin-test sealing pass for ROADMAP
#25 + #27 stays in gaebal-gajae's OMX lane.

Verification:
- cargo test --release -p plugins → 35 passing, 0 failing
- cargo fmt -p plugins → clean
- cargo clippy -p plugins -- -D warnings → clean

Co-authored-by: gaebal-gajae <gaebal-gajae@layofflabs.com>
2026-04-08 15:48:20 +09:00
YeonGyu-Kim 647ff379a4 docs(roadmap): file dev/rust plugin-validation host-home leak as backlog #27
Filing per gaebal-gajae's status summary at message 1491322807026454579
in #clawcode-building-in-public, with corrected scope after re-running
`cargo test -p rusty-claude-cli` against main HEAD (79da4b8): the 11
deterministic failures only reproduce on dev/rust, not main, so this is
a dev/rust catchup item rather than a main regression.

Two-layered root cause documented:
1. dev/rust `parse_args` eagerly validates user plugin hook scripts
   exist on disk before returning a CliAction
2. dev/rust test harness does not redirect $HOME/XDG_CONFIG_HOME to a
   fixture (no `env_lock` equivalent — main has 30+ env_lock hits, dev
   has zero)

Together they make dev/rust `cargo test -p rusty-claude-cli` fail on
any clean clone whose owner has a half-installed user plugin in
~/.claude/plugins/installed/. main has both the env_lock test isolation
AND the parse_args/hook-validation decoupling already; dev/rust is just
behind on the merge train.

Action block in #27 spells out backporting env_lock + the parse_args
decoupling so the next dev/rust release picks this up.
2026-04-08 15:30:04 +09:00
YeonGyu-Kim 79da4b8a63 docs(roadmap): record hooks test flake as P2 backlog item #25
Linux CI keeps tripping over
`plugins::hooks::tests::collects_and_runs_hooks_from_enabled_plugins`
with `Broken pipe (os error 32)` when the hook runner tries to spawn a
child shell script that was written by `write_hook_plugin` without the
execute bit set. Fails on first attempt, passes on rerun (observed in CI
runs 24120271422 and 24120538408). Passes consistently on macOS.

Since issues are disabled on the repo, recording as ROADMAP backlog
item #25 in the Immediate Backlog P2 cluster next to the related plugin
lifecycle flake at #24. Action block spells out the chmod +755 fix in
`write_hook_plugin` plus the regression guard.
2026-04-08 15:10:13 +09:00
YeonGyu-Kim 7d90283cf9 docs(roadmap): record cascade-masking pinpoint under green-ness contract (#9)
Concrete follow-up captured from today's dogfood session:

A single hung test (oversized-request preflight, 6 minutes per attempt
after `be561bf` silently swallowed count_tokens errors) crashed the
`cargo test --workspace` job before downstream crates could run, hiding
6 separate pre-existing CLI regressions until `8c6dfe5` + `5851f2d`
restored the fast-fail path.

Two new acceptance criteria for #9:
- per-test timeouts in CI so one hang cannot mask other failures
- distinguish `test.hung` from generic test failures in worker reports
2026-04-08 15:03:30 +09:00
12 changed files with 1050 additions and 39 deletions

No files matched your search

+30 -1
View File
@@ -194,6 +194,20 @@ Workers should distinguish:
Acceptance:
- no more ambiguous "tests passed" messaging
- merge policy can require the correct green level for the lane type
- a single hung test must not mask other failures: enforce per-test
timeouts in CI (`cargo test --workspace`) so a 6-minute hang in one
crate cannot prevent downstream crates from running their suites
- when a CI job fails because of a hang, the worker must report it as
`test.hung` rather than a generic failure, so triage doesn't conflate
it with a normal `assertion failed`
- recorded pinpoint (2026-04-08): `be561bf` swapped the local
byte-estimate preflight for a `count_tokens` round-trip and silently
returned `Ok(())` on any error, so `send_message_blocks_oversized_*`
hung for ~6 minutes per attempt; the resulting workspace job crash
hid 6 *separate* pre-existing CLI regressions (compact flag
discarded, piped stdin vs permission prompter, legacy session layout,
help/prompt assertions, mock harness count) that only became
diagnosable after `8c6dfe5` + `5851f2d` restored the fast-fail path
## Phase 4 — Claws-First Task Execution
@@ -307,11 +321,16 @@ Priority order: P0 = blocks CI/green state, P1 = blocks integration wiring, P2 =
18. **Context-window preflight gap** — **done**: provider request sizing now emits `context_window_blocked` before oversized requests leave the process, using a model-context registry instead of the old naive max-token heuristic.
19. **Subcommand help falls through into runtime/API path** — **done**: `claw doctor --help`, `claw status --help`, `claw sandbox --help`, and nested `mcp`/`skills` help are now intercepted locally without runtime/provider startup, with regression tests covering the direct CLI paths.
20. **Session state classification gap (working vs blocked vs finished vs truly stale)** — **done**: agent manifests now derive machine states such as `working`, `blocked_background_job`, `blocked_merge_conflict`, `degraded_mcp`, `interrupted_transport`, `finished_pending_report`, and `finished_cleanable`, and terminal-state persistence records commit provenance plus derived state so downstream monitoring can distinguish quiet progress from truly idle sessions.
21. **Resumed `/status` JSON parity gap** — dogfooding shows fresh `claw status --output-format json` now emits structured JSON, but resumed slash-command status still leaks through a text-shaped path in at least one dispatch path. Local CI-equivalent repro fails `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs::resumed_status_command_emits_structured_json_when_requested` with `expected value at line 1 column 1`, so resumed automation can receive text where JSON was explicitly requested. **Action:** unify fresh vs resumed `/status` rendering through one output-format contract and add regression coverage so resumed JSON output is guaranteed valid.
21. **Resumed `/status` JSON parity gap** — **done**: resolved by the broader "Resumed local-command JSON parity gap" work tracked as #26 below. Re-verified on `main` HEAD `8dc6580` — `cargo test --release -p rusty-claude-cli resumed_status_command_emits_structured_json_when_requested` passes cleanly (1 passed, 0 failed), so resumed `/status --output-format json` now goes through the same structured renderer as the fresh CLI path. The original failure (`expected value at line 1 column 1` because resumed dispatch fell back to prose) no longer reproduces.
22. **Opaque failure surface for session/runtime crashes** — **done**: `safe_failure_class()` in `error.rs` classifies all API errors into 8 user-safe classes (`provider_auth`, `provider_internal`, `provider_retry_exhausted`, `provider_rate_limit`, `provider_transport`, `provider_error`, `context_window`, `runtime_io`). `format_user_visible_api_error` in `main.rs` attaches session ID + request trace ID to every user-visible error. Coverage in `opaque_provider_wrapper_surfaces_failure_class_session_and_trace` and 3 related tests.
23. **`doctor --output-format json` check-level structure gap** — **done**: `claw doctor --output-format json` now keeps the human-readable `message`/`report` while also emitting structured per-check diagnostics (`name`, `status`, `summary`, `details`, plus typed fields like workspace paths and sandbox fallback data), with regression coverage in `output_format_contract.rs`.
24. **Plugin lifecycle init/shutdown test flakes under workspace-parallel execution** — dogfooding surfaced that `build_runtime_runs_plugin_lifecycle_init_and_shutdown` can fail under `cargo test --workspace` while passing in isolation because sibling tests race on tempdir-backed shell init script paths. This is test brittleness rather than a code-path regression, but it still destabilizes CI confidence and wastes diagnosis cycles. **Action:** isolate temp resources per test robustly (unique dirs + no shared cwd assumptions), audit cleanup timing, and add a regression guard so the plugin lifecycle test remains stable under parallel workspace execution.
25. **`plugins::hooks::collects_and_runs_hooks_from_enabled_plugins` flaked on Linux CI, root cause was a stdin-write race not missing exec bit** — **done at `172a2ad` on 2026-04-08**. Dogfooding reproduced this four times on `main` (CI runs [24120271422](https://github.com/ultraworkers/claw-code/actions/runs/24120271422), [24120538408](https://github.com/ultraworkers/claw-code/actions/runs/24120538408), [24121392171](https://github.com/ultraworkers/claw-code/actions/runs/24121392171), [24121776826](https://github.com/ultraworkers/claw-code/actions/runs/24121776826)), escalating from first-attempt-flake to deterministic-red on the third push. Failure mode was `PostToolUse hook .../hooks/post.sh failed to start for "Read": Broken pipe (os error 32)` surfacing from `HookRunResult`. **Initial diagnosis was wrong.** The first theory (documented in earlier revisions of this entry and in the root-cause note on commit `79da4b8`) was that `write_hook_plugin` in `rust/crates/plugins/src/hooks.rs` was writing the generated `.sh` files without the execute bit and `Command::new(path).spawn()` was racing on fork/exec. An initial chmod-only fix at `4f7b674` was shipped against that theory and **still failed CI on run `24121776826`** with the same `Broken pipe` symptom, falsifying the chmod-only hypothesis. **Actual root cause.** `CommandWithStdin::output_with_stdin` in `rust/crates/plugins/src/hooks.rs` was unconditionally propagating `write_all` errors on the child's stdin pipe, including `std::io::ErrorKind::BrokenPipe`. The test hook scripts run in microseconds (`#!/bin/sh` + a single `printf`), so the child exits and closes its stdin before the parent finishes writing the ~200-byte JSON hook payload. On Linux the pipe raises `EPIPE` immediately; on macOS the pipe happens to buffer the small payload before the child exits, which is why the race only surfaced on ubuntu CI runners. The parent's `write_all` returned `Err(BrokenPipe)`, `output_with_stdin` returned that as a hook failure, and `run_command` classified the hook as "failed to start" even though the child had already run to completion and printed the expected message to stdout. **Fix (commit `172a2ad`, force-pushed over `4f7b674`).** Three parts: (1) **actual fix** — `output_with_stdin` now matches the `write_all` result and swallows `BrokenPipe` specifically, while propagating all other write errors unchanged; after a `BrokenPipe` swallow the code still calls `wait_with_output()` so stdout/stderr/exit code are still captured from the cleanly-exited child. (2) **hygiene hardening** — a new `make_executable` helper sets mode `0o755` on each generated `.sh` via `std::os::unix::fs::PermissionsExt` under `#[cfg(unix)]`. This is defense-in-depth for future non-sh hook runners, not the bug that was biting CI. (3) **regression guard** — new `generated_hook_scripts_are_executable` test under `#[cfg(unix)]` asserts each generated `.sh` file has at least one execute bit set (`mode & 0o111 != 0`) so future tweaks cannot silently regress the hygiene change. **Verification.** `cargo test --release -p plugins` 35 passing, fmt clean, clippy `-D warnings` clean; CI run [24121999385](https://github.com/ultraworkers/claw-code/actions/runs/24121999385) went green on first attempt on `main` for the hotfix commit. **Meta-lesson.** `Broken pipe (os error 32)` from a child-process spawn path is ambiguous between "could not exec" and "exec'd and exited before the parent finished writing stdin." The first theory cargo-culted the "could not exec" reading because the ROADMAP scaffolding anchored on the exec-bit guess; falsification came from empirical CI, not from code inspection. Record the pattern: when a pipe error surfaces on fork/exec, instrument what `wait_with_output()` actually reports on the child before attributing the failure to a permissions or path issue.
26. **Resumed local-command JSON parity gap** — **done**: direct `claw --output-format json` already had structured renderers for `sandbox`, `mcp`, `skills`, `version`, and `init`, but resumed `claw --output-format json --resume <session> /…` paths still fell back to prose because resumed slash dispatch only emitted JSON for `/status`. Resumed `/sandbox`, `/mcp`, `/skills`, `/version`, and `/init` now reuse the same JSON envelopes as their direct CLI counterparts, with regression coverage in `rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs` and `rust/crates/rusty-claude-cli/tests/output_format_contract.rs`.
27. **`dev/rust` `cargo test -p rusty-claude-cli` reads host `~/.claude/plugins/installed/` from real `$HOME` and fails parse-time on any half-installed user plugin** — dogfooding on 2026-04-08 (filed from gaebal-gajae's clawhip bullet at message `1491322807026454579` after the provider-matrix branch QA surfaced it) reproduced 11 deterministic failures on clean `dev/rust` HEAD of the form `panicked at crates/rusty-claude-cli/src/main.rs:3953:31: args should parse: "hook path \`/Users/yeongyu/.claude/plugins/installed/sample-hooks-bundled/./hooks/pre.sh\` does not exist; hook path \`...\post.sh\` does not exist"` covering `parses_prompt_subcommand`, `parses_permission_mode_flag`, `defaults_to_repl_when_no_args`, `parses_resume_flag_with_slash_command`, `parses_system_prompt_options`, `parses_bare_prompt_and_json_output_flag`, `rejects_unknown_allowed_tools`, `parses_resume_flag_with_multiple_slash_commands`, `resolves_model_aliases_in_args`, `parses_allowed_tools_flags_with_aliases_and_lists`, `parses_login_and_logout_subcommands`. **Same failures do NOT reproduce on `main`** (re-verified with `cargo test --release -p rusty-claude-cli` against `main` HEAD `79da4b8`, all 156 tests pass). **Root cause is two-layered.** First, on `dev/rust` `parse_args` eagerly walks user-installed plugin manifests under `~/.claude/plugins/installed/` and validates that every declared hook script exists on disk before returning a `CliAction`, so any half-installed plugin in the developer's real `$HOME` (in this case `~/.claude/plugins/installed/sample-hooks-bundled/` whose `.claude-plugin` manifest references `./hooks/pre.sh` and `./hooks/post.sh` but whose `hooks/` subdirectory was deleted) makes argv parsing itself fail. Second, the test harness on `dev/rust` does not redirect `$HOME` or `XDG_CONFIG_HOME` to a fixture for the duration of the test — there is no `env_lock`-style guard equivalent to the one `main` already uses (`grep -n env_lock rust/crates/rusty-claude-cli/src/main.rs` returns 0 hits on `dev/rust` and 30+ hits on `main`). Together those two gaps mean `dev/rust` `cargo test -p rusty-claude-cli` is non-deterministic on every clean clone whose owner happens to have any non-pristine plugin in `~/.claude/`. **Action (two parts).** (a) Backport the `env_lock`-based test isolation pattern from `main` into `dev/rust`'s `rusty-claude-cli` test module so each test runs against a temp `$HOME`/`XDG_CONFIG_HOME` and cannot read host plugin state. (b) Decouple `parse_args` from filesystem hook validation on `dev/rust` (the same decoupling already on `main`, where hook validation happens later in the lifecycle than argv parsing) so even outside tests a partially installed user plugin cannot break basic CLI invocation. **Branch scope.** This is a `dev/rust` catchup against `main`, not a `main` regression. Tracking it here so the dev/rust merge train picks it up before the next dev/rust release rather than rediscovering it in CI.
28. **Auth-provider truth: error copy fails real users at the env-var-vs-header layer** — dogfooded live on 2026-04-08 in #claw-code (Sisyphus Labs guild), two separate new users hit adjacent failure modes within minutes of each other that both trace back to the same root: the `MissingApiKey` / 401 error surface does not teach users how the auth inputs map to HTTP semantics, so a user who sets a "reasonable-looking" env var still hits a hard error with no signpost. **Case 1 (varleg, Norway).** Wanted to use OpenRouter via the OpenAI-compat path. Found a comparison table claiming "provider-agnostic (Claude, OpenAI, local models)" and assumed it Just Worked. Set `OPENAI_API_KEY` to an OpenRouter `sk-or-v1-...` key and a model name without an `openai/` prefix; claw's provider detection fell through to Anthropic first because `ANTHROPIC_API_KEY` was still in the environment. Unsetting `ANTHROPIC_API_KEY` got them `ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set` instead of a useful hint that the OpenAI path was right there. Fix delivered live as a channel reply: use `main` branch (not `dev/rust`), export `OPENAI_BASE_URL=https://openrouter.ai/api/v1` alongside `OPENAI_API_KEY`, and prefix the model name with `openai/` so the prefix router wins over env-var presence. **Case 2 (stanley078852).** Had set `ANTHROPIC_AUTH_TOKEN="sk-ant-..."` and was getting 401 `Invalid bearer token` from Anthropic. Root cause: `sk-ant-` keys are `x-api-key`-header keys, not bearer tokens. `ANTHROPIC_API_KEY` path in `anthropic.rs` sends the value as `x-api-key`; `ANTHROPIC_AUTH_TOKEN` path sends it as `Authorization: Bearer` (for OAuth access tokens from `claw login`). Setting an `sk-ant-` key in the wrong env var makes claw send it as `Bearer sk-ant-...` which Anthropic rejects at the edge with 401 before it ever reaches the completions endpoint. The error text propagated all the way to the user (`api returned 401 Unauthorized (authentication_error) ... Invalid bearer token`) with zero signal that the problem was env-var choice, not key validity. Fix delivered live as a channel reply: move the `sk-ant-...` key to `ANTHROPIC_API_KEY` and unset `ANTHROPIC_AUTH_TOKEN`. **Pattern.** Both cases are failures at the *auth-intent translation* layer: the user chose an env var that made syntactic sense to them (`OPENAI_API_KEY` for OpenAI, `ANTHROPIC_AUTH_TOKEN` for Anthropic auth) but the actual wire-format routing requires a more specific choice. The error messages surface the HTTP-layer symptom (401, missing-key) without bridging back to "which env var should you have used and why." **Action.** Three concrete improvements, scoped for a single `main`-side PR: (a) In `ApiError::MissingCredentials` Display, when the Anthropic path is the one being reported but `OPENAI_API_KEY`, `XAI_API_KEY`, or `DASHSCOPE_API_KEY` are present in the environment, extend the message with "— but I see `$OTHER_KEY` set; if you meant to use that provider, prefix your model name with `openai/`, `grok`, or `qwen/` respectively so prefix routing selects it." (b) In the 401-from-Anthropic error path in `anthropic.rs`, when the failing auth source is `BearerToken` AND the bearer token starts with `sk-ant-`, append "— looks like you put an `sk-ant-*` API key in `ANTHROPIC_AUTH_TOKEN`, which is the Bearer-header path. Move it to `ANTHROPIC_API_KEY` instead (that env var maps to `x-api-key`, which is the correct header for `sk-ant-*` keys)." Same treatment for OAuth access tokens landing in `ANTHROPIC_API_KEY` (symmetric mis-assignment). (c) In `rust/README.md` on `main` and the matrix section on `dev/rust`, add a short "Which env var goes where" paragraph mapping `sk-ant-*` → `ANTHROPIC_API_KEY` and OAuth access token → `ANTHROPIC_AUTH_TOKEN`, with the one-line explanation of `x-api-key` vs `Authorization: Bearer`. **Verification path.** Both improvements can be tested with unit tests against `ApiError::fmt` output (the prefix-routing hint) and with a targeted integration test that feeds an `sk-ant-*`-shaped token into `BearerToken` and asserts the fmt output surfaces the correction hint (no HTTP call needed). **Source.** Live users in #claw-code at `1491328554598924389` (varleg) and `1491329840706486376` (stanley078852) on 2026-04-08. **Partial landing (`ff1df4c`).** Action parts (a), (b), (c) shipped on `main`: `MissingCredentials` now carries an optional hint field and renders adjacent-provider signals, Anthropic 401 + `sk-ant-*` bearer gets a correction hint, USAGE.md has a "Which env var goes where" section. BUT the copy fix only helps users who fell through to the Anthropic auth path by accident — it does NOT fix the underlying routing bug where the CLI instantiates `AnthropicRuntimeClient` unconditionally and ignores prefix routing at the runtime-client layer. That deeper routing gap is tracked separately as #29 below and was filed within hours of #28 landing when live users still hit `missing Anthropic credentials` with `--model openai/gpt-4`Line truncated
29. **CLI provider dispatch is hardcoded to Anthropic, ignoring prefix routing** — **done at `8dc6580` on 2026-04-08**. Changed `AnthropicRuntimeClient.client` from concrete `AnthropicClient` to `ApiProviderClient` (the api crate’s `ProviderClient` enum), which dispatches to Anthropic / xAI / OpenAi at construction time based on `detect_provider_kind(&resolved_model)`. 1 file, +59 −7, all 182 rusty-claude-cli tests pass, CI green at run `24125825431`. Users can now run `claw --model openai/gpt-4.1-mini prompt "hello"` with only `OPENAI_API_KEY` set and it routes correctly. **Original filing below for the trace record.** Dogfooded live on 2026-04-08 within hours of ROADMAP #28 landing. Users in #claw-code (nicma at `1491342350960562277`, Jengro at `1491345009021030533`) followed the exact "use main, set OPENAI_API_KEY and OPENAI_BASE_URL, unset ANTHROPIC_*, prefix the model with `openai/`" checklist from the #28 error-copy improvements AND STILL hit `error: missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API`. **Reproduction on `main` HEAD `ff1df4c`:** `unset ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN; export OPENAI_API_KEY=sk-...; export OPENAI_BASE_URL=https://api.openai.com/v1; claw --model openai/gpt-4 prompt 'test'` → reproduces the error deterministically. **Root cause (traced).** `rust/crates/rusty-claude-cli/src/main.rs` at `build_runtime_with_plugin_state` (line ~6221) unconditionally builds `AnthropicRuntimeClient::new(session_id, model, ...)` without consulting `providers::detect_provider_kind(&model)`. `BuiltRuntime` at line ~2855 is statically typed as `ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>`, so even if the dispatch logic existed there would be nowhere to slot an alternative client. `providers/mod.rs::metadata_for_model` correctly identifies `openai/gpt-4` as `ProviderKind::OpenAi` at the metadata layer — the routing decision is *computed* correctly, it's just *never used* to pick a runtime client. The result is that the CLI is structurally single-provider (Anthropic only) even though the `api` crate's `openai_compat.rs`, `XAI_ENV_VARS`, `DASHSCOPE_ENV_VARS`, and `send_message_streaming` all exist and are exercised by unit tests inside the `api` crate. The provider matrix in `rust/README.md` is misleading because it describes the api-crate capabilities, not the CLI's actual dispatch behaviour. **Why #28 didn't catch this.** ROADMAP #28 focused on the `MissingCredentials` error *message* (adding hints when adjacent provider env vars are set, or when a bearer token starts with `sk-ant-*`). None of its tests exercised the `build_runtime` code path — they were all unit tests against `ApiError::fmt` output. The routing bug survives #28 because the `Display` improvements fire AFTER the hardcoded Anthropic client has already been constructed and failed. You need the CLI to dispatch to a different client in the first place for the new hints to even surface at the right moment. **Action (single focused commit).** (1) New `OpenAiCompatRuntimeClient` struct in `rust/crates/rusty-claude-cli/src/main.rs` mirroring `AnthropicRuntimeClient` but delegating to `openai_compat::send_message_streaming`. One client type handles OpenAI, xAI, DashScope, and any OpenAI-compat endpoint — they differ only in base URL and auth env var, both of which come from the `ProviderMetadata` returned by `metadata_for_model`. (2) New enum `DynamicApiClient { Anthropic(AnthropicRuntimeClient), OpenAiCompat(OpenAiCompatRuntimeClient) }` that implements `runtime::ApiClient` by matching on the variant and delegating. (3) Retype `BuiltRuntime` from `ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>` to `ConversationRuntime<DynamicApiClient, CliToolExecutor>`, update the Deref/DerefMut/new spots. (4) In `build_runtime_with_plugin_state`, call `detect_provider_kind(&model)` and construct either variant of `DynamicApiClient`. Prefix routing wins over env-var presence (that's the whole point). (5) Integration test using a mock OpenAI-compat server (reuse `mock_parity_harness` pattern from `crates/api/tests/`) that feeds `claw --model openai/gpt-4 prompt 'test'` with `OPENAI_BASE_URL` pointed at the mock and no `ANTHROPIC_*` env vars, asserts the request reaches the mock, and asserts the response round-trips as an `AssistantEvent`. (6) Unit test that `build_runtime_with_plugin_state` with `model="openai/gpt-4"` returns a `BuiltRuntime` whose inner client is the `DynamicApiClient::OpenAiCompat` variant. **Verification.** `cargo test --workspace`, `cargo fmt --all`, `cargo clippy --workspace`. **Source.** Live users nicma (`1491342350960562277`) and Jengro (`1491345009021030533`) in #claw-code on 2026-04-08, within hours of #28 landing.
41. **Phantom completions root cause: global session store has no per-worktree isolation** —
@@ -459,3 +478,13 @@ If `ANTHROPIC_API_KEY` was present in the environment (e.g. user has both Anthro
Model name prefix now wins unconditionally over env-var presence. Regression test locked in: `providers::tests::openai_namespaced_model_routes_to_openai_not_anthropic`.
**Lesson:** Auth-sniffer fallback order is fragile. Any new provider added in the future should be registered in `metadata_for_model` via a model-name prefix, not left to env-var order. This is the canonical extension point.
30. **DashScope model routing in ProviderClient dispatch uses wrong config** — **done at `adcea6b` on 2026-04-08**. `ProviderClient::from_model_with_anthropic_auth` dispatched all `ProviderKind::OpenAi` matches to `OpenAiCompatConfig::openai()` (reads `OPENAI_API_KEY`, points at `api.openai.com`). But DashScope models (`qwen-plus`, `qwen/qwen-max`) return `ProviderKind::OpenAi` because DashScope speaks the OpenAI wire format — they need `OpenAiCompatConfig::dashscope()` (reads `DASHSCOPE_API_KEY`, points at `dashscope.aliyuncs.com/compatible-mode/v1`). Fix: consult `metadata_for_model` in the `OpenAi` dispatch arm and pick `dashscope()` vs `openai()` based on `metadata.auth_env`. Adds regression test + `pub base_url()` accessor. 2 files, +94/−3. Authored by droid (Kimi K2.5 Turbo) via acpx, cleaned up by Jobdori.
31. **`code-on-disk → verified commit lands` depends on undocumented executor quirks** — dogfooded 2026-04-08 during live fix session. Three hidden contracts tripped the "last mile" path when using droid via acpx in the claw-code workspace: **(a) hidden CWD contract** — droid's `terminal/create` rejects `cd /path && cargo build` compound commands with `spawn ENOENT`; callers must pass `--cwd` or split commands; **(b) hidden commit-message transport limit** — embedding a multi-line commit message in a single shell invocation hits `ENAMETOOLONG`; workaround is `git commit -F <file>` but the caller must know to write the file first; **(c) hidden workspace lint/edition contract** — `unsafe_code = "forbid"` workspace-wide with Rust 2021 edition makes `unsafe {}` wrappers incorrect for `set_var`/`remove_var`, but droid generates Rust 2024-style unsafe blocks without inspecting the workspace Cargo.toml or clippy config. Each of these required the orchestrator to learn the constraint by failing, then switching strategies. **Acceptance bar:** a fresh agent should be able to verify/commit/push a correct diff in this workspace without needing to know executor-specific shell trivia ahead of time. **Fix shape:** (1) `run-acpx.sh`-style wrapper that normalizes the commit idiom (always writes to temp file, sets `--cwd`, splits compound commands); (2) inject workspace constraints into the droid/acpx task preamble (edition, lint gates, known shell executor quirks) so the model doesn't have to discover them from failures; (3) or upstream a fix to the executor itself so `cd /path && cmd` chains work correctly.
32. **OpenAI-compatible provider/model-id passthrough is not fully literal** — dogfooded 2026-04-08 via live user in #claw-code who confirmed the exact backend model id works outside claw but fails through claw for an OpenAI-compatible endpoint. The gap: `openai/` prefix is correctly used for **transport selection** (pick the OpenAI-compat client) but the **wire model id** — the string placed in `"model": "..."` in the JSON request body — may not be the literal backend model string the user supplied. Two candidate failure modes: **(a)** `resolve_model_alias()` is called on the model string before it reaches the wire — alias expansion designed for Anthropic/known models corrupts a user-supplied backend-specific id; **(b)** the `openai/` routing prefix may not be stripped before `build_chat_completion_request` packages the body, so backends receive `openai/gpt-4` instead of `gpt-4`. **Fix shape:** cleanly separate transport selection from wire model id. Transport selection uses the prefix; wire model id is the user-supplied string minus only the routing prefix — no alias expansion, no prefix leakage. **Trace path for next session:** (1) find where `resolve_model_alias()` is called relative to the OpenAI-compat dispatch path; (2) inspect what `build_chat_completion_request` puts in `"model"` for an `openai/some-backend-id` input. **Source:** live user in #claw-code 2026-04-08, confirmed exact model id works outside claw, fails through claw for OpenAI-compat backend.
32. **OpenAI-compatible provider/model-id passthrough is not fully literal** — dogfooded 2026-04-08 via live user in #claw-code who confirmed the exact backend model id works outside claw but fails through claw for an OpenAI-compatible endpoint. The gap: `openai/` prefix is correctly used for **transport selection** (pick the OpenAI-compat client) but the **wire model id** — the string placed in `"model": "..."` in the JSON request body — may not be the literal backend model string the user supplied. Two candidate failure modes: **(a)** `resolve_model_alias()` is called on the model string before it reaches the wire — alias expansion designed for Anthropic/known models corrupts a user-supplied backend-specific id; **(b)** the `openai/` routing prefix may not be stripped before `build_chat_completion_request` packages the body, so backends receive `openai/gpt-4` instead of `gpt-4`. **Fix shape:** cleanly separate transport selection from wire model id. Transport selection uses the prefix; wire model id is the user-supplied string minus only the routing prefix — no alias expansion, no prefix leakage. **Trace path for next session:** (1) find where `resolve_model_alias()` is called relative to the OpenAI-compat dispatch path; (2) inspect what `build_chat_completion_request` puts in `"model"` for an `openai/some-backend-id` input. **Source:** live user in #claw-code 2026-04-08, confirmed exact model id works outside claw, fails through claw for OpenAI-compat backend.
32. **OpenAI-compatible provider/model-id passthrough is not fully literal** — dogfooded 2026-04-08 via live user in #claw-code who confirmed the exact backend model id works outside claw but fails through claw for an OpenAI-compatible endpoint. The gap: `openai/` prefix is correctly used for **transport selection** (pick the OpenAI-compat client) but the **wire model id** — the string placed in `"model": "..."` in the JSON request body — may not be the literal backend model string the user supplied. Two candidate failure modes: **(a)** `resolve_model_alias()` is called on the model string before it reaches the wire — alias expansion designed for Anthropic/known models corrupts a user-supplied backend-specific id; **(b)** the `openai/` routing prefix may not be stripped before `build_chat_completion_request` packages the body, so backends receive `openai/gpt-4` instead of `gpt-4`. **Fix shape:** cleanly separate transport selection from wire model id. Transport selection uses the prefix; wire model id is the user-supplied string minus only the routing prefix — no alias expansion, no prefix leakage. **Trace path for next session:** (1) find where `resolve_model_alias()` is called relative to the OpenAI-compat dispatch path; (2) inspect what `build_chat_completion_request` puts in `"model"` for an `openai/some-backend-id` input. **Source:** live user in #claw-code 2026-04-08, confirmed exact model id works outside claw, fails through claw for OpenAI-compat backend.
+14
View File
@@ -109,6 +109,20 @@ cd rust
./target/debug/claw logout
```
### Which env var goes where
`claw` accepts two Anthropic credential env vars and they are **not interchangeable** — the HTTP header Anthropic expects differs per credential shape. Putting the wrong value in the wrong slot is the most common 401 we see.
| Credential shape | Env var | HTTP header | Typical source |
|---|---|---|---|
| `sk-ant-*` API key | `ANTHROPIC_API_KEY` | `x-api-key: sk-ant-...` | [console.anthropic.com](https://console.anthropic.com) |
| OAuth access token (opaque) | `ANTHROPIC_AUTH_TOKEN` | `Authorization: Bearer ...` | `claw login` or an Anthropic-compatible proxy that mints Bearer tokens |
| OpenRouter key (`sk-or-v1-*`) | `OPENAI_API_KEY` + `OPENAI_BASE_URL=https://openrouter.ai/api/v1` | `Authorization: Bearer ...` | [openrouter.ai/keys](https://openrouter.ai/keys) |
**Why this matters:** if you paste an `sk-ant-*` key into `ANTHROPIC_AUTH_TOKEN`, Anthropic's API will return `401 Invalid bearer token` because `sk-ant-*` keys are rejected over the Bearer header. The fix is a one-line env var swap — move the key to `ANTHROPIC_API_KEY`. Recent `claw` builds detect this exact shape (401 + `sk-ant-*` in the Bearer slot) and append a hint to the error message pointing at the fix.
**If you meant a different provider:** if `claw` reports missing Anthropic credentials but you already have `OPENAI_API_KEY`, `XAI_API_KEY`, or `DASHSCOPE_API_KEY` exported, you most likely forgot to prefix the model name with the provider's routing prefix. Use `--model openai/gpt-4.1-mini` (OpenAI-compat / OpenRouter / Ollama), `--model grok` (xAI), or `--model qwen-plus` (DashScope) and the prefix router will select the right backend regardless of the ambient credentials. The error message now includes a hint that names the detected env var.
## Local Models
`claw` can talk to local servers and provider gateways through either Anthropic-compatible or OpenAI-compatible endpoints. Use `ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` for Anthropic-compatible services, or `OPENAI_BASE_URL` with `OPENAI_API_KEY` for OpenAI-compatible services. OAuth is Anthropic-only, so when `OPENAI_BASE_URL` is set you should use API-key style auth instead of `claw login`.
+89 -3
View File
@@ -31,9 +31,18 @@ impl ProviderClient {
ProviderKind::Xai => Ok(Self::Xai(OpenAiCompatClient::from_env(
OpenAiCompatConfig::xai(),
)?)),
ProviderKind::OpenAi => Ok(Self::OpenAi(OpenAiCompatClient::from_env(
OpenAiCompatConfig::openai(),
)?)),
ProviderKind::OpenAi => {
// DashScope models (qwen-*) also return ProviderKind::OpenAi because they
// speak the OpenAI wire format, but they need the DashScope config which
// reads DASHSCOPE_API_KEY and points at dashscope.aliyuncs.com.
let config = match providers::metadata_for_model(&resolved_model) {
Some(meta) if meta.auth_env == "DASHSCOPE_API_KEY" => {
OpenAiCompatConfig::dashscope()
}
_ => OpenAiCompatConfig::openai(),
};
Ok(Self::OpenAi(OpenAiCompatClient::from_env(config)?))
}
}
}
@@ -135,8 +144,21 @@ pub fn read_xai_base_url() -> String {
#[cfg(test)]
mod tests {
use std::sync::{Mutex, OnceLock};
use super::ProviderClient;
use crate::providers::{detect_provider_kind, resolve_model_alias, ProviderKind};
/// Serializes every test in this module that mutates process-wide
/// environment variables so concurrent test threads cannot observe
/// each other's partially-applied state.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[test]
fn resolves_existing_and_grok_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
@@ -152,4 +174,68 @@ mod tests {
ProviderKind::Anthropic
);
}
/// Snapshot-restore guard for a single environment variable. Mirrors
/// the pattern used in `providers/mod.rs` tests: captures the original
/// value on construction, applies the override, and restores on drop so
/// tests leave the process env untouched even when they panic.
struct EnvVarGuard {
key: &'static str,
original: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn dashscope_model_uses_dashscope_config_not_openai() {
// Regression: qwen-plus was being routed to OpenAiCompatConfig::openai()
// which reads OPENAI_API_KEY and points at api.openai.com, when it should
// use OpenAiCompatConfig::dashscope() which reads DASHSCOPE_API_KEY and
// points at dashscope.aliyuncs.com.
let _lock = env_lock();
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("test-dashscope-key"));
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
let client = ProviderClient::from_model("qwen-plus");
// Must succeed (not fail with "missing OPENAI_API_KEY")
assert!(
client.is_ok(),
"qwen-plus with DASHSCOPE_API_KEY set should build successfully, got: {:?}",
client.err()
);
// Verify it's the OpenAi variant pointed at the DashScope base URL.
match client.unwrap() {
ProviderClient::OpenAi(openai_client) => {
assert!(
openai_client.base_url().contains("dashscope.aliyuncs.com"),
"qwen-plus should route to DashScope base URL (contains 'dashscope.aliyuncs.com'), got: {}",
openai_client.base_url()
);
}
other => panic!(
"Expected ProviderClient::OpenAi for qwen-plus, got: {:?}",
other
),
}
}
}
+88 -2
View File
@@ -22,6 +22,11 @@ pub enum ApiError {
MissingCredentials {
provider: &'static str,
env_vars: &'static [&'static str],
/// Optional, runtime-computed hint appended to the error Display
/// output. Populated when the provider resolver can infer what the
/// user probably intended (e.g. an OpenAI key is set but Anthropic
/// was selected because no Anthropic credentials exist).
hint: Option<String>,
},
ContextWindowExceeded {
model: String,
@@ -66,7 +71,29 @@ impl ApiError {
provider: &'static str,
env_vars: &'static [&'static str],
) -> Self {
Self::MissingCredentials { provider, env_vars }
Self::MissingCredentials {
provider,
env_vars,
hint: None,
}
}
/// Build a `MissingCredentials` error carrying an extra, runtime-computed
/// hint string that the Display impl appends after the canonical "missing
/// <provider> credentials" message. Used by the provider resolver to
/// suggest the likely fix when the user has credentials for a different
/// provider already in the environment.
#[must_use]
pub fn missing_credentials_with_hint(
provider: &'static str,
env_vars: &'static [&'static str],
hint: impl Into<String>,
) -> Self {
Self::MissingCredentials {
provider,
env_vars,
hint: Some(hint.into()),
}
}
/// Build a `Self::Json` enriched with the provider name, the model that
@@ -204,7 +231,11 @@ impl ApiError {
impl Display for ApiError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingCredentials { provider, env_vars } => {
Self::MissingCredentials {
provider,
env_vars,
hint,
} => {
write!(
f,
"missing {provider} credentials; export {} before calling the {provider} API",
@@ -223,6 +254,9 @@ impl Display for ApiError {
)?;
}
}
if let Some(hint) = hint {
write!(f, " — hint: {hint}")?;
}
Ok(())
}
Self::ContextWindowExceeded {
@@ -483,4 +517,56 @@ mod tests {
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_123"));
}
#[test]
fn missing_credentials_without_hint_renders_the_canonical_message() {
// given
let error = ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
);
// when
let rendered = error.to_string();
// then
assert!(
rendered.starts_with(
"missing Anthropic credentials; export ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY before calling the Anthropic API"
),
"rendered error should lead with the canonical missing-credential message: {rendered}"
);
assert!(
!rendered.contains(" — hint: "),
"no hint should be appended when none is supplied: {rendered}"
);
}
#[test]
fn missing_credentials_with_hint_appends_the_hint_after_base_message() {
// given
let error = ApiError::missing_credentials_with_hint(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
"I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.",
);
// when
let rendered = error.to_string();
// then
assert!(
rendered.starts_with("missing Anthropic credentials;"),
"hint should be appended, not replace the base message: {rendered}"
);
let hint_marker = " — hint: I see OPENAI_API_KEY is set — if you meant to use the OpenAI-compat provider, prefix your model name with `openai/` so prefix routing selects it.";
assert!(
rendered.ends_with(hint_marker),
"rendered error should end with the hint: {rendered}"
);
// Classification semantics are unaffected by the presence of a hint.
assert_eq!(error.safe_failure_class(), "provider_auth");
assert!(!error.is_retryable());
assert_eq!(error.request_id(), None);
}
}
+246 -17
View File
@@ -16,7 +16,9 @@ use crate::error::ApiError;
use crate::http_client::build_http_client_or_default;
use crate::prompt_cache::{PromptCache, PromptCacheRecord, PromptCacheStats};
use super::{model_token_limit, resolve_model_alias, Provider, ProviderFuture};
use super::{
anthropic_missing_credentials, model_token_limit, resolve_model_alias, Provider, ProviderFuture,
};
use crate::sse::SseParser;
use crate::types::{MessageDeltaEvent, MessageRequest, MessageResponse, StreamEvent, Usage};
@@ -49,10 +51,7 @@ impl AuthSource {
}),
(Some(api_key), None) => Ok(Self::ApiKey(api_key)),
(None, Some(bearer_token)) => Ok(Self::BearerToken(bearer_token)),
(None, None) => Err(ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
)),
(None, None) => Err(anthropic_missing_credentials()),
}
}
@@ -436,6 +435,7 @@ impl AnthropicClient {
last_error = Some(error);
}
Err(error) => {
let error = enrich_bearer_auth_error(error, &self.auth);
self.record_request_failure(attempts, &error);
return Err(error);
}
@@ -643,10 +643,7 @@ impl AuthSource {
}
}
Ok(Some(token_set)) => Ok(Self::BearerToken(token_set.access_token)),
Ok(None) => Err(ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
)),
Ok(None) => Err(anthropic_missing_credentials()),
Err(error) => Err(error),
}
}
@@ -690,10 +687,7 @@ where
}
let Some(token_set) = load_saved_oauth_token()? else {
return Err(ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
));
return Err(anthropic_missing_credentials());
};
if !oauth_token_is_expired(&token_set) {
return Ok(AuthSource::BearerToken(token_set.access_token));
@@ -790,10 +784,7 @@ fn read_api_key() -> Result<String, ApiError> {
auth.api_key()
.or_else(|| auth.bearer_token())
.map(ToOwned::to_owned)
.ok_or(ApiError::missing_credentials(
"Anthropic",
&["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"],
))
.ok_or_else(anthropic_missing_credentials)
}
#[cfg(test)]
@@ -934,6 +925,85 @@ const fn is_retryable_status(status: reqwest::StatusCode) -> bool {
matches!(status.as_u16(), 408 | 409 | 429 | 500 | 502 | 503 | 504)
}
/// Anthropic API keys (`sk-ant-*`) are accepted over the `x-api-key` header
/// and rejected with HTTP 401 "Invalid bearer token" when sent as a Bearer
/// token via `ANTHROPIC_AUTH_TOKEN`. This happens often enough in the wild
/// (users copy-paste an `sk-ant-...` key into `ANTHROPIC_AUTH_TOKEN` because
/// the env var name sounds auth-related) that a bare 401 error is useless.
/// When we detect this exact shape, append a hint to the error message that
/// points the user at the one-line fix.
const SK_ANT_BEARER_HINT: &str = "sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY.";
fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError {
let ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
} = error
else {
return error;
};
if status.as_u16() != 401 {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
};
}
let Some(bearer_token) = auth.bearer_token() else {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
};
};
if !bearer_token.starts_with("sk-ant-") {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
};
}
// Only append the hint when the AuthSource is pure BearerToken. If both
// api_key and bearer_token are present (`ApiKeyAndBearer`), the x-api-key
// header is already being sent alongside the Bearer header and the 401
// is coming from a different cause — adding the hint would be misleading.
if auth.api_key().is_some() {
return ApiError::Api {
status,
error_type,
message,
request_id,
body,
retryable,
};
}
let enriched_message = match message {
Some(existing) => Some(format!("{existing} — hint: {SK_ANT_BEARER_HINT}")),
None => Some(format!("hint: {SK_ANT_BEARER_HINT}")),
};
ApiError::Api {
status,
error_type,
message: enriched_message,
request_id,
body,
retryable,
}
}
/// Remove beta-only body fields that the standard `/v1/messages` and
/// `/v1/messages/count_tokens` endpoints reject as `Extra inputs are not
/// permitted`. The `betas` opt-in is communicated via the `anthropic-beta`
@@ -1538,4 +1608,163 @@ mod tests {
Some("claude-sonnet-4-6")
);
}
#[test]
fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() {
// given
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".to_string()),
request_id: Some("req_varleg_001".to_string()),
body: String::new(),
retryable: false,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
rendered.contains("Invalid bearer token"),
"existing provider message should be preserved: {rendered}"
);
assert!(
rendered.contains(
"sk-ant-* keys go in ANTHROPIC_API_KEY (x-api-key header), not ANTHROPIC_AUTH_TOKEN (Bearer header). Move your key to ANTHROPIC_API_KEY."
),
"rendered error should include the sk-ant-* hint: {rendered}"
);
assert!(
rendered.contains("[trace req_varleg_001]"),
"request id should still flow through the enriched error: {rendered}"
);
match enriched {
crate::error::ApiError::Api { status, .. } => {
assert_eq!(status, reqwest::StatusCode::UNAUTHORIZED);
}
other => panic!("expected Api variant, got {other:?}"),
}
}
#[test]
fn enrich_bearer_auth_error_leaves_non_401_errors_unchanged() {
// given
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
error_type: Some("api_error".to_string()),
message: Some("internal server error".to_string()),
request_id: None,
body: String::new(),
retryable: true,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
!rendered.contains("sk-ant-*"),
"non-401 errors must not be annotated with the bearer hint: {rendered}"
);
assert!(
rendered.contains("internal server error"),
"original message must be preserved verbatim: {rendered}"
);
}
#[test]
fn enrich_bearer_auth_error_ignores_401_when_bearer_token_is_not_sk_ant() {
// given
let auth = AuthSource::BearerToken("oauth-access-token-opaque".to_string());
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".to_string()),
request_id: None,
body: String::new(),
retryable: false,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
!rendered.contains("sk-ant-*"),
"oauth-style bearer tokens must not trigger the sk-ant-* hint: {rendered}"
);
}
#[test]
fn enrich_bearer_auth_error_skips_hint_when_api_key_header_is_also_present() {
// given
let auth = AuthSource::ApiKeyAndBearer {
api_key: "sk-ant-api03-legitimate".to_string(),
bearer_token: "sk-ant-api03-deadbeef".to_string(),
};
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid bearer token".to_string()),
request_id: None,
body: String::new(),
retryable: false,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
!rendered.contains("sk-ant-*"),
"hint should be suppressed when x-api-key header is already being sent: {rendered}"
);
}
#[test]
fn enrich_bearer_auth_error_ignores_401_when_auth_source_has_no_bearer() {
// given
let auth = AuthSource::ApiKey("sk-ant-api03-legitimate".to_string());
let error = crate::error::ApiError::Api {
status: reqwest::StatusCode::UNAUTHORIZED,
error_type: Some("authentication_error".to_string()),
message: Some("Invalid x-api-key".to_string()),
request_id: None,
body: String::new(),
retryable: false,
};
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
let rendered = enriched.to_string();
assert!(
!rendered.contains("sk-ant-*"),
"bearer hint must not apply when AuthSource is ApiKey-only: {rendered}"
);
}
#[test]
fn enrich_bearer_auth_error_passes_non_api_errors_through_unchanged() {
// given
let auth = AuthSource::BearerToken("sk-ant-api03-deadbeef".to_string());
let error = crate::error::ApiError::InvalidSseFrame("unterminated event");
// when
let enriched = super::enrich_bearer_auth_error(error, &auth);
// then
assert!(matches!(
enriched,
crate::error::ApiError::InvalidSseFrame(_)
));
}
}
+335 -3
View File
@@ -291,6 +291,73 @@ fn estimate_serialized_tokens<T: Serialize>(value: &T) -> u32 {
.map_or(0, |bytes| (bytes.len() / 4 + 1) as u32)
}
/// Env var names used by other provider backends. When Anthropic auth
/// resolution fails we sniff these so we can hint the user that their
/// credentials probably belong to a different provider and suggest the
/// model-prefix routing fix that would select it.
const FOREIGN_PROVIDER_ENV_VARS: &[(&str, &str, &str)] = &[
(
"OPENAI_API_KEY",
"OpenAI-compat",
"prefix your model name with `openai/` (e.g. `--model openai/gpt-4.1-mini`) so prefix routing selects the OpenAI-compatible provider, and set `OPENAI_BASE_URL` if you are pointing at OpenRouter/Ollama/a local server",
),
(
"XAI_API_KEY",
"xAI",
"use an xAI model alias (e.g. `--model grok` or `--model grok-mini`) so the prefix router selects the xAI backend",
),
(
"DASHSCOPE_API_KEY",
"Alibaba DashScope",
"prefix your model name with `qwen/` or `qwen-` (e.g. `--model qwen-plus`) so prefix routing selects the DashScope backend",
),
];
/// Check whether an env var is set to a non-empty value either in the real
/// process environment or in the working-directory `.env` file. Mirrors the
/// credential discovery path used by `read_env_non_empty` so the hint text
/// stays truthful when users rely on `.env` instead of a real export.
fn env_or_dotenv_present(key: &str) -> bool {
match std::env::var(key) {
Ok(value) if !value.is_empty() => true,
Ok(_) | Err(std::env::VarError::NotPresent) => {
dotenv_value(key).is_some_and(|value| !value.is_empty())
}
Err(_) => false,
}
}
/// Produce a hint string describing the first foreign provider credential
/// that is present in the environment when Anthropic auth resolution has
/// just failed. Returns `None` when no foreign credential is set, in which
/// case the caller should fall back to the plain `missing_credentials`
/// error without a hint.
pub(crate) fn anthropic_missing_credentials_hint() -> Option<String> {
for (env_var, provider_label, fix_hint) in FOREIGN_PROVIDER_ENV_VARS {
if env_or_dotenv_present(env_var) {
return Some(format!(
"I see {env_var} is set — if you meant to use the {provider_label} provider, {fix_hint}."
));
}
}
None
}
/// Build an Anthropic-specific `MissingCredentials` error, attaching a
/// hint suggesting the probable fix whenever a different provider's
/// credentials are already present in the environment. Anthropic call
/// sites should prefer this helper over `ApiError::missing_credentials`
/// so users who mistyped a model name or forgot the prefix get a useful
/// signal instead of a generic "missing Anthropic credentials" wall.
pub(crate) fn anthropic_missing_credentials() -> ApiError {
const PROVIDER: &str = "Anthropic";
const ENV_VARS: &[&str] = &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"];
match anthropic_missing_credentials_hint() {
Some(hint) => ApiError::missing_credentials_with_hint(PROVIDER, ENV_VARS, hint),
None => ApiError::missing_credentials(PROVIDER, ENV_VARS),
}
}
/// Parse a `.env` file body into key/value pairs using a minimal `KEY=VALUE`
/// grammar. Lines that are blank, start with `#`, or do not contain `=` are
/// ignored. Surrounding double or single quotes are stripped from the value.
@@ -348,6 +415,9 @@ pub(crate) fn dotenv_value(key: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use serde_json::json;
use crate::error::ApiError;
@@ -356,11 +426,52 @@ mod tests {
};
use super::{
detect_provider_kind, load_dotenv_file, max_tokens_for_model,
max_tokens_for_model_with_override, model_token_limit, parse_dotenv,
preflight_message_request, resolve_model_alias, ProviderKind,
anthropic_missing_credentials, anthropic_missing_credentials_hint, detect_provider_kind,
load_dotenv_file, max_tokens_for_model, max_tokens_for_model_with_override,
model_token_limit, parse_dotenv, preflight_message_request, resolve_model_alias,
ProviderKind,
};
/// Serializes every test in this module that mutates process-wide
/// environment variables so concurrent test threads cannot observe
/// each other's partially-applied state while probing the foreign
/// provider credential sniffer.
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Snapshot-restore guard for a single environment variable. Captures
/// the original value on construction, applies the requested override
/// (set or remove), and restores the original on drop so tests leave
/// the process env untouched even when they panic mid-assertion.
struct EnvVarGuard {
key: &'static str,
original: Option<OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: Option<&str>) -> Self {
let original = std::env::var_os(key);
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
Self { key, original }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match self.original.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn resolves_grok_aliases() {
assert_eq!(resolve_model_alias("grok"), "grok-3");
@@ -649,4 +760,225 @@ NO_EQUALS_LINE
let _ = std::fs::remove_dir_all(&temp_root);
}
#[test]
fn anthropic_missing_credentials_hint_is_none_when_no_foreign_creds_present() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
// when
let hint = anthropic_missing_credentials_hint();
// then
assert!(
hint.is_none(),
"no hint should be produced when every foreign provider env var is absent, got {hint:?}"
);
}
#[test]
fn anthropic_missing_credentials_hint_detects_openai_api_key_and_recommends_openai_prefix() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
// when
let hint = anthropic_missing_credentials_hint()
.expect("OPENAI_API_KEY presence should produce a hint");
// then
assert!(
hint.contains("OPENAI_API_KEY is set"),
"hint should name the detected env var so users recognize it: {hint}"
);
assert!(
hint.contains("OpenAI-compat"),
"hint should identify the target provider: {hint}"
);
assert!(
hint.contains("openai/"),
"hint should mention the `openai/` prefix routing fix: {hint}"
);
assert!(
hint.contains("OPENAI_BASE_URL"),
"hint should mention OPENAI_BASE_URL so OpenRouter users see the full picture: {hint}"
);
}
#[test]
fn anthropic_missing_credentials_hint_detects_xai_api_key() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
// when
let hint = anthropic_missing_credentials_hint()
.expect("XAI_API_KEY presence should produce a hint");
// then
assert!(
hint.contains("XAI_API_KEY is set"),
"hint should name XAI_API_KEY: {hint}"
);
assert!(
hint.contains("xAI"),
"hint should identify the xAI provider: {hint}"
);
assert!(
hint.contains("grok"),
"hint should suggest a grok-prefixed model alias: {hint}"
);
}
#[test]
fn anthropic_missing_credentials_hint_detects_dashscope_api_key() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("sk-dashscope-test"));
// when
let hint = anthropic_missing_credentials_hint()
.expect("DASHSCOPE_API_KEY presence should produce a hint");
// then
assert!(
hint.contains("DASHSCOPE_API_KEY is set"),
"hint should name DASHSCOPE_API_KEY: {hint}"
);
assert!(
hint.contains("DashScope"),
"hint should identify the DashScope provider: {hint}"
);
assert!(
hint.contains("qwen"),
"hint should suggest a qwen-prefixed model alias: {hint}"
);
}
#[test]
fn anthropic_missing_credentials_hint_prefers_openai_when_multiple_foreign_creds_set() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
let _xai = EnvVarGuard::set("XAI_API_KEY", Some("xai-test-key"));
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", Some("sk-dashscope-test"));
// when
let hint = anthropic_missing_credentials_hint()
.expect("multiple foreign creds should still produce a hint");
// then
assert!(
hint.contains("OPENAI_API_KEY"),
"OpenAI should be prioritized because it is the most common misrouting pattern (OpenRouter users), got: {hint}"
);
assert!(
!hint.contains("XAI_API_KEY"),
"only the first detected provider should be named to keep the hint focused, got: {hint}"
);
}
#[test]
fn anthropic_missing_credentials_builds_error_with_canonical_env_vars_and_no_hint_when_clean() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", None);
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
// when
let error = anthropic_missing_credentials();
// then
match &error {
ApiError::MissingCredentials {
provider,
env_vars,
hint,
} => {
assert_eq!(*provider, "Anthropic");
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
assert!(
hint.is_none(),
"clean environment should not generate a hint, got {hint:?}"
);
}
other => panic!("expected MissingCredentials variant, got {other:?}"),
}
let rendered = error.to_string();
assert!(
!rendered.contains(" — hint: "),
"rendered error should be a plain missing-creds message: {rendered}"
);
}
#[test]
fn anthropic_missing_credentials_builds_error_with_hint_when_openai_key_is_set() {
// given
let _lock = env_lock();
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some("sk-openrouter-varleg"));
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
// when
let error = anthropic_missing_credentials();
// then
match &error {
ApiError::MissingCredentials {
provider,
env_vars,
hint,
} => {
assert_eq!(*provider, "Anthropic");
assert_eq!(*env_vars, &["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"]);
let hint_value = hint.as_deref().expect("hint should be populated");
assert!(
hint_value.contains("OPENAI_API_KEY is set"),
"hint should name the detected env var: {hint_value}"
);
}
other => panic!("expected MissingCredentials variant, got {other:?}"),
}
let rendered = error.to_string();
assert!(
rendered.starts_with("missing Anthropic credentials;"),
"canonical base message should still lead the rendered error: {rendered}"
);
assert!(
rendered.contains(" — hint: I see OPENAI_API_KEY is set"),
"rendered error should carry the env-driven hint: {rendered}"
);
}
#[test]
fn anthropic_missing_credentials_hint_ignores_empty_string_values() {
// given
let _lock = env_lock();
// An empty value is semantically equivalent to "not set" for the
// credential discovery path, so the sniffer must treat it that way
// to avoid false-positive hints for users who intentionally cleared
// a stale export with `OPENAI_API_KEY=`.
let _openai = EnvVarGuard::set("OPENAI_API_KEY", Some(""));
let _xai = EnvVarGuard::set("XAI_API_KEY", None);
let _dashscope = EnvVarGuard::set("DASHSCOPE_API_KEY", None);
// when
let hint = anthropic_missing_credentials_hint();
// then
assert!(
hint.is_none(),
"empty env var should not trigger the hint sniffer, got {hint:?}"
);
}
}
@@ -98,6 +98,11 @@ impl OpenAiCompatClient {
const fn config(&self) -> OpenAiCompatConfig {
self.config
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
#[must_use]
pub fn new(api_key: impl Into<String>, config: OpenAiCompatConfig) -> Self {
Self {
@@ -22,7 +22,9 @@ fn provider_client_reports_missing_xai_credentials_for_grok_models() {
.expect_err("grok requests without XAI_API_KEY should fail fast");
match error {
ApiError::MissingCredentials { provider, env_vars } => {
ApiError::MissingCredentials {
provider, env_vars, ..
} => {
assert_eq!(provider, "xAI");
assert_eq!(env_vars, &["XAI_API_KEY"]);
}
+108 -4
View File
@@ -337,7 +337,28 @@ impl CommandWithStdin {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write as _;
child_stdin.write_all(stdin)?;
// Tolerate BrokenPipe: a hook script that runs to completion
// (or exits early without reading stdin) closes its stdin
// before the parent finishes writing the JSON payload, and
// the kernel raises EPIPE on the parent's write_all. That is
// not a hook failure — the child still exited cleanly and we
// still need to wait_with_output() to capture stdout/stderr
// and the real exit code. Other write errors (e.g. EIO,
// permission, OOM) still propagate.
//
// This was the root cause of the Linux CI flake on
// hooks::tests::collects_and_runs_hooks_from_enabled_plugins
// (ROADMAP #25, runs 24120271422 / 24120538408 / 24121392171
// / 24121776826): the test hook scripts run in microseconds
// and the parent's stdin write races against child exit.
// macOS pipes happen to buffer the small payload before the
// child exits; Linux pipes do not, so the race shows up
// deterministically on ubuntu runners.
match child_stdin.write_all(stdin) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {}
Err(error) => return Err(error),
}
}
child.wait_with_output()
}
@@ -359,6 +380,18 @@ mod tests {
std::env::temp_dir().join(format!("plugins-hook-runner-{label}-{nanos}"))
}
fn make_executable(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(0o755);
fs::set_permissions(path, perms)
.unwrap_or_else(|e| panic!("chmod +x {}: {e}", path.display()));
}
#[cfg(not(unix))]
let _ = path;
}
fn write_hook_plugin(
root: &Path,
name: &str,
@@ -368,21 +401,30 @@ mod tests {
) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::create_dir_all(root.join("hooks")).expect("hooks dir");
let pre_path = root.join("hooks").join("pre.sh");
fs::write(
root.join("hooks").join("pre.sh"),
&pre_path,
format!("#!/bin/sh\nprintf '%s\\n' '{pre_message}'\n"),
)
.expect("write pre hook");
make_executable(&pre_path);
let post_path = root.join("hooks").join("post.sh");
fs::write(
root.join("hooks").join("post.sh"),
&post_path,
format!("#!/bin/sh\nprintf '%s\\n' '{post_message}'\n"),
)
.expect("write post hook");
make_executable(&post_path);
let failure_path = root.join("hooks").join("failure.sh");
fs::write(
root.join("hooks").join("failure.sh"),
&failure_path,
format!("#!/bin/sh\nprintf '%s\\n' '{failure_message}'\n"),
)
.expect("write failure hook");
make_executable(&failure_path);
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
@@ -496,4 +538,66 @@ mod tests {
.iter()
.any(|message| message == "later plugin hook"));
}
#[test]
#[cfg(unix)]
fn generated_hook_scripts_are_executable() {
use std::os::unix::fs::PermissionsExt;
// given
let root = temp_dir("exec-guard");
write_hook_plugin(&root, "exec-check", "pre", "post", "fail");
// then
for script in ["pre.sh", "post.sh", "failure.sh"] {
let path = root.join("hooks").join(script);
let mode = fs::metadata(&path)
.unwrap_or_else(|e| panic!("{script} metadata: {e}"))
.permissions()
.mode();
assert!(
mode & 0o111 != 0,
"{script} must have at least one execute bit set, got mode {mode:#o}"
);
}
}
#[test]
fn output_with_stdin_tolerates_broken_pipe_when_child_closes_stdin_early() {
// given: a hook that immediately closes stdin without consuming the
// JSON payload. Use an oversized payload so the parent keeps writing
// long enough for Linux to surface EPIPE on the old implementation.
let root = temp_dir("stdin-close");
let script = root.join("close-stdin.sh");
fs::create_dir_all(&root).expect("temp hook dir");
fs::write(
&script,
"#!/bin/sh\nexec 0<&-\nprintf 'stdin closed early\\n'\nsleep 0.05\n",
)
.expect("write stdin-closing hook");
make_executable(&script);
let mut child = super::shell_command(script.to_str().expect("utf8 path"));
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
let large_input = vec![b'x'; 2 * 1024 * 1024];
// when
let output = child
.output_with_stdin(&large_input)
.expect("broken pipe should be tolerated");
// then
assert!(
output.status.success(),
"child should still exit cleanly: {output:?}"
);
assert_eq!(
String::from_utf8_lossy(&output.stdout).trim(),
"stdin closed early"
);
let _ = fs::remove_dir_all(root);
}
}
+59
View File
@@ -0,0 +1,59 @@
use std::env;
use std::process::Command;
fn main() {
// Get git SHA (short hash)
let git_sha = Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.output()
.ok()
.and_then(|output| {
if output.status.success() {
String::from_utf8(output.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
println!("cargo:rustc-env=GIT_SHA={}", git_sha);
// TARGET is always set by Cargo during build
let target = env::var("TARGET").unwrap_or_else(|_| "unknown".to_string());
println!("cargo:rustc-env=TARGET={}", target);
// Build date from SOURCE_DATE_EPOCH (reproducible builds) or current UTC date.
// Intentionally ignoring time component to keep output deterministic within a day.
let build_date = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|epoch| epoch.parse::<i64>().ok())
.map(|_ts| {
// Use SOURCE_DATE_EPOCH to derive date via chrono if available;
// for simplicity we just use the env var as a signal and fall back
// to build-time env. In practice CI sets this via workflow.
std::env::var("BUILD_DATE").unwrap_or_else(|_| "unknown".to_string())
})
.or_else(|| std::env::var("BUILD_DATE").ok())
.unwrap_or_else(|| {
// Fall back to current date via `date` command
Command::new("date")
.args(["+%Y-%m-%d"])
.output()
.ok()
.and_then(|o| {
if o.status.success() {
String::from_utf8(o.stdout).ok()
} else {
None
}
})
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
});
println!("cargo:rustc-env=BUILD_DATE={build_date}");
// Rerun if git state changes
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
}
+65 -8
View File
@@ -26,8 +26,9 @@ use std::time::{Duration, Instant, UNIX_EPOCH};
use api::{
detect_provider_kind, oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient,
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
MessageResponse, OutputContentBlock, PromptCache, ProviderKind, StreamEvent as ApiStreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock,
MessageResponse, OutputContentBlock, PromptCache, ProviderClient as ApiProviderClient,
ProviderKind, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition,
ToolResultContentBlock,
};
use commands::{
@@ -66,7 +67,12 @@ fn max_tokens_for_model(model: &str) -> u32 {
64_000
}
}
const DEFAULT_DATE: &str = "2026-03-31";
// Build-time constants injected by build.rs (fall back to static values when
// build.rs hasn't run, e.g. in doc-test or unusual toolchain environments).
const DEFAULT_DATE: &str = match option_env!("BUILD_DATE") {
Some(d) => d,
None => "unknown",
};
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET");
@@ -6348,9 +6354,15 @@ impl runtime::PermissionPrompter for CliPermissionPrompter {
}
}
// NOTE: Despite the historical name `AnthropicRuntimeClient`, this struct
// now holds an `ApiProviderClient` which dispatches to Anthropic, xAI,
// OpenAI, or DashScope at construction time based on
// `detect_provider_kind(&model)`. The struct name is kept to avoid
// churning `BuiltRuntime` and every Deref/DerefMut site that references
// it. See ROADMAP #29 for the provider-dispatch routing fix.
struct AnthropicRuntimeClient {
runtime: tokio::runtime::Runtime,
client: AnthropicClient,
client: ApiProviderClient,
session_id: String,
model: String,
enable_tools: bool,
@@ -6370,11 +6382,51 @@ impl AnthropicRuntimeClient {
tool_registry: GlobalToolRegistry,
progress_reporter: Option<InternalPromptProgressReporter>,
) -> Result<Self, Box<dyn std::error::Error>> {
// Dispatch to the correct provider at construction time.
// `ApiProviderClient` (exposed by the api crate as
// `ProviderClient`) is an enum over Anthropic / xAI / OpenAI
// variants, where xAI and OpenAI both use the OpenAI-compat
// wire format under the hood. We consult
// `detect_provider_kind(&resolved_model)` so model-name prefix
// routing (`openai/`, `gpt-`, `grok`, `qwen/`) wins over
// env-var presence.
//
// For Anthropic we build the client directly instead of going
// through `ApiProviderClient::from_model_with_anthropic_auth`
// so we can explicitly apply `api::read_base_url()` — that
// reads `ANTHROPIC_BASE_URL` and is required for the local
// mock-server test harness
// (`crates/rusty-claude-cli/tests/compact_output.rs`) to point
// claw at its fake Anthropic endpoint. We also attach a
// session-scoped prompt cache on the Anthropic path; the
// prompt cache is Anthropic-only so non-Anthropic variants
// skip it.
let resolved_model = api::resolve_model_alias(&model);
let client = match detect_provider_kind(&resolved_model) {
ProviderKind::Anthropic => {
let auth = resolve_cli_auth_source()?;
let inner = AnthropicClient::from_auth(auth)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id));
ApiProviderClient::Anthropic(inner)
}
ProviderKind::Xai | ProviderKind::OpenAi => {
// The api crate's `ProviderClient::from_model_with_anthropic_auth`
// with `None` for the anthropic auth routes via
// `detect_provider_kind` and builds an
// `OpenAiCompatClient::from_env` with the matching
// `OpenAiCompatConfig` (openai / xai / dashscope).
// That reads the correct API-key env var and BASE_URL
// override internally, so this one call covers OpenAI,
// OpenRouter, xAI, DashScope, Ollama, and any other
// OpenAI-compat endpoint users configure via
// `OPENAI_BASE_URL` / `XAI_BASE_URL` / `DASHSCOPE_BASE_URL`.
ApiProviderClient::from_model_with_anthropic_auth(&resolved_model, None)?
}
};
Ok(Self {
runtime: tokio::runtime::Runtime::new()?,
client: AnthropicClient::from_auth(resolve_cli_auth_source()?)
.with_base_url(api::read_base_url())
.with_prompt_cache(PromptCache::new(session_id)),
client,
session_id: session_id.to_string(),
model,
enable_tools,
@@ -7404,7 +7456,12 @@ fn response_to_events(
Ok(events)
}
fn push_prompt_cache_record(client: &AnthropicClient, events: &mut Vec<AssistantEvent>) {
fn push_prompt_cache_record(client: &ApiProviderClient, events: &mut Vec<AssistantEvent>) {
// `ApiProviderClient::take_last_prompt_cache_record` is a pass-through
// to the Anthropic variant and returns `None` for OpenAI-compat /
// xAI variants, which do not have a prompt cache. So this helper
// remains a no-op on non-Anthropic providers without any extra
// branching here.
if let Some(record) = client.take_last_prompt_cache_record() {
if let Some(event) = prompt_cache_record_to_runtime_event(record) {
events.push(AssistantEvent::PromptCache(event));
+8
View File
@@ -6249,6 +6249,14 @@ mod tests {
#[test]
fn web_search_extracts_and_filters_results() {
// Serialize env-var mutation so this test cannot race with the sibling
// web_search_handles_generic_links_and_invalid_base_url test that also
// sets CLAWD_WEB_SEARCH_BASE_URL. Without the lock, parallel test
// runners can interleave the set/remove calls and cause assertion
// failures on the wrong port.
let _guard = env_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let server = TestServer::spawn(Arc::new(|request_line: &str| {
assert!(request_line.contains("GET /search?q=rust+web+search "));
HttpResponse::html(