From 3dde9be4ce68ca235dd3ec39d04ee01a700a0f58 Mon Sep 17 00:00:00 2001 From: TheArchitectit Date: Tue, 16 Jun 2026 12:25:52 -0500 Subject: [PATCH] fix(setup): normalize bare custom model to local/ prefix to avoid proxy 404 The previous fix normalized bare model names like openclaw to openai/openclaw so validation passed, but the full prefixed name was sent as the wire model, causing custom proxies to return 404. Instead, normalize to local/openclaw. The local/ prefix forces OpenAI- compatible routing and is always stripped by wire_model_for_base_url(), so the backend receives the bare model id the user configured while the CLI still validates and routes correctly. - Revert validation-time bare-name acceptance (it leaked the user's OPENAI_BASE_URL env var into unrelated tests). - Add unit tests for the local/ normalization and config env injection. - Fix an unused_mut warning in tui/app.rs terminal-guard tests. Co-Authored-By: Claude Fable 5 --- rust/crates/rusty-claude-cli/src/main.rs | 100 +++++++++++++++++++- rust/crates/rusty-claude-cli/src/tui/app.rs | 56 +++++++---- 2 files changed, 136 insertions(+), 20 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 6bb5bd13..fa880f24 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -3133,11 +3133,11 @@ fn config_model_for_current_dir() -> Option { let model = config.model()?; // If the user configured a custom OpenAI-compatible endpoint with a bare - // model name (e.g. "openclaw"), normalize it to "openai/openclaw" so it - // passes provider/model syntax validation. Real OpenAI bare names like - // "gpt-4" are already accepted as aliases. + // model name (e.g. "openclaw"), route it through the local/ prefix so it + // passes validation, maps to the OpenAI-compat client, and gets stripped + // down to the bare model id on the wire (avoiding a proxy 404). if config_has_custom_openai_base_url(&config) && !model.contains('/') { - return Some(format!("openai/{model}")); + return Some(format!("local/{model}")); } Some(model.to_owned()) @@ -15116,6 +15116,98 @@ mod tests { ); } + #[test] + fn config_model_normalizes_bare_name_to_local_prefix_for_custom_openai_endpoint() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "openai", + "apiKey": "sk-test", + "baseUrl": "http://localhost:9999/v1" + }, + "model": "openclaw" + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_base_url = std::env::var("OPENAI_BASE_URL").ok(); + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("OPENAI_BASE_URL"); + + let resolved = with_current_dir(&cwd, super::config_model_for_current_dir); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_base_url { + Some(value) => std::env::set_var("OPENAI_BASE_URL", value), + None => std::env::remove_var("OPENAI_BASE_URL"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(resolved, Some("local/openclaw".to_string())); + } + + #[test] + fn inject_config_as_env_fallbacks_sets_openai_provider_env_vars() { + let _guard = env_lock(); + let root = temp_dir(); + let cwd = root.join("project"); + let config_home = root.join("config-home"); + std::fs::create_dir_all(&cwd).expect("project dir should exist"); + std::fs::create_dir_all(&config_home).expect("config home should exist"); + std::fs::write( + config_home.join("settings.json"), + r#"{ + "provider": { + "kind": "openai", + "apiKey": "sk-from-config", + "baseUrl": "http://localhost:9999/v1" + } + }"#, + ) + .expect("user settings should write"); + + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_api_key = std::env::var("OPENAI_API_KEY").ok(); + let original_base_url = std::env::var("OPENAI_BASE_URL").ok(); + + std::env::set_var("CLAW_CONFIG_HOME", &config_home); + std::env::remove_var("OPENAI_API_KEY"); + std::env::remove_var("OPENAI_BASE_URL"); + + with_current_dir(&cwd, super::inject_config_as_env_fallbacks); + + let api_key = std::env::var("OPENAI_API_KEY").ok(); + let base_url = std::env::var("OPENAI_BASE_URL").ok(); + + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_api_key { + Some(value) => std::env::set_var("OPENAI_API_KEY", value), + None => std::env::remove_var("OPENAI_API_KEY"), + } + match original_base_url { + Some(value) => std::env::set_var("OPENAI_BASE_URL", value), + None => std::env::remove_var("OPENAI_BASE_URL"), + } + std::fs::remove_dir_all(root).expect("temp config root should clean up"); + + assert_eq!(api_key, Some("sk-from-config".to_string())); + assert_eq!(base_url, Some("http://localhost:9999/v1".to_string())); + } + #[test] fn default_permission_mode_uses_project_config_when_env_is_unset() { let _guard = env_lock(); diff --git a/rust/crates/rusty-claude-cli/src/tui/app.rs b/rust/crates/rusty-claude-cli/src/tui/app.rs index 2f8b6714..e854fb8b 100644 --- a/rust/crates/rusty-claude-cli/src/tui/app.rs +++ b/rust/crates/rusty-claude-cli/src/tui/app.rs @@ -16,8 +16,8 @@ use ratatui::Frame; use crate::keybindings::{Action, KeyMap, KeyPreset, VimMode}; use crate::theme::TuiTheme; use crate::tui::component::{Component, Overlay}; -use crate::tui::components::command_palette::CommandPaletteOverlay; use crate::tui::components::agent_view::AgentViewOverlay; +use crate::tui::components::command_palette::CommandPaletteOverlay; use crate::tui::components::conversation::ConversationPane; use crate::tui::components::dashboard::Dashboard; use crate::tui::components::input_bar::{InputBar, InputOutcome}; @@ -271,14 +271,16 @@ impl TuiApp { } TuiEvent::TurnComplete { assistant_text } => { if !assistant_text.is_empty() { - self.conversation.push_output(&assistant_text, false, &self.theme); + self.conversation + .push_output(&assistant_text, false, &self.theme); } self.input_bar.set_turn_in_progress(false); self.dashboard.set_status("Done"); } TuiEvent::TurnError { error } => { let color = self.theme.conversation_error.to_color(); - self.conversation.push_system_message(&format!("Error: {error}"), color); + self.conversation + .push_system_message(&format!("Error: {error}"), color); self.input_bar.set_turn_in_progress(false); self.dashboard.set_status(""); } @@ -383,7 +385,10 @@ impl TuiApp { fn dispatch_action(&mut self, action: Action) -> io::Result { match action { - Action::CommandPalette => { self.command_palette.open(); Ok(TuiReadOutcome::Pending) } + Action::CommandPalette => { + self.command_palette.open(); + Ok(TuiReadOutcome::Pending) + } Action::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView), Action::Help => { let preset = self.key_preset_name().to_string(); @@ -395,12 +400,30 @@ impl TuiApp { self.conversation.clear(); Ok(TuiReadOutcome::Pending) } - Action::ScrollUp => { self.conversation.scroll_up(1); Ok(TuiReadOutcome::Pending) } - Action::ScrollDown => { self.conversation.scroll_down(1); Ok(TuiReadOutcome::Pending) } - Action::ScrollHalfUp => { self.conversation.scroll_up(5); Ok(TuiReadOutcome::Pending) } - Action::ScrollHalfDown => { self.conversation.scroll_down(5); Ok(TuiReadOutcome::Pending) } - Action::ScrollTop => { self.conversation.scroll_top(); Ok(TuiReadOutcome::Pending) } - Action::ScrollBottom => { self.conversation.scroll_bottom(); Ok(TuiReadOutcome::Pending) } + Action::ScrollUp => { + self.conversation.scroll_up(1); + Ok(TuiReadOutcome::Pending) + } + Action::ScrollDown => { + self.conversation.scroll_down(1); + Ok(TuiReadOutcome::Pending) + } + Action::ScrollHalfUp => { + self.conversation.scroll_up(5); + Ok(TuiReadOutcome::Pending) + } + Action::ScrollHalfDown => { + self.conversation.scroll_down(5); + Ok(TuiReadOutcome::Pending) + } + Action::ScrollTop => { + self.conversation.scroll_top(); + Ok(TuiReadOutcome::Pending) + } + Action::ScrollBottom => { + self.conversation.scroll_bottom(); + Ok(TuiReadOutcome::Pending) + } _ => Ok(TuiReadOutcome::Pending), } } @@ -433,10 +456,7 @@ impl TuiApp { let main = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(40), - Constraint::Length(dashboard_width), - ]) + .constraints([Constraint::Min(40), Constraint::Length(dashboard_width)]) .split(area); let left = Layout::default() @@ -523,7 +543,11 @@ mod tests { let bus = EventBus::new(); let sender = bus.sender(); sender.send(TuiEvent::TurnStarted).unwrap(); - sender.send(TuiEvent::TurnError { error: "test".into() }).unwrap(); + sender + .send(TuiEvent::TurnError { + error: "test".into(), + }) + .unwrap(); let events = bus.drain(); assert_eq!(events.len(), 2); } @@ -546,7 +570,7 @@ mod tests { /// Regression: reenter_after_turn is a no-op when already inside TUI. #[test] fn test_terminal_guard_reenter_when_already_inside() { - let mut guard = TerminalGuard { in_tui: true }; + let guard = TerminalGuard { in_tui: true }; // reenter_after_turn would try to enter alternate screen, // which fails without a real terminal. But the guard should // short-circuit since it's already in_tui.