diff --git a/ROADMAP.md b/ROADMAP.md index 0c34f8d6..55f98f6d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6348,7 +6348,7 @@ Original filing (2026-04-18): the session emitted `SessionStart hook (completed) 423. **`claw prompt` does not read prompt text from stdin when no positional prompt arg is provided — `echo "what is 2+2" | claw prompt --output-format json` returns `kind:"unknown" error:"prompt subcommand requires a prompt string"` instead of consuming stdin** — dogfooded 2026-05-11 by Jobdori on `3c563fa1` in response to Clawhip pinpoint nudge at `1503222644739276951`. Reproduction: `echo "what is 2+2" | claw prompt --output-format json` → `{"error":"prompt subcommand requires a prompt string","hint":null,"kind":"unknown","type":"error"}` exit 1. Same for `claw prompt --output-format json` with stdin redirected from a file. The most common Unix automation pattern (`cmd | claw prompt`) is broken because the prompt subcommand only reads the positional argument, never falls through to stdin. **Sibling envelope-kind bug:** the error `kind` is `"unknown"` instead of a typed `"missing_argument"` or `"validation_error"`. The `unknown` discriminator is the catch-all bucket — automation that switches on `kind` to differentiate input-validation errors from runtime errors gets no signal here. **Required fix shape:** (a) when `prompt` subcommand has no positional prompt arg AND stdin is not a TTY (i.e., piped or redirected), read stdin to EOF and use that as the prompt; (b) emit `kind:"missing_argument"` (not `"unknown"`) when both positional arg and stdin are absent; (c) add `--prompt-stdin` or `--stdin` opt-in flag for explicit control; (d) regression tests: `echo X | claw prompt --output-format json` reaches the runtime with prompt=X, AND `claw prompt < /dev/null` returns `kind:"missing_argument"` exit 1. **Why this matters:** Unix pipelines are the foundation of CLI automation. Every other major CLI (curl, jq, gh, kubectl) accepts stdin as the primary input when no positional arg is given. Breaking this convention forces automation to either inline the prompt as a shell-quoted string (escaping nightmare for multiline/code) or write to a temp file first. The `kind:"unknown"` error category compounds the problem by making the failure indistinguishable from a runtime crash. Source: Jobdori live dogfood, `3c563fa1`, 2026-05-11. -424. **`--model` rejects bare canonical Anthropic model names (`claude-opus-4-7`, `claude-opus-4-6`, `claude-sonnet-4-6`) as `invalid_model_syntax` — only short aliases (`opus`, `sonnet`, `haiku`) and full prefixed form (`anthropic/claude-opus-4-7`) work; sibling: error message stale-suggests `claude-opus-4-6` not `4-7`** — dogfooded 2026-05-11 by Jobdori on `6c0c305a` in response to Clawhip pinpoint nudge at `1503230194889134103`. Reproduction: `claw --model claude-opus-4-7 status --output-format json` → `{"error":"invalid model syntax: 'claude-opus-4-7'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)","kind":"invalid_model_syntax"}`. Same for `claude-opus-4-6`, `claude-sonnet-4-6`. Forcing `--model anthropic/claude-opus-4-7` works (`model:"anthropic/claude-opus-4-7"`, `model_source:"flag"`). Three problems compounded: (a) Anthropic-canonical model names without provider prefix are rejected even though the `claude-` prefix unambiguously identifies the provider; (b) the error suggests `anthropic/claude-opus-4-6` as the example — `4-7` shipped 2026-04-16 and is the current production Anthropic frontier model, the suggestion is one model behind; (c) the alias list `opus, sonnet, haiku` doesn't disambiguate version (which `opus` does the alias resolve to — `opus-4-6` or `opus-4-7`?). **Required fix shape:** (a) accept bare `claude-*` and `gpt-*` model names as canonical-named-without-prefix and route via name-prefix detection (already implemented for prefix-routed mode); (b) update the example in `invalid_model_syntax` error to current frontier (`anthropic/claude-opus-4-7`); (c) document or expose `opus` → exact-version mapping in the error message and in `claw doctor`/`status` output (`model_alias_resolved_to: "claude-opus-4-7"`); (d) regression test: `claw --model claude-opus-4-7 status --output-format json` returns `model_source:"flag"`, not `kind:"invalid_model_syntax"`. **Sibling bug observed in same probe:** `enabledPlugins` deprecation warning repeats 3 times in stderr for the same `~/.claw/settings.json` load — config file is being loaded/parsed 3 times during a single `status` invocation. **Why this matters:** every Anthropic doc, every CCAPI route, every internal tooling references models by their bare canonical name (`claude-opus-4-7`). Forcing the `anthropic/` prefix breaks copy-paste from Anthropic's own examples and adds a redundant token to every invocation. The stale `4-6` suggestion in the error message actively misdirects users away from the current model. Source: Jobdori live dogfood, `6c0c305a`, 2026-05-11. +424. **DONE — `--model` accepts bare canonical provider model names and Anthropic routing prefixes are stripped before provider calls** — fixed 2026-06-03 in `fix: normalize Anthropic model routing`. `validate_model_syntax()` now accepts unambiguous bare `claude-*` and `gpt-*` model IDs while preserving raw model provenance, and Anthropic `/v1/messages` plus `/v1/messages/count_tokens` request bodies strip the CLI-only `anthropic/` routing prefix so default/alias models do not reach Anthropic as `anthropic/claude-*`. Existing `qwen-*`/`grok-*` prefix-hint behavior remains intentionally unchanged for provider families whose bare names are ambiguous with DashScope/xAI routing. Regression coverage: `standard_messages_body_strips_anthropic_routing_prefix`, `send_message_strips_anthropic_routing_prefix_on_wire`, `default_model_alias_uses_anthropic_routing_prefix`, and the bare `--model=claude-opus-4-6 status` / `--model gpt-4 prompt` parser assertions in `parses_single_word_command_aliases_without_falling_back_to_prompt_mode`. 425. **Config file precedence (`.claw/settings.json` always wins over `.claw.json`) is undocumented in user-facing surfaces — `config --output-format json` reports both files as `loaded:true` with no `precedence_rank` or `wins_for_keys` attribution; sibling: deprecation warning fires 4× per status invocation (was 3× in #424, regression upward)** — dogfooded 2026-05-11 by Jobdori on `d7dbe951` in response to Clawhip pinpoint nudge at `1503237744451649537`. Reproduction: create `.claw.json` with `{"model":"anthropic/claude-sonnet-4-6"}` and `.claw/settings.json` with `{"model":"anthropic/claude-opus-4-7"}` in the same workspace. `claw status --output-format json` returns `model:"anthropic/claude-opus-4-7", model_source:"config"`. Reverse the files (.claw.json=opus, settings.json=sonnet) → `model:"anthropic/claude-sonnet-4-6"`. Confirmed: `.claw/settings.json` **always** wins over `.claw.json` for conflicting keys, regardless of file mtime or alphabetical order. `claw config --output-format json` reports both as `loaded:true` with no `precedence_rank`, `effective_for_keys`, or `shadowed_keys` attribution. The only signal of precedence is the final merged value in `status` — automation cannot programmatically discover which file contributed which key without re-implementing the merge logic. **Sibling bug (regression from #424):** the `enabledPlugins` deprecation warning now fires **4 times** in stderr per single `status` invocation (was 3× in #424's probe at HEAD `6c0c305a`; current HEAD `d7dbe951` shows 4×). Config load count went up by 1. **Sibling bug observed in config-section probe:** `claw config model --output-format json` with a `.claw.json` that contains a benign unknown key (e.g., `"alpha":"x"`) returns `{"error":"/path/.claw.json: unknown key \"alpha\" (line 1)","kind":"unknown"}` — the entire config command fails with a generic `unknown` kind instead of (a) tolerating unrecognized keys with a warning, or (b) emitting a typed `kind:"unknown_key"` error scoped to the offending file/key. **Required fix shape:** (a) document precedence order in `USAGE.md` (`.claw/settings.local.json > .claw/settings.json > .claw.json` for project scope; `user`/`system` scope at each layer); (b) add `precedence_rank:int` and optional `wins_for_keys:[string]` / `shadowed_keys:[string]` to each entry in `config --output-format json` `files[]`; (c) dedupe the deprecation warning to fire **once per discovered file** instead of N× per load pass; (d) make `config
--output-format json` tolerate unknown keys with warnings, OR emit `kind:"unknown_key"` with `path:` and `key:` fields scoped to the offending file. **Why this matters:** users mixing legacy `.claw.json` with new `.claw/settings.json` have no way to verify which file is actually controlling their runtime. The undocumented precedence + missing per-key attribution forces trial-and-error to debug config drift. Cross-references #407 (config files no load_error) and #415 (config section returns merged_keys count not values). Source: Jobdori live dogfood, `d7dbe951`, 2026-05-11. diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index 51e10b44..73a644aa 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -468,8 +468,7 @@ impl AnthropicClient { request: &MessageRequest, ) -> Result { let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/')); - let mut request_body = self.request_profile.render_json_body(request)?; - strip_unsupported_beta_body_fields(&mut request_body); + let request_body = render_standard_messages_body(&self.request_profile, request)?; let request_builder = self.build_request(&request_url).json(&request_body); request_builder.send().await.map_err(ApiError::from) } @@ -529,8 +528,7 @@ impl AnthropicClient { "{}/v1/messages/count_tokens", self.base_url.trim_end_matches('/') ); - let mut request_body = self.request_profile.render_json_body(request)?; - strip_unsupported_beta_body_fields(&mut request_body); + let request_body = render_standard_messages_body(&self.request_profile, request)?; let response = self .build_request(&request_url) .json(&request_body) @@ -977,6 +975,21 @@ fn enrich_bearer_auth_error(error: ApiError, auth: &AuthSource) -> ApiError { } } +fn anthropic_wire_model(model: &str) -> &str { + model.strip_prefix("anthropic/").unwrap_or(model) +} + +fn render_standard_messages_body( + request_profile: &AnthropicRequestProfile, + request: &MessageRequest, +) -> Result { + let mut wire_request = request.clone(); + wire_request.model = anthropic_wire_model(&request.model).to_string(); + let mut body = request_profile.render_json_body(&wire_request)?; + strip_unsupported_beta_body_fields(&mut body); + Ok(body) +} + /// 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` @@ -1550,6 +1563,27 @@ mod tests { ); } + #[test] + fn standard_messages_body_strips_anthropic_routing_prefix() { + let client = AnthropicClient::new("test-key"); + let request = MessageRequest { + model: "anthropic/claude-opus-4-6".to_string(), + max_tokens: 64, + messages: vec![], + system: None, + tools: None, + tool_choice: None, + stream: false, + ..Default::default() + }; + + let rendered = super::render_standard_messages_body(client.request_profile(), &request) + .expect("body should render"); + + assert_eq!(rendered["model"], serde_json::json!("claude-opus-4-6")); + assert!(rendered.get("betas").is_none()); + } + #[test] fn enrich_bearer_auth_error_appends_sk_ant_hint_on_401_with_pure_bearer_token() { // given diff --git a/rust/crates/api/tests/client_integration.rs b/rust/crates/api/tests/client_integration.rs index 15959e71..c53e34c5 100644 --- a/rust/crates/api/tests/client_integration.rs +++ b/rust/crates/api/tests/client_integration.rs @@ -103,6 +103,58 @@ async fn send_message_posts_json_and_parses_response() { ); } +#[tokio::test] +async fn send_message_strips_anthropic_routing_prefix_on_wire() { + let state = Arc::new(Mutex::new(Vec::::new())); + let server = spawn_server( + state.clone(), + vec![ + http_response("200 OK", "application/json", "{\"input_tokens\":1}"), + http_response( + "200 OK", + "application/json", + concat!( + "{", + "\"id\":\"msg_prefixed\",", + "\"type\":\"message\",", + "\"role\":\"assistant\",", + "\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],", + "\"model\":\"claude-opus-4-6\",", + "\"stop_reason\":\"end_turn\",", + "\"stop_sequence\":null,", + "\"usage\":{\"input_tokens\":1,\"output_tokens\":1}", + "}" + ), + ), + ], + ) + .await; + + let client = AnthropicClient::new("test-key").with_base_url(server.base_url()); + client + .send_message(&MessageRequest { + model: "anthropic/claude-opus-4-6".to_string(), + ..sample_request(false) + }) + .await + .expect("request should succeed"); + + let captured = state.lock().await; + assert_eq!( + captured.len(), + 2, + "count_tokens and messages requests should be captured" + ); + let count_tokens_body: serde_json::Value = + serde_json::from_str(&captured[0].body).expect("count_tokens body should be json"); + let messages_body: serde_json::Value = + serde_json::from_str(&captured[1].body).expect("request body should be json"); + assert_eq!(captured[0].path, "/v1/messages/count_tokens"); + assert_eq!(captured[1].path, "/v1/messages"); + assert_eq!(count_tokens_body["model"], json!("claude-opus-4-6")); + assert_eq!(messages_body["model"], json!("claude-opus-4-6")); +} + #[tokio::test] async fn send_message_blocks_oversized_requests_before_the_http_call() { let state = Arc::new(Mutex::new(Vec::::new())); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 88ccd24e..6c042d28 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -2068,6 +2068,9 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { trimmed )); } + if is_bare_provider_model(trimmed) { + return Ok(()); + } // Check provider/model format: provider_id/model_id let parts: Vec<&str> = trimmed.split('/').collect(); if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() { @@ -2094,6 +2097,10 @@ fn validate_model_syntax(model: &str) -> Result<(), String> { Ok(()) } +fn is_bare_provider_model(model: &str) -> bool { + model.starts_with("claude-") || model.starts_with("gpt-") +} + fn config_alias_for_current_dir(alias: &str) -> Option { if alias.is_empty() { return None; @@ -12449,6 +12456,12 @@ mod tests { assert_eq!(resolve_model_alias("claude-opus"), "claude-opus"); } + #[test] + fn default_model_alias_uses_anthropic_routing_prefix() { + assert_eq!(DEFAULT_MODEL, "anthropic/claude-opus-4-6"); + assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-6"); + } + #[test] fn user_defined_aliases_resolve_before_provider_dispatch() { // given @@ -12956,6 +12969,19 @@ mod tests { } other => panic!("expected CliAction::Status, got: {other:?}"), } + match parse_args(&["--model=claude-opus-4-6".to_string(), "status".to_string()]) + .expect("bare Anthropic model should parse") + { + CliAction::Status { + model, + model_flag_raw, + .. + } => { + assert_eq!(model, "claude-opus-4-6"); + assert_eq!(model_flag_raw.as_deref(), Some("claude-opus-4-6")); + } + other => panic!("expected CliAction::Status, got: {other:?}"), + } } #[test] @@ -13481,22 +13507,19 @@ mod tests { !err_other.contains("--output-format json"), "unrelated args should not trigger --json hint: {err_other}" ); - // #154: model syntax error should hint at provider prefix when applicable - let err_gpt = parse_args(&[ + // #424: bare canonical GPT model ids should parse and route via provider + // detection instead of forcing the local-only `openai/` routing prefix. + match parse_args(&[ "prompt".to_string(), "test".to_string(), "--model".to_string(), "gpt-4".to_string(), ]) - .expect_err("`--model gpt-4` should fail with OpenAI hint"); - assert!( - err_gpt.contains("Did you mean `openai/gpt-4`?"), - "GPT model error should hint openai/ prefix: {err_gpt}" - ); - assert!( - err_gpt.contains("OPENAI_API_KEY"), - "GPT model error should mention env var: {err_gpt}" - ); + .expect("`--model gpt-4` should parse as a bare OpenAI model") + { + CliAction::Prompt { model, .. } => assert_eq!(model, "gpt-4"), + other => panic!("expected CliAction::Prompt, got: {other:?}"), + } let err_qwen = parse_args(&[ "prompt".to_string(), "test".to_string(),