Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions rust/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions rust/crates/rusty-claude-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ serde_json.workspace = true
syntect = "5"
tokio = { version = "1", features = ["rt-multi-thread", "signal", "time"] }
tools = { path = "../tools" }
log = "0.4"


[lints]
workspace = true
Expand Down
118 changes: 81 additions & 37 deletions rust/crates/rusty-claude-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant, UNIX_EPOCH};

use log::debug;

use api::{
detect_provider_kind, model_family_identity_for, resolve_startup_auth_source, AnthropicClient,
AuthSource, ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest,
Expand Down Expand Up @@ -58,7 +60,7 @@ use tools::{
execute_tool, mvp_tool_specs, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput,
};

const DEFAULT_MODEL: &str = "claude-opus-4-6";
const DEFAULT_MODEL: &str = "anthropic/claude-opus-4-6";

/// #148: Model provenance for `claw status` JSON/text output. Records where
/// the resolved model string came from so claws don't have to re-read argv
Expand Down Expand Up @@ -718,15 +720,19 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let value = args
.get(index + 1)
.ok_or_else(|| "missing value for --model".to_string())?;
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
let resolved = resolve_model_alias_with_config(value);
debug!("Resolved --model '{}' -> '{}'", value, resolved);
validate_model_syntax(&resolved)?;
model = resolved;
model_flag_raw = Some(value.clone()); // #148
index += 2;
}
flag if flag.starts_with("--model=") => {
let value = &flag[8..];
validate_model_syntax(value)?;
model = resolve_model_alias_with_config(value);
let resolved = resolve_model_alias_with_config(value);
debug!("Resolved --model='{}' -> '{}'", value, resolved);
validate_model_syntax(&resolved)?;
model = resolved;
model_flag_raw = Some(value.to_string()); // #148
index += 1;
}
Expand Down Expand Up @@ -1512,9 +1518,9 @@ fn levenshtein_distance(left: &str, right: &str) -> usize {

fn resolve_model_alias(model: &str) -> &str {
match model {
"opus" => "claude-opus-4-6",
"sonnet" => "claude-sonnet-4-6",
"haiku" => "claude-haiku-4-5-20251213",
"opus" => "anthropic/claude-opus-4-6",
"sonnet" => "anthropic/claude-sonnet-4-6",
"haiku" => "anthropic/claude-haiku-4-5-20251213",
_ => model,
}
}
Expand All @@ -1538,11 +1544,6 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
if trimmed.is_empty() {
return Err("model string cannot be empty".to_string());
}
// Known aliases are always valid
match trimmed {
"opus" | "sonnet" | "haiku" => return Ok(()),
_ => {}
}
// Check for spaces (malformed)
if trimmed.contains(' ') {
return Err(format!(
Expand All @@ -1555,7 +1556,7 @@ fn validate_model_syntax(model: &str) -> Result<(), String> {
if parts.len() != 2 || parts[0].is_empty() || parts[1].is_empty() {
// #154: hint if the model looks like it belongs to a different provider
let mut err_msg = format!(
"invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6) or known alias (opus, sonnet, haiku)",
"invalid model syntax: '{}'. Expected provider/model (e.g., anthropic/claude-opus-4-6)",
trimmed
);
if trimmed.starts_with("gpt-") || trimmed.starts_with("gpt_") {
Expand Down Expand Up @@ -10308,7 +10309,7 @@ mod tests {
#[test]
fn context_window_preflight_errors_render_recovery_steps() {
let error = ApiError::ContextWindowExceeded {
model: "claude-sonnet-4-6".to_string(),
model: "anthropic/claude-sonnet-4-6".to_string(),
estimated_input_tokens: 182_000,
requested_output_tokens: 64_000,
estimated_total_tokens: 246_000,
Expand All @@ -10323,7 +10324,7 @@ mod tests {
"{rendered}"
);
assert!(
rendered.contains("Model claude-sonnet-4-6"),
rendered.contains("Model anthropic/claude-sonnet-4-6"),
"{rendered}"
);
assert!(
Expand Down Expand Up @@ -10777,7 +10778,7 @@ mod tests {
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Json,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
Expand Down Expand Up @@ -10851,7 +10852,7 @@ mod tests {
parse_args(&args).expect("args should parse"),
CliAction::Prompt {
prompt: "explain this".to_string(),
model: "claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: PermissionMode::DangerFullAccess,
Expand All @@ -10865,9 +10866,9 @@ mod tests {

#[test]
fn resolves_known_model_aliases() {
assert_eq!(resolve_model_alias("opus"), "claude-opus-4-6");
assert_eq!(resolve_model_alias("sonnet"), "claude-sonnet-4-6");
assert_eq!(resolve_model_alias("haiku"), "claude-haiku-4-5-20251213");
assert_eq!(resolve_model_alias("opus"), "anthropic/claude-opus-4-6");
assert_eq!(resolve_model_alias("sonnet"), "anthropic/claude-sonnet-4-6");
assert_eq!(resolve_model_alias("haiku"), "anthropic/claude-haiku-4-5-20251213");
assert_eq!(resolve_model_alias("claude-opus"), "claude-opus");
}

Expand All @@ -10882,7 +10883,7 @@ mod tests {
std::fs::create_dir_all(&config_home).expect("config home should exist");
std::fs::write(
cwd.join(".claw").join("settings.json"),
r#"{"aliases":{"fast":"claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#,
r#"{"aliases":{"fast":"anthropic/claude-haiku-4-5-20251213","smart":"opus","cheap":"grok-3-mini"}}"#,
)
.expect("project config should write");

Expand All @@ -10903,11 +10904,11 @@ mod tests {
std::fs::remove_dir_all(root).expect("temp config root should clean up");

// then
assert_eq!(direct, "claude-haiku-4-5-20251213");
assert_eq!(chained, "claude-opus-4-6");
assert_eq!(direct, "anthropic/claude-haiku-4-5-20251213");
assert_eq!(chained, "anthropic/claude-opus-4-6");
assert_eq!(cross_provider, "grok-3-mini");
assert_eq!(unknown, "unknown-model");
assert_eq!(builtin, "claude-haiku-4-5-20251213");
assert_eq!(builtin, "anthropic/claude-haiku-4-5-20251213");
}

#[test]
Expand Down Expand Up @@ -11341,7 +11342,7 @@ mod tests {
model_flag_raw,
..
} => {
assert_eq!(model, "claude-sonnet-4-6", "sonnet alias should resolve");
assert_eq!(model, "anthropic/claude-sonnet-4-6", "sonnet alias should resolve");
assert_eq!(
model_flag_raw.as_deref(),
Some("sonnet"),
Expand Down Expand Up @@ -12313,7 +12314,7 @@ mod tests {
.expect("prompt shorthand should still work"),
CliAction::Prompt {
prompt: "please debug this".to_string(),
model: "claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
output_format: CliOutputFormat::Text,
allowed_tools: None,
permission_mode: crate::default_permission_mode(),
Expand Down Expand Up @@ -12815,7 +12816,7 @@ mod tests {
vec!["session-old".to_string()],
);

assert!(completions.contains(&"/model claude-sonnet-4-6".to_string()));
assert!(completions.contains(&"/model anthropic/claude-sonnet-4-6".to_string()));
assert!(completions.contains(&"/permissions workspace-write".to_string()));
assert!(completions.contains(&"/session list".to_string()));
assert!(completions.contains(&"/session switch session-current".to_string()));
Expand All @@ -12834,7 +12835,7 @@ mod tests {

let banner = with_current_dir(&root, || {
LiveCli::new(
"claude-sonnet-4-6".to_string(),
"anthropic/claude-sonnet-4-6".to_string(),
true,
None,
PermissionMode::DangerFullAccess,
Expand All @@ -12852,11 +12853,11 @@ mod tests {

#[test]
fn format_connected_line_renders_anthropic_provider_for_claude_model() {
let model = "claude-sonnet-4-6";
let model = "anthropic/claude-sonnet-4-6";

let line = format_connected_line(model);

assert_eq!(line, "Connected: claude-sonnet-4-6 via anthropic");
assert_eq!(line, "Connected: anthropic/claude-sonnet-4-6 via anthropic");
}

#[test]
Expand All @@ -12870,11 +12871,11 @@ mod tests {

#[test]
fn resolve_repl_model_returns_user_supplied_model_unchanged_when_explicit() {
let user_model = "claude-sonnet-4-6".to_string();
let user_model = "anthropic/claude-sonnet-4-6".to_string();

let resolved = resolve_repl_model(user_model);

assert_eq!(resolved, "claude-sonnet-4-6");
assert_eq!(resolved, "anthropic/claude-sonnet-4-6");
}

#[test]
Expand All @@ -12890,7 +12891,7 @@ mod tests {

let resolved = with_current_dir(&root, || resolve_repl_model(DEFAULT_MODEL.to_string()));

assert_eq!(resolved, "claude-sonnet-4-6");
assert_eq!(resolved, "anthropic/claude-sonnet-4-6");

std::env::remove_var("ANTHROPIC_MODEL");
std::env::remove_var("CLAW_CONFIG_HOME");
Expand Down Expand Up @@ -14380,7 +14381,7 @@ UU conflicted.rs",
MessageResponse {
id: "msg-1".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-1".to_string(),
Expand Down Expand Up @@ -14415,7 +14416,7 @@ UU conflicted.rs",
MessageResponse {
id: "msg-2".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![OutputContentBlock::ToolUse {
id: "tool-2".to_string(),
Expand Down Expand Up @@ -14450,7 +14451,7 @@ UU conflicted.rs",
MessageResponse {
id: "msg-3".to_string(),
kind: "message".to_string(),
model: "claude-opus-4-6".to_string(),
model: "anthropic/claude-opus-4-6".to_string(),
role: "assistant".to_string(),
content: vec![
OutputContentBlock::Thinking {
Expand Down Expand Up @@ -15055,3 +15056,46 @@ mod dump_manifests_tests {
let _ = fs::remove_dir_all(&root);
}
}

#[cfg(test)]
mod alias_resolution_tests {
use super::{resolve_model_alias_with_config, validate_model_syntax};

#[test]
fn test_alias_resolution_builtin() {
// Built-in aliases should resolve to their full IDs
assert_eq!(resolve_model_alias_with_config("opus"), "anthropic/claude-opus-4-6");
assert_eq!(resolve_model_alias_with_config("sonnet"), "anthropic/claude-sonnet-4-6");
assert_eq!(resolve_model_alias_with_config("haiku"), "anthropic/claude-haiku-4-5-20251213");
}

#[test]
fn test_alias_resolution_syntax_validation() {
// Resolved aliases should pass syntax validation
let resolved = resolve_model_alias_with_config("opus");
assert!(validate_model_syntax(&resolved).is_ok());

// Raw aliases should FAIL syntax validation (this is why we resolve first!)
assert!(validate_model_syntax("opus").is_err());
}

#[test]
fn test_unknown_alias_fails_validation() {
// Unknown aliases resolve to themselves
let resolved = resolve_model_alias_with_config("unknown-alias");
assert_eq!(resolved, "unknown-alias");

// And then fail validation with a helpful error
let result = validate_model_syntax(&resolved);
assert!(result.is_err());
assert!(result.unwrap_err().contains("invalid model syntax"));
}

#[test]
fn test_direct_provider_model_passes() {
// Direct provider/model strings should remain unchanged and pass
let model = "openai/gpt-4o";
assert_eq!(resolve_model_alias_with_config(model), model);
assert!(validate_model_syntax(model).is_ok());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ fn status_command_applies_model_and_permission_mode_flags() {
assert_success(&output);
let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Model claude-sonnet-4-6"));
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6"));
assert!(stdout.contains("Permission mode read-only"));

fs::remove_dir_all(temp_dir).expect("cleanup temp dir");
Expand Down
2 changes: 1 addition & 1 deletion rust/crates/rusty-claude-cli/tests/compact_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ stderr:
"Mock streaming says hello from the parity harness."
);
assert_eq!(parsed["compact"], true);
assert_eq!(parsed["model"], "claude-sonnet-4-6");
assert_eq!(parsed["model"], "anthropic/claude-sonnet-4-6");
assert!(parsed["usage"].is_object());

fs::remove_dir_all(&workspace).expect("workspace cleanup should succeed");
Expand Down
16 changes: 10 additions & 6 deletions rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::os::unix::fs::PermissionsExt;

use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
Expand Down Expand Up @@ -426,11 +426,15 @@ fn prepare_plugin_fixture(workspace: &HarnessWorkspace) {
"#!/bin/sh\nINPUT=$(cat)\nprintf '{\"plugin\":\"%s\",\"tool\":\"%s\",\"input\":%s}\\n' \"$CLAWD_PLUGIN_ID\" \"$CLAWD_TOOL_NAME\" \"$INPUT\"\n",
)
.expect("plugin script should write");
let mut permissions = fs::metadata(&script_path)
.expect("plugin script metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = fs::metadata(&script_path)
.expect("plugin script metadata")
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script_path, permissions).expect("plugin script should be executable");
}

fs::write(
manifest_dir.join("plugin.json"),
Expand Down
6 changes: 3 additions & 3 deletions rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ fn status_command_applies_cli_flags_end_to_end() {

let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8");
assert!(stdout.contains("Status"));
assert!(stdout.contains("Model claude-sonnet-4-6"));
assert!(stdout.contains("Model anthropic/claude-sonnet-4-6"));
assert!(stdout.contains("Permission mode read-only"));
}

Expand Down Expand Up @@ -289,7 +289,7 @@ fn resumed_status_surfaces_persisted_model() {
let session_path = temp_dir.join("session.jsonl");

let mut session = workspace_session(&temp_dir);
session.model = Some("claude-sonnet-4-6".to_string());
session.model = Some("anthropic/claude-sonnet-4-6".to_string());
session
.push_user_text("model persistence fixture")
.expect("write ok");
Expand Down Expand Up @@ -317,7 +317,7 @@ fn resumed_status_surfaces_persisted_model() {
let parsed: Value = serde_json::from_str(stdout.trim()).expect("should be json");
assert_eq!(parsed["kind"], "status");
assert_eq!(
parsed["model"], "claude-sonnet-4-6",
parsed["model"], "anthropic/claude-sonnet-4-6",
"model should round-trip through session metadata"
);
}
Expand Down