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()?;
|
let model = config.model()?;
|
||||||
|
|
||||||
// If the user configured a custom OpenAI-compatible endpoint with a bare
|
// If the user configured a custom OpenAI-compatible endpoint with a bare
|
||||||
// model name (e.g. "openclaw"), normalize it to "openai/openclaw" so it
|
// model name (e.g. "openclaw"), route it through the local/ prefix so it
|
||||||
// passes provider/model syntax validation. Real OpenAI bare names like
|
// passes validation, maps to the OpenAI-compat client, and gets stripped
|
||||||
// "gpt-4" are already accepted as aliases.
|
// down to the bare model id on the wire (avoiding a proxy 404).
|
||||||
if config_has_custom_openai_base_url(&config) && !model.contains('/') {
|
if config_has_custom_openai_base_url(&config) && !model.contains('/') {
|
||||||
return Some(format!("openai/{model}"));
|
return Some(format!("local/{model}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(model.to_owned())
|
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]
|
#[test]
|
||||||
fn default_permission_mode_uses_project_config_when_env_is_unset() {
|
fn default_permission_mode_uses_project_config_when_env_is_unset() {
|
||||||
let _guard = env_lock();
|
let _guard = env_lock();
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@ use ratatui::Frame;
|
||||||
use crate::keybindings::{Action, KeyMap, KeyPreset, VimMode};
|
use crate::keybindings::{Action, KeyMap, KeyPreset, VimMode};
|
||||||
use crate::theme::TuiTheme;
|
use crate::theme::TuiTheme;
|
||||||
use crate::tui::component::{Component, Overlay};
|
use crate::tui::component::{Component, Overlay};
|
||||||
use crate::tui::components::command_palette::CommandPaletteOverlay;
|
|
||||||
use crate::tui::components::agent_view::AgentViewOverlay;
|
use crate::tui::components::agent_view::AgentViewOverlay;
|
||||||
|
use crate::tui::components::command_palette::CommandPaletteOverlay;
|
||||||
use crate::tui::components::conversation::ConversationPane;
|
use crate::tui::components::conversation::ConversationPane;
|
||||||
use crate::tui::components::dashboard::Dashboard;
|
use crate::tui::components::dashboard::Dashboard;
|
||||||
use crate::tui::components::input_bar::{InputBar, InputOutcome};
|
use crate::tui::components::input_bar::{InputBar, InputOutcome};
|
||||||
|
|
@ -271,14 +271,16 @@ impl TuiApp {
|
||||||
}
|
}
|
||||||
TuiEvent::TurnComplete { assistant_text } => {
|
TuiEvent::TurnComplete { assistant_text } => {
|
||||||
if !assistant_text.is_empty() {
|
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.input_bar.set_turn_in_progress(false);
|
||||||
self.dashboard.set_status("Done");
|
self.dashboard.set_status("Done");
|
||||||
}
|
}
|
||||||
TuiEvent::TurnError { error } => {
|
TuiEvent::TurnError { error } => {
|
||||||
let color = self.theme.conversation_error.to_color();
|
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.input_bar.set_turn_in_progress(false);
|
||||||
self.dashboard.set_status("");
|
self.dashboard.set_status("");
|
||||||
}
|
}
|
||||||
|
|
@ -383,7 +385,10 @@ impl TuiApp {
|
||||||
|
|
||||||
fn dispatch_action(&mut self, action: Action) -> io::Result<TuiReadOutcome> {
|
fn dispatch_action(&mut self, action: Action) -> io::Result<TuiReadOutcome> {
|
||||||
match action {
|
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::ToggleAgentView => Ok(TuiReadOutcome::ToggleAgentView),
|
||||||
Action::Help => {
|
Action::Help => {
|
||||||
let preset = self.key_preset_name().to_string();
|
let preset = self.key_preset_name().to_string();
|
||||||
|
|
@ -395,12 +400,30 @@ impl TuiApp {
|
||||||
self.conversation.clear();
|
self.conversation.clear();
|
||||||
Ok(TuiReadOutcome::Pending)
|
Ok(TuiReadOutcome::Pending)
|
||||||
}
|
}
|
||||||
Action::ScrollUp => { self.conversation.scroll_up(1); Ok(TuiReadOutcome::Pending) }
|
Action::ScrollUp => {
|
||||||
Action::ScrollDown => { self.conversation.scroll_down(1); Ok(TuiReadOutcome::Pending) }
|
self.conversation.scroll_up(1);
|
||||||
Action::ScrollHalfUp => { self.conversation.scroll_up(5); Ok(TuiReadOutcome::Pending) }
|
Ok(TuiReadOutcome::Pending)
|
||||||
Action::ScrollHalfDown => { self.conversation.scroll_down(5); Ok(TuiReadOutcome::Pending) }
|
}
|
||||||
Action::ScrollTop => { self.conversation.scroll_top(); Ok(TuiReadOutcome::Pending) }
|
Action::ScrollDown => {
|
||||||
Action::ScrollBottom => { self.conversation.scroll_bottom(); Ok(TuiReadOutcome::Pending) }
|
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),
|
_ => Ok(TuiReadOutcome::Pending),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -433,10 +456,7 @@ impl TuiApp {
|
||||||
|
|
||||||
let main = Layout::default()
|
let main = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.constraints([
|
.constraints([Constraint::Min(40), Constraint::Length(dashboard_width)])
|
||||||
Constraint::Min(40),
|
|
||||||
Constraint::Length(dashboard_width),
|
|
||||||
])
|
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let left = Layout::default()
|
let left = Layout::default()
|
||||||
|
|
@ -523,7 +543,11 @@ mod tests {
|
||||||
let bus = EventBus::new();
|
let bus = EventBus::new();
|
||||||
let sender = bus.sender();
|
let sender = bus.sender();
|
||||||
sender.send(TuiEvent::TurnStarted).unwrap();
|
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();
|
let events = bus.drain();
|
||||||
assert_eq!(events.len(), 2);
|
assert_eq!(events.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
@ -546,7 +570,7 @@ mod tests {
|
||||||
/// Regression: reenter_after_turn is a no-op when already inside TUI.
|
/// Regression: reenter_after_turn is a no-op when already inside TUI.
|
||||||
#[test]
|
#[test]
|
||||||
fn test_terminal_guard_reenter_when_already_inside() {
|
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,
|
// reenter_after_turn would try to enter alternate screen,
|
||||||
// which fails without a real terminal. But the guard should
|
// which fails without a real terminal. But the guard should
|
||||||
// short-circuit since it's already in_tui.
|
// short-circuit since it's already in_tui.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue