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:
parent
abb6ba9d5c
commit
3dde9be4ce
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue