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 <noreply@anthropic.com>
This commit is contained in:
TheArchitectit 2026-06-16 12:25:52 -05:00
parent abb6ba9d5c
commit 3dde9be4ce
2 changed files with 136 additions and 20 deletions

View File

@ -3133,11 +3133,11 @@ fn config_model_for_current_dir() -> Option<String> {
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();

View File

@ -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<TuiReadOutcome> {
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.