fix(tui): preserve exact provider route provenance

Carry optional exact provider IDs from configuration through sessions, runtime threads, engine operations, Fleet and subagent receipts so named custom routes never collapse into the generic custom kind.

Resolve and client-preflight immutable turn descriptors before mutating UI/runtime state, preserve idless legacy root custom provenance, and keep injected model clients as an explicit test/embedding seam. Add fail-closed collision, resume, exec, runtime API, Fleet, paused-dispatch, and no-zombie regressions.
This commit is contained in:
Hunter B
2026-07-15 11:14:01 -07:00
parent 03a0edd36a
commit cae14f4b9d
45 changed files with 6839 additions and 1149 deletions
@@ -2238,8 +2238,8 @@ pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult {
/// `codewhale auth clear --provider <id>` and
/// `codewhale auth set --provider <id>`.
pub fn logout(app: &mut App) -> CommandResult {
let provider_name = app.api_provider.as_str();
match clear_active_provider_api_key(provider_name) {
let provider_name = app.provider_identity_for_persistence().to_string();
match clear_active_provider_api_key(&provider_name) {
Ok(()) => {
app.onboarding = OnboardingState::ApiKey;
app.onboarding_needs_api_key = true;
@@ -4010,4 +4010,52 @@ max_concurrent = 4
let updated = fs::read_to_string(config_path).unwrap();
assert!(!updated.contains("api_key"));
}
#[test]
fn logout_clears_only_exact_named_custom_provider_key() {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-custom-logout-test-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root).unwrap();
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap()).unwrap();
fs::write(
&config_path,
"[providers.custom-a]\napi_key = \"a-key\"\n\n[providers.custom-b]\napi_key = \"b-key\"\n",
)
.unwrap();
let mut app = create_test_app();
app.set_provider_identity(ApiProvider::Custom, "custom-a");
let result = logout(&mut app);
assert!(result.message.is_some());
let updated = fs::read_to_string(config_path).unwrap();
assert!(!updated.contains("a-key"), "{updated}");
assert!(updated.contains("b-key"), "{updated}");
}
#[test]
fn named_custom_provider_url_write_fails_closed() {
let mut app = create_test_app();
app.set_provider_identity(ApiProvider::Custom, "custom-a");
let result = config_command(
&mut app,
Some("provider_url http://127.0.0.1:18181/v1 --save"),
);
let message = result.message.expect("error message");
assert!(
message.contains("named [providers.<name>] table"),
"{message}"
);
}
}
@@ -21,7 +21,11 @@ fn format_status(app: &App) -> String {
let _ = writeln!(out, "===================");
let _ = writeln!(out);
push_row(&mut out, "Version:", env!("CARGO_PKG_VERSION"));
push_row(&mut out, "Provider:", app.api_provider.as_str());
push_row(
&mut out,
"Provider:",
app.provider_identity_for_persistence(),
);
push_row(
&mut out,
"Model:",
@@ -284,6 +288,22 @@ mod tests {
assert!(msg.contains("Use /statusline to configure footer items."));
}
#[test]
fn status_report_keeps_exact_named_custom_provider() {
let tmpdir = TempDir::new().expect("temp dir");
let mut app = create_test_app(tmpdir.path().to_path_buf());
app.set_provider_identity(ApiProvider::Custom, "lm-studio");
let msg = status(&mut app).message.expect("status message");
let provider_row = msg
.lines()
.find(|line| line.trim_start().starts_with("Provider:"))
.expect("provider row");
assert_eq!(provider_row.split_whitespace().last(), Some("lm-studio"));
assert_ne!(provider_row.split_whitespace().last(), Some("custom"));
}
#[test]
fn status_report_surfaces_effective_safety_policy() {
let tmpdir = TempDir::new().expect("temp dir");
@@ -970,6 +970,7 @@ mod tests {
});
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 100,
@@ -1234,6 +1235,7 @@ mod tests {
app.model = "deepseek-v4-pro".to_string();
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 100,
@@ -1258,6 +1260,7 @@ mod tests {
app.model = "deepseek-v4-pro".to_string();
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 100,
@@ -152,7 +152,10 @@ fn provider_fallback(app: &mut App, subcommand: Option<&str>) -> CommandResult {
}
let mut lines = vec![
format!("Current provider: {}", app.api_provider.as_str()),
format!(
"Current provider: {}",
app.provider_identity_for_persistence()
),
"Fallback chain:".to_string(),
];
for (index, provider, is_current) in entries {
+27 -2
View File
@@ -675,8 +675,10 @@ fn format_turn_cache_route(rec: &TurnCacheRecord) -> String {
return "".to_string();
};
let provider = rec
.provider
.map(|provider| provider.as_str())
.provider_identity
.as_deref()
.filter(|provider| !provider.trim().is_empty())
.or_else(|| rec.provider.map(|provider| provider.as_str()))
.unwrap_or("?");
let route = if rec.auto_model {
format!("auto:{provider}/{model}")
@@ -708,3 +710,26 @@ fn humanize_age(d: std::time::Duration) -> String {
format!("{}h{:02}m", secs / 3600, (secs % 3600) / 60)
}
}
#[cfg(test)]
mod route_tests {
use super::*;
#[test]
fn cache_route_keeps_exact_named_custom_identity() {
let record = TurnCacheRecord {
provider: Some(crate::config::ApiProvider::Custom),
provider_identity: Some("lm-studio".to_string()),
model: Some("local-code-model".to_string()),
auto_model: false,
input_tokens: 1,
output_tokens: 1,
cache_hit_tokens: None,
cache_miss_tokens: None,
reasoning_replay_tokens: None,
recorded_at: Instant::now(),
};
assert_eq!(format_turn_cache_route(&record), "lm-studio/local-code-...");
}
}
@@ -534,6 +534,7 @@ fn cache_command_renders_recorded_turns_with_ratio() {
// Three turns: 75% hit, 50% hit, miss-only (provider didn't report hit).
app.push_turn_cache_record(TurnCacheRecord {
provider: Some(crate::config::ApiProvider::Deepseek),
provider_identity: Some("deepseek".to_string()),
model: Some("deepseek-v4-pro".to_string()),
auto_model: true,
input_tokens: 4_000,
@@ -545,6 +546,7 @@ fn cache_command_renders_recorded_turns_with_ratio() {
});
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 6_000,
@@ -558,6 +560,7 @@ fn cache_command_renders_recorded_turns_with_ratio() {
// infer miss = input hit and mark with `*`.
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 5_000,
@@ -570,6 +573,7 @@ fn cache_command_renders_recorded_turns_with_ratio() {
// Turn 4: no telemetry at all — must not pollute aggregate ratios.
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 1_000,
@@ -613,6 +617,7 @@ fn cache_command_replays_reported_1177_low_hit_fixture() {
] {
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: input,
@@ -641,6 +646,7 @@ fn cache_command_count_argument_clamps_to_history() {
for _ in 0..3 {
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 1_000,
@@ -663,6 +669,7 @@ fn turn_cache_history_is_capped_at_50() {
for i in 0..(crate::tui::app::App::TURN_CACHE_HISTORY_CAP + 12) {
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: i as u32,
@@ -1341,6 +1348,7 @@ fn cache_stats_shows_cache_hit_summary() {
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 10_000,
@@ -1352,6 +1360,7 @@ fn cache_stats_shows_cache_hit_summary() {
});
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 5_000,
@@ -1380,6 +1389,7 @@ fn cache_stats_low_hit_rate_shows_note() {
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 10_000,
@@ -1413,6 +1423,7 @@ fn cache_stats_flags_reported_1747_low_hit_fixture() {
// hit=21,356,928, miss=8,470,281, output=165,624.
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 29_827_209,
@@ -90,7 +90,9 @@ pub(crate) fn rename_with_manager(
session.context_references = app.session_context_references.clone();
session.artifacts = app.session_artifacts.clone();
session.metadata.model = app.model_selection_for_persistence();
session.metadata.model_provider = app.provider_identity_for_persistence().to_string();
session
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
session.metadata.workspace.clone_from(&app.workspace);
session.metadata.mode = Some(app.mode.as_setting().to_string());
app.sync_cost_to_metadata(&mut session.metadata);
@@ -222,7 +224,11 @@ mod tests {
assert_eq!(reloaded.work_state, expected_work_state);
assert!(reloaded.system_prompt.is_none());
assert_eq!(reloaded.metadata.model, "local-code-model");
assert_eq!(reloaded.metadata.model_provider, "lm-studio");
assert_eq!(reloaded.metadata.model_provider, "custom");
assert_eq!(
reloaded.metadata.model_provider_id.as_deref(),
Some("lm-studio")
);
assert_eq!(reloaded.metadata.workspace, app.workspace);
assert_eq!(reloaded.metadata.mode.as_deref(), Some("operate"));
assert_eq!(app.session_title.as_deref(), Some("Brand New Title"));
+76 -175
View File
@@ -6,8 +6,8 @@ use std::path::PathBuf;
use crate::session_manager::{
create_saved_session_with_id_and_mode, create_saved_session_with_mode,
};
use crate::tui::app::{App, AppAction, AppMode};
use crate::tui::history::{HistoryCell, history_cells_from_message};
use crate::tui::app::{App, AppAction};
use crate::tui::history::HistoryCell;
use crate::tui::session_picker::SessionPickerView;
use super::CommandResult;
@@ -31,7 +31,9 @@ pub fn save(app: &mut App, path: Option<&str>) -> CommandResult {
app.system_prompt.as_ref(),
Some(app.mode.label()),
);
session.metadata.model_provider = app.provider_identity_for_persistence().to_string();
session
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
app.sync_cost_to_metadata(&mut session.metadata);
session.context_references = app.session_context_references.clone();
session.artifacts = app.session_artifacts.clone();
@@ -105,7 +107,9 @@ pub fn fork(app: &mut App) -> CommandResult {
app.system_prompt.as_ref(),
Some(app.mode.label()),
);
parent.metadata.model_provider = app.provider_identity_for_persistence().to_string();
parent
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
if let Some(cached) = app
.current_session_metadata
.as_ref()
@@ -140,7 +144,9 @@ pub fn fork(app: &mut App) -> CommandResult {
app.system_prompt.as_ref(),
Some(app.mode.label()),
);
forked.metadata.model_provider = app.provider_identity_for_persistence().to_string();
forked
.metadata
.set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
forked.metadata.copy_cost_from(&parent.metadata);
forked.metadata.mark_forked_from(&parent.metadata);
forked.context_references = app.session_context_references.clone();
@@ -265,131 +271,20 @@ pub fn load(app: &mut App, path: Option<&str>) -> CommandResult {
}
};
let session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
let _session: crate::session_manager::SavedSession = match serde_json::from_str(&content) {
Ok(s) => s,
Err(e) => {
return CommandResult::error(format!("Failed to parse session file: {e}"));
}
};
// `/load` executes in the command layer, which does not borrow the event
// loop's Config. Reload the same live config source before mutating App so
// an exact named custom provider can be validated and a removed/renamed
// table fails closed. The subsequent SyncSession action repeats this check
// against the event loop's in-memory Config before rebuilding the engine.
let live_config =
match crate::config::Config::load(app.config_path.clone(), app.config_profile.as_deref()) {
Ok(config) => config,
Err(err) => {
return CommandResult::error(format!(
"Failed to load live config for session provider restore: {err}"
));
}
};
let provider_identity =
match live_config.resolve_provider_identity(&session.metadata.model_provider) {
Ok(identity) => identity,
Err(err) => return CommandResult::error(format!("Failed to restore session: {err}")),
};
if let Err(err) = app.restore_work_state(session.work_state.as_ref()) {
return CommandResult::error(format!("Failed to restore saved Work state: {err}"));
}
app.api_messages.clone_from(&session.messages);
app.clear_history();
let messages = app.api_messages.clone();
let mut message_to_cell = std::collections::HashMap::new();
for (message_index, message) in messages.iter().enumerate() {
let cells = history_cells_from_message(message);
let base = app.history.len();
if message.role == "user"
&& let Some(offset) = cells
.iter()
.position(|cell| matches!(cell, HistoryCell::User { .. }))
{
message_to_cell.insert(message_index, base + offset);
}
app.extend_history(cells);
}
app.sync_context_references_from_session(&session.context_references, &message_to_cell);
app.mark_history_updated();
app.viewport.transcript_selection.clear();
let previous_provider = app.api_provider;
let provider = provider_identity.provider;
app.set_provider_identity(provider, provider_identity.key);
app.reasoning_effort = app.reasoning_effort.normalize_for_provider(provider);
if provider != previous_provider {
// A context override belongs to the route that supplied it. A
// file load can cross providers without the event loop's live Config
// in scope, so never leak the previous provider's limit into the
// restored route.
app.set_active_context_window_override(None);
}
app.set_model_selection(session.metadata.model.clone());
app.active_route_limits = if app.auto_model {
app.context_window_override_limits()
} else {
crate::route_runtime::resolve_route_candidate(
app.api_provider,
Some(&app.model),
Some(&session.metadata.model),
None,
app.active_context_window_override,
)
.ok()
.and_then(|candidate| crate::route_budget::known_route_limits(candidate.limits))
.or_else(|| app.context_window_override_limits())
};
app.update_model_compaction_budget();
app.workspace.clone_from(&session.metadata.workspace);
if let Some(mode) = session.metadata.mode.as_deref().and_then(AppMode::parse) {
app.set_mode(mode);
}
app.session.total_tokens = u32::try_from(session.metadata.total_tokens).unwrap_or(u32::MAX);
app.session.total_conversation_tokens = app.session.total_tokens;
// Accumulated token breakdown is per-runtime-session; zero on load.
app.session.reset_token_breakdown();
app.session.session_cost = 0.0;
app.session.session_cost_cny = 0.0;
app.session.subagent_cost = 0.0;
app.session.subagent_cost_cny = 0.0;
app.session.subagent_cost_event_seqs.clear();
app.session.displayed_cost_high_water = 0.0;
app.session.displayed_cost_high_water_cny = 0.0;
app.session.last_prompt_tokens = None;
app.session.last_completion_tokens = None;
app.session.last_output_throughput = None;
app.session.last_prompt_cache_hit_tokens = None;
app.session.last_prompt_cache_miss_tokens = None;
app.session.last_reasoning_replay_tokens = None;
app.session.turn_cache_history.clear();
app.current_session_id = Some(session.metadata.id.clone());
app.current_session_metadata = Some(session.metadata.clone());
app.session_title = Some(session.metadata.title.clone());
app.session_artifacts = session.artifacts.clone();
app.system_prompt = session
.system_prompt
.clone()
.map(crate::models::SystemPrompt::Text);
app.scroll_to_bottom();
CommandResult::with_message_and_action(
format!(
"Session loaded from {} (ID: {}, {} messages)",
load_path.display(),
crate::session_manager::truncate_id(&session.metadata.id),
session.metadata.message_count
),
crate::tui::app::AppAction::SyncSession {
session_id: app.current_session_id.clone(),
messages: app.api_messages.clone(),
system_prompt: app.system_prompt.clone(),
model: app.model.clone(),
workspace: app.workspace.clone(),
mode: app.mode,
},
)
// The command layer only validates the file shape. The event loop reloads
// Config once and applies the session plus route atomically before it
// rebuilds or syncs the engine.
// Success is reported only after the event loop re-reads live Config and
// atomically applies the session route. Emitting it here would leave a
// false receipt in the current transcript if that final validation fails.
CommandResult::action(crate::tui::app::AppAction::LoadSession(load_path))
}
/// Trigger context compaction
@@ -615,7 +510,7 @@ fn line_to_string(line: ratatui::text::Line<'static>) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Config, DEFAULT_TEXT_MODEL};
use crate::config::Config;
use crate::test_support::EnvVarGuard;
use crate::tui::app::{App, AppMode, ReasoningEffort, TuiOptions, TurnCacheRecord};
use std::time::Instant;
@@ -736,7 +631,11 @@ mod tests {
.expect("parent saved");
let child = manager.load_session(&new_id).expect("child saved");
assert_eq!(parent.messages.len(), 1);
assert_eq!(parent.metadata.model_provider, "lm-studio");
assert_eq!(parent.metadata.model_provider, "custom");
assert_eq!(
parent.metadata.model_provider_id.as_deref(),
Some("lm-studio")
);
assert_eq!(parent.metadata.title, cached_parent.title);
assert_eq!(parent.metadata.created_at, cached_parent.created_at);
assert_eq!(
@@ -744,7 +643,11 @@ mod tests {
Some("parent-session")
);
assert_eq!(child.metadata.forked_from_message_count, Some(1));
assert_eq!(child.metadata.model_provider, "lm-studio");
assert_eq!(child.metadata.model_provider, "custom");
assert_eq!(
child.metadata.model_provider_id.as_deref(),
Some("lm-studio")
);
let cached_child = app
.current_session_metadata
.as_ref()
@@ -1033,7 +936,7 @@ mod tests {
}
#[test]
fn test_load_valid_session_restores_state() {
fn test_load_valid_session_defers_state_restore_to_event_loop() {
let tmpdir = TempDir::new().unwrap();
let mut app1 = create_test_app_with_tmpdir(&tmpdir);
// Set up some state to save
@@ -1069,28 +972,20 @@ mod tests {
},
});
let result = load(&mut app2, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
let msg = result.message.unwrap();
assert!(msg.contains("Session loaded from"));
assert!(msg.contains("ID:"));
assert!(msg.contains("messages"));
assert_eq!(app2.api_messages.len(), 1);
assert_eq!(app2.session.total_tokens, 500);
assert_eq!(app2.mode, AppMode::Plan);
assert!(app2.current_session_id.is_some());
assert!(app2.system_prompt.is_none());
assert!(app2.session_context_references.is_empty());
assert_eq!(result.message, None);
assert!(app2.api_messages.is_empty());
assert_eq!(app2.session.total_tokens, 0);
assert!(app2.current_session_id.is_none());
assert!(app2.system_prompt.is_some());
assert_eq!(app2.session_context_references.len(), 1);
assert!(matches!(
result.action,
Some(AppAction::SyncSession {
mode: AppMode::Plan,
..
})
Some(AppAction::LoadSession(path)) if path == save_path
));
}
#[test]
fn explicit_save_and_load_round_trip_work_state() {
fn explicit_save_persists_work_state_and_load_defers_application() {
let tmpdir = TempDir::new().unwrap();
let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
{
@@ -1115,10 +1010,15 @@ mod tests {
let mut loaded_app = create_test_app_with_tmpdir(&tmpdir);
let loaded = load(&mut loaded_app, Some(save_path.to_str().unwrap()));
assert!(!loaded.is_error, "{:?}", loaded.message);
assert_eq!(
loaded_app.work_state_snapshot().expect("snapshot"),
expected
);
assert_eq!(loaded_app.work_state_snapshot().expect("snapshot"), None);
assert!(matches!(
loaded.action,
Some(AppAction::LoadSession(path)) if path == save_path
));
let saved_session: crate::session_manager::SavedSession =
serde_json::from_str(&std::fs::read_to_string(&save_path).expect("saved session file"))
.expect("saved session JSON");
assert_eq!(saved_session.work_state, expected);
}
#[test]
@@ -1142,7 +1042,7 @@ mod tests {
}
#[test]
fn load_auto_model_session_restores_auto_mode() {
fn load_auto_model_session_defers_model_restore_to_event_loop() {
let tmpdir = TempDir::new().unwrap();
let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
saved_app.set_model_selection("auto".to_string());
@@ -1157,17 +1057,17 @@ mod tests {
let result = load(&mut app, Some(save_path.to_str().unwrap()));
assert!(!result.is_error);
assert!(app.auto_model);
assert_eq!(app.model, "auto");
assert_eq!(app.model_selection_for_persistence(), "auto");
assert_eq!(app.last_effective_model, None);
assert_eq!(app.last_effective_reasoning_effort, None);
assert_eq!(app.reasoning_effort, ReasoningEffort::Auto);
assert_eq!(app.effective_model_for_budget(), DEFAULT_TEXT_MODEL);
assert!(!app.auto_model);
assert_eq!(app.model, "deepseek-v4-flash");
assert_eq!(app.reasoning_effort, ReasoningEffort::High);
assert!(matches!(
result.action,
Some(AppAction::LoadSession(path)) if path == save_path
));
}
#[test]
fn load_restores_artifact_registry() {
fn load_defers_artifact_registry_restore_to_event_loop() {
let tmpdir = TempDir::new().unwrap();
let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
saved_app
@@ -1203,11 +1103,16 @@ mod tests {
let result = load(&mut app, Some(save_path.to_str().unwrap()));
assert!(!result.is_error);
assert_eq!(app.session_artifacts, saved_app.session_artifacts);
assert_eq!(app.session_artifacts.len(), 1);
assert_eq!(app.session_artifacts[0].id, "art_stale");
assert!(matches!(
result.action,
Some(AppAction::LoadSession(path)) if path == save_path
));
}
#[test]
fn load_resets_cache_history_and_cost() {
fn load_defers_telemetry_reset_to_event_loop() {
let tmpdir = TempDir::new().unwrap();
let mut saved_app = create_test_app_with_tmpdir(&tmpdir);
saved_app.api_messages.push(crate::models::Message {
@@ -1236,6 +1141,7 @@ mod tests {
app.session.last_reasoning_replay_tokens = Some(12);
app.push_turn_cache_record(TurnCacheRecord {
provider: None,
provider_identity: None,
model: None,
auto_model: false,
input_tokens: 120,
@@ -1248,22 +1154,17 @@ mod tests {
let result = load(&mut app, Some(save_path.to_str().unwrap()));
assert!(result.message.is_some());
assert_eq!(app.session.total_tokens, 500);
assert_eq!(app.session.total_conversation_tokens, 500);
assert_eq!(app.session.session_cost, 0.0);
assert_eq!(app.session.session_cost_cny, 0.0);
assert_eq!(app.session.subagent_cost, 0.0);
assert_eq!(app.session.subagent_cost_cny, 0.0);
assert!(app.session.subagent_cost_event_seqs.is_empty());
assert_eq!(app.session.displayed_cost_high_water, 0.0);
assert_eq!(app.session.displayed_cost_high_water_cny, 0.0);
assert_eq!(app.session.last_prompt_tokens, None);
assert_eq!(app.session.last_completion_tokens, None);
assert_eq!(app.session.last_prompt_cache_hit_tokens, None);
assert_eq!(app.session.last_prompt_cache_miss_tokens, None);
assert_eq!(app.session.last_reasoning_replay_tokens, None);
assert!(app.session.turn_cache_history.is_empty());
assert_eq!(result.message, None);
assert_eq!(app.session.total_tokens, 0);
assert_eq!(app.session.session_cost, 1.25);
assert_eq!(app.session.session_cost_cny, 9.13);
assert_eq!(app.session.subagent_cost, 0.75);
assert_eq!(app.session.subagent_cost_cny, 5.48);
assert_eq!(app.session.turn_cache_history.len(), 1);
assert!(matches!(
result.action,
Some(AppAction::LoadSession(path)) if path == save_path
));
}
#[test]
+599 -115
View File
@@ -93,6 +93,18 @@ pub enum ApiProvider {
pub(crate) struct ProviderIdentity {
pub(crate) provider: ApiProvider,
pub(crate) key: String,
/// Additive exact configured provider id written by current persistence
/// schemas. `None` is meaningful: it identifies the released legacy
/// root-level `provider = "custom"` route and must never be upgraded to an
/// exact `[providers.custom]` table merely because one exists later.
pub(crate) exact_id: Option<String>,
}
impl ProviderIdentity {
#[must_use]
pub(crate) fn persisted_id(&self) -> Option<&str> {
self.exact_id.as_deref()
}
}
impl ApiProvider {
@@ -3388,11 +3400,11 @@ impl Config {
}
/// Surface a one-line warning when the user has set the legacy root
/// `base_url` field but their active provider is not DeepSeek (the only
/// provider that actually reads that field, plus an NvidiaNim back-compat
/// sniff). Common confusion: users add `base_url = "..."` at the top of
/// `~/.deepseek/config.toml` for ollama / vllm / openai-compat servers
/// and wonder why it's silently ignored (#1308).
/// `base_url` field but their active provider does not read it. DeepSeek,
/// the NvidiaNim compatibility sniff, and the literal legacy `custom`
/// route are the exceptions. Common confusion: users add a top-level
/// `base_url = "..."` to `~/.deepseek/config.toml` for ollama / vllm /
/// named OpenAI-compatible servers and wonder why it is ignored (#1308).
fn warn_on_misplaced_root_base_url(&self) {
let Some(root_base) = self.base_url.as_deref().map(str::trim) else {
return;
@@ -3409,6 +3421,9 @@ impl Config {
{
return;
}
if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
return;
}
// Only warn if the per-provider table doesn't have an explicit
// `base_url`, because if it does, the per-provider one wins and the
// root field is just dead config — no behavior surprise.
@@ -3515,13 +3530,10 @@ impl Config {
#[must_use]
pub fn api_provider(&self) -> ApiProvider {
if let Some(provider) = self.provider.as_deref().and_then(ApiProvider::parse) {
return provider;
}
// #1519 safety fix: when `provider = "<name>"` is not a built-in provider
// but names a `[providers.<name>]` custom table, route as the dynamic
// custom identity. This MUST precede the DeepSeek fallback below so an
// arbitrary custom name can never silently misroute to DeepSeek.
// custom identity. Exact configured keys win even when their spelling
// collides case-insensitively with a built-in slug.
if let Some(name) = self.provider.as_deref()
&& self
.providers
@@ -3531,6 +3543,9 @@ impl Config {
{
return ApiProvider::Custom;
}
if let Some(provider) = self.provider.as_deref().and_then(ApiProvider::parse) {
return provider;
}
self.base_url
.as_deref()
.filter(|base| base.contains("integrate.api.nvidia.com"))
@@ -3553,21 +3568,35 @@ impl Config {
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty())
&& ApiProvider::parse(name).is_none()
&& (self
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(name))
.is_some()
|| ApiProvider::parse(name).is_none())
{
return name.to_string();
}
provider.as_str().to_string()
}
/// Resolve the currently selected live route while retaining whether the
/// literal custom key came from the legacy root fields or an exact table.
pub(crate) fn active_provider_identity(
&self,
provider: ApiProvider,
) -> std::result::Result<ProviderIdentity, String> {
self.resolve_provider_identity(&self.provider_identity_for(provider))
}
/// Resolve a persisted provider key against the current live config.
///
/// Named custom providers are exact and fail closed: a removed, renamed,
/// or malformed table can never fall through to DeepSeek or whichever
/// provider happens to be selected now. The literal legacy value `custom`
/// remains loadable only when the live config already selects one valid
/// named custom table; older records did not retain enough information to
/// do anything safer.
/// remains loadable only for the old root-field config shape where the live
/// provider is also literally `custom` and both `base_url` and
/// `default_text_model` identify one valid route.
pub(crate) fn resolve_provider_identity(
&self,
persisted: &str,
@@ -3580,27 +3609,81 @@ impl Config {
);
}
if let Some(provider) = ApiProvider::parse(key)
let has_exact_custom_table = self
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(key))
.is_some();
if !has_exact_custom_table
&& let Some(provider) = ApiProvider::parse(key)
&& provider != ApiProvider::Custom
{
return Ok(ProviderIdentity {
provider,
key: provider.as_str().to_string(),
exact_id: Some(provider.as_str().to_string()),
});
}
let exact_key = if key.eq_ignore_ascii_case(ApiProvider::Custom.as_str()) {
self.provider
.as_deref()
.map(str::trim)
.filter(|name| !name.is_empty() && ApiProvider::parse(name).is_none())
.ok_or_else(|| {
"legacy session records only the generic `custom` provider kind. Set `provider = \"<name>\"` to the original `[providers.<name>]` table and retry; CodeWhale will not guess or fall back"
.to_string()
})?
} else {
key
};
if !has_exact_custom_table && key.eq_ignore_ascii_case(ApiProvider::Custom.as_str()) {
if self.selects_literal_custom_provider() {
// The historical literal `provider = "custom"` can mean
// either the legacy root-field route or an exact
// `[providers.custom]` table. Prefer the table when it exists;
// otherwise validate the legacy root shape. This keeps old
// save/resume records deterministic without treating the
// literal key as a wildcard for some other named provider.
if !has_exact_custom_table {
self.validate_legacy_literal_custom_route()?;
return Ok(ProviderIdentity {
provider: ApiProvider::Custom,
key: ApiProvider::Custom.as_str().to_string(),
exact_id: None,
});
}
}
// Pre-exact releases persisted every named custom route as the
// generic literal `custom`. Migrate that record only when the live
// config selects the sole valid named custom table; otherwise the
// old value is genuinely ambiguous and must fail closed.
if !self.selects_literal_custom_provider() {
let selected = self.provider.as_deref().map(str::trim).unwrap_or_default();
let valid_named = self
.providers
.as_ref()
.map(|providers| {
providers
.custom
.keys()
.filter(|name| {
!name.eq_ignore_ascii_case(ApiProvider::Custom.as_str())
&& ApiProvider::parse(name).is_none()
&& self.resolve_provider_identity(name).is_ok()
})
.cloned()
.collect::<Vec<_>>()
})
.unwrap_or_default();
if let [name] = valid_named.as_slice()
&& selected == name
{
return self.resolve_provider_identity(name);
}
return Err(format!(
"legacy session records only the generic `custom` provider kind, but the live config does not select exactly one valid named custom route (selected '{}', valid named routes: {}). Restore the original single `[providers.<name>]` route or repair the saved provider identity; CodeWhale will not guess or fall back",
if selected.is_empty() {
"<unset>"
} else {
selected
},
valid_named.len()
));
}
}
let exact_key = key;
let entry = self
.providers
@@ -3640,9 +3723,253 @@ impl Config {
Ok(ProviderIdentity {
provider: ApiProvider::Custom,
key: exact_key.to_string(),
exact_id: Some(exact_key.to_string()),
})
}
/// Resolve an additive exact provider id. Unlike raw selector resolution,
/// this never interprets the literal id `custom` as the legacy root route:
/// an id means the record requires that exact `[providers.<id>]` table.
fn resolve_exact_provider_identity(
&self,
persisted: &str,
) -> std::result::Result<ProviderIdentity, String> {
let id = persisted.trim();
if id.is_empty() {
return Err(
"persisted provider route has an empty exact provider id; CodeWhale will not guess or fall back"
.to_string(),
);
}
let has_exact_custom_table = self
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(id))
.is_some();
if id.eq_ignore_ascii_case(ApiProvider::Custom.as_str()) && !has_exact_custom_table {
return Err(format!(
"persisted provider route requires exact custom provider '{id}', but `[providers.{id}]` is missing from the live config. Restore that exact table and retry; CodeWhale will not fall back"
));
}
let identity = self.resolve_provider_identity(id)?;
if identity.provider == ApiProvider::Custom && identity.persisted_id() != Some(id) {
return Err(format!(
"persisted provider route requires exact custom provider '{id}', but the live config only provides the legacy root-level custom route. Restore `[providers.{id}]` and retry; CodeWhale will not fall back"
));
}
Ok(identity)
}
/// Resolve the two-field provider route written by current session/thread
/// schemas without erasing which field supplied the identity.
///
/// `provider_kind` is the generic wire/provider class (`custom` for every
/// named OpenAI-compatible endpoint); `provider_id` is the additive exact
/// configured key. Older records have no id and may have overloaded the
/// kind field with an exact custom name. Keeping those cases distinct is
/// security-sensitive: a legacy built-in record must never be captured by
/// a later same-key custom table, while a current `custom` + exact-id pair
/// must retain that user-owned table identity.
pub(crate) fn resolve_persisted_provider_identity(
&self,
provider_kind: Option<&str>,
provider_id: Option<&str>,
) -> std::result::Result<ProviderIdentity, String> {
let kind = provider_kind
.map(str::trim)
.filter(|value| !value.is_empty());
let id = provider_id.map(str::trim).filter(|value| !value.is_empty());
let Some(kind) = kind else {
return id.map_or_else(
|| {
Err(
"persisted provider route has neither a provider kind nor an exact provider id; CodeWhale will not guess or fall back"
.to_string(),
)
},
|id| self.resolve_exact_provider_identity(id),
);
};
let Some(provider) = ApiProvider::parse(kind) else {
// Pre-additive releases sometimes wrote an exact named custom key
// into `model_provider`. Preserve that shape, but reject a
// contradictory additive id instead of silently choosing one.
if let Some(id) = id
&& id != kind
{
return Err(format!(
"persisted provider route has legacy identity '{kind}' but exact provider id '{id}'; repair the mismatched fields because CodeWhale will not guess or fall back"
));
}
return match id {
Some(id) => self.resolve_exact_provider_identity(id),
None => self.resolve_provider_identity(kind),
};
};
if provider == ApiProvider::Custom {
if let Some(id) = id {
let identity = self.resolve_exact_provider_identity(id)?;
if identity.provider != ApiProvider::Custom {
return Err(format!(
"persisted provider route declares generic kind 'custom' but exact provider id '{id}' resolves as built-in '{}'; use the matching built-in kind or restore `[providers.{id}]`. CodeWhale will not guess or fall back",
identity.provider.as_str()
));
}
return Ok(identity);
}
// The absence of the additive id is itself provenance. Released
// id-less `custom` records belong to the root-level route only;
// they must not be captured by a table added under the same key.
self.validate_legacy_literal_custom_root_route()?;
return Ok(ProviderIdentity {
provider: ApiProvider::Custom,
key: ApiProvider::Custom.as_str().to_string(),
exact_id: None,
});
}
if let Some(id) = id
&& ApiProvider::parse(id) != Some(provider)
{
return Err(format!(
"persisted provider route declares built-in kind '{}' but exact provider id '{id}' names a different route; repair the mismatched fields because CodeWhale will not guess or fall back",
provider.as_str()
));
}
// Exact custom keys normally win raw string resolution. A persisted
// built-in kind is stronger evidence than that raw key, but Config's
// single selector cannot represent both routes simultaneously. Fail
// closed instead of constructing a descriptor whose client would read
// credentials/settings from the shadowing custom table.
if self
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(provider.as_str()))
.is_some()
{
return Err(format!(
"persisted provider route requires built-in '{}', but an exact `[providers.{}]` custom route shadows the same selector. Rename the custom route or update the saved provider kind/id pair; CodeWhale will not guess or fall back",
provider.as_str(),
provider.as_str()
));
}
Ok(ProviderIdentity {
provider,
key: provider.as_str().to_string(),
exact_id: Some(provider.as_str().to_string()),
})
}
/// Scope a cloned runtime config to one already-resolved identity. This is
/// required only for the root-literal custom route: when a later
/// `[providers.custom]` table coexists, ordinary selector lookup would
/// otherwise capture the table. Removing it from the scoped clone keeps
/// the root endpoint authoritative without mutating the live registry.
pub(crate) fn scope_to_provider_identity(&mut self, identity: &ProviderIdentity) {
self.provider = Some(identity.key.clone());
if identity.provider == ApiProvider::Custom
&& identity.persisted_id().is_none()
&& let Some(providers) = self.providers.as_mut()
{
providers.custom.retain(|name, _| {
!name
.trim()
.eq_ignore_ascii_case(ApiProvider::Custom.as_str())
});
}
}
fn validate_legacy_literal_custom_route(&self) -> std::result::Result<(), String> {
if self.has_literal_custom_provider_table() {
return Err(
"legacy `provider = \"custom\"` is ambiguous because `[providers.custom]` is also present. Move the route to one named `[providers.<name>]` table and update the saved provider identity; CodeWhale will not guess or fall back"
.to_string(),
);
}
self.validate_legacy_literal_custom_root_route()
}
fn validate_legacy_literal_custom_root_route(&self) -> std::result::Result<(), String> {
let selected = self.provider.as_deref().map(str::trim).unwrap_or_default();
if !self.selects_literal_custom_provider() {
return Err(format!(
"legacy session records only the generic `custom` provider kind, but the live config selects '{}'. Only an unchanged legacy config with `provider = \"custom\"` and root-level `base_url`/`default_text_model` can load this session; CodeWhale will not guess or fall back",
if selected.is_empty() {
"<unset>"
} else {
selected
}
));
}
let base_url = self
.base_url
.as_deref()
.map(str::trim)
.filter(|base_url| !base_url.is_empty())
.ok_or_else(|| {
"legacy `provider = \"custom\"` requires a non-empty root-level `base_url` to load a saved session; CodeWhale will not use the custom-provider placeholder or fall back"
.to_string()
})?;
let parsed = reqwest::Url::parse(base_url).map_err(|err| {
format!(
"legacy `provider = \"custom\"` has an invalid root-level `base_url`: {err}. Fix the live config and retry; CodeWhale will not fall back"
)
})?;
if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
return Err(
"legacy `provider = \"custom\"` requires a root-level `base_url` with an http(s) scheme and host; CodeWhale will not fall back"
.to_string(),
);
}
let model = self
.default_text_model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
.ok_or_else(|| {
"legacy `provider = \"custom\"` requires a non-empty root-level `default_text_model` to load a saved session; CodeWhale will not guess or fall back"
.to_string()
})?;
if model.eq_ignore_ascii_case("auto") || normalize_custom_model_id(model).is_none() {
return Err(
"legacy `provider = \"custom\"` requires one explicit, valid root-level `default_text_model` (not `auto`) to load a saved session; CodeWhale will not guess or fall back"
.to_string(),
);
}
Ok(())
}
fn selects_literal_custom_provider(&self) -> bool {
self.provider
.as_deref()
.map(str::trim)
.is_some_and(|name| name.eq_ignore_ascii_case(ApiProvider::Custom.as_str()))
}
fn has_literal_custom_provider_table(&self) -> bool {
self.providers.as_ref().is_some_and(|providers| {
providers.custom.keys().any(|name| {
name.trim()
.eq_ignore_ascii_case(ApiProvider::Custom.as_str())
})
})
}
pub(crate) fn uses_legacy_literal_custom_route(&self) -> bool {
self.selects_literal_custom_provider() && !self.has_literal_custom_provider_table()
}
pub(crate) fn provider_config_for(&self, provider: ApiProvider) -> Option<&ProviderConfig> {
let providers = self.providers.as_ref()?;
// The custom provider's config lives in the flatten map, keyed by the
@@ -3759,6 +4086,72 @@ impl Config {
}
}
/// Apply a runtime model override without migrating a released
/// root-literal custom route into an ambiguous `[providers.custom]` table.
pub(crate) fn set_provider_model_override(
&mut self,
provider: ApiProvider,
model: Option<String>,
) {
if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
self.default_text_model = model;
} else {
self.provider_config_for_mut(provider).model = model;
}
}
/// Apply a runtime endpoint override while preserving the storage shape of
/// a released root-literal custom route.
pub(crate) fn set_provider_base_url_override(
&mut self,
provider: ApiProvider,
base_url: Option<String>,
) {
if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
self.base_url = base_url;
} else {
self.provider_config_for_mut(provider).base_url = base_url;
}
}
/// Apply an in-memory credential update without creating a named custom
/// table for the legacy root-literal route.
pub(crate) fn set_provider_api_key_override(
&mut self,
provider: ApiProvider,
api_key: Option<String>,
) {
if provider == ApiProvider::Custom && self.uses_legacy_literal_custom_route() {
self.api_key = api_key;
} else {
self.provider_config_for_mut(provider).api_key = api_key;
}
}
/// Refresh only model-provider route material from a newly loaded disk
/// snapshot. The receiver is the already-effective interactive Config,
/// including CLI feature toggles and workspace/project permission overlays;
/// replacing it wholesale during `/load` could silently loosen those
/// controls. Provider tables carry their endpoint, auth, headers, TLS,
/// model-passthrough, and per-route limits as one atomic registry.
pub(crate) fn refresh_provider_routes_from(&mut self, fresh: &Self) {
self.provider.clone_from(&fresh.provider);
self.api_key.clone_from(&fresh.api_key);
self.base_url.clone_from(&fresh.base_url);
self.http_headers.clone_from(&fresh.http_headers);
self.default_text_model
.clone_from(&fresh.default_text_model);
self.auth_mode.clone_from(&fresh.auth_mode);
self.fallback_providers
.clone_from(&fresh.fallback_providers);
self.retry.clone_from(&fresh.retry);
self.providers.clone_from(&fresh.providers);
self.reasoning_effort_inferred_from_legacy_alias =
fresh.reasoning_effort_inferred_from_legacy_alias;
self.migrated_deepseek_model_alias
.clone_from(&fresh.migrated_deepseek_model_alias);
}
/// Return the configured provider request concurrency cap.
///
/// `None` means the client does not apply an extra in-flight request
@@ -4003,10 +4396,10 @@ impl Config {
let provider = self.api_provider();
let provider_base = self
.provider_config_string_with_runtime_fallback(provider, |entry| entry.base_url.clone());
// Root `base_url` is the legacy DeepSeek field; only NvidiaNim has a
// back-compat sniff (integrate.api.nvidia.com). OpenRouter / Novita
// were added in v0.6.7 and require explicit `[providers.<name>]`
// entries or the corresponding `*_BASE_URL` env var.
// Root `base_url` is normally the legacy DeepSeek field. NvidiaNim has
// a back-compat sniff (integrate.api.nvidia.com), and the literal
// `provider = "custom"` legacy shape retains its root endpoint. Named
// custom providers always read their own `[providers.<name>]` table.
let root_base = match provider {
ApiProvider::Deepseek | ApiProvider::DeepseekCN => self.base_url.clone(),
ApiProvider::DeepseekAnthropic => None,
@@ -4044,10 +4437,10 @@ impl Config {
| ApiProvider::Sakana
| ApiProvider::LongCat
| ApiProvider::Meta
| ApiProvider::Xai
// Custom reads its base_url from the named `[providers.<name>]`
// table (via provider_base), never from the legacy root field.
| ApiProvider::Custom => None,
| ApiProvider::Xai => None,
ApiProvider::Custom if self.uses_legacy_literal_custom_route() => self.base_url.clone(),
// Named custom routes read their base URL from `provider_base`.
ApiProvider::Custom => None,
};
let configured_base_url = provider_base.or(root_base);
let base = if provider == ApiProvider::XiaomiMimo {
@@ -4160,9 +4553,10 @@ impl Config {
let provider = self.api_provider();
let explicit_cli_key = explicit_cli_api_key_override();
// 0. DeepSeek compatibility slot. The legacy top-level `api_key`
// belongs to DeepSeek only; provider-specific keys below must win for
// NIM/OpenRouter/etc. so a stale DeepSeek key is not sent elsewhere.
// 0. Legacy root compatibility slot. The top-level `api_key` belongs
// to DeepSeek, plus the literal root-field `provider = "custom"`
// compatibility route. Provider-specific keys below must win for all
// named/custom-table routes so a stale root key is not sent elsewhere.
//
// However, when the CLI dispatcher forwards an explicit `--api-key`
// through `DEEPSEEK_API_KEY` with the dispatcher source marker, that
@@ -4232,6 +4626,14 @@ impl Config {
{
return Ok(configured);
}
if provider == ApiProvider::Custom
&& self.uses_legacy_literal_custom_route()
&& let Some(configured) = self.api_key.as_ref()
&& !configured.trim().is_empty()
&& configured != API_KEYRING_SENTINEL
{
return Ok(configured.clone());
}
// 1b. Custom providers (#1519) name their auth env var per-entry via
// `[providers.<name>] api_key_env = "..."`. Resolve it before the
@@ -5340,9 +5742,10 @@ fn apply_env_overrides(config: &mut Config) {
.base_url = Some(value);
}
// Custom resolves to the named `[providers.<name>]` table; route the
// override through the name-keyed mutable accessor (#1519).
// override through the exact route while retaining the released
// root-literal custom storage shape (#1519, #4334).
ApiProvider::Custom => {
config.provider_config_for_mut(ApiProvider::Custom).base_url = Some(value);
config.set_provider_base_url_override(ApiProvider::Custom, Some(value));
}
}
}
@@ -5546,60 +5949,65 @@ fn apply_env_overrides(config: &mut Config) {
config.http_headers = Some(root_headers);
let provider = config.api_provider();
// Capture the custom entry key (the selected provider name) before the
// mutable borrow of `providers` below (#1519).
let custom_key = (provider == ApiProvider::Custom).then(|| {
config
.provider
.clone()
.unwrap_or_else(|| "__custom__".to_string())
});
let providers = config
.providers
.get_or_insert_with(ProvidersConfig::default);
let entry = match provider {
ApiProvider::Deepseek => &mut providers.deepseek,
ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic,
ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
ApiProvider::Openai => &mut providers.openai,
ApiProvider::Atlascloud => &mut providers.atlascloud,
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
ApiProvider::Openrouter => &mut providers.openrouter,
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
ApiProvider::Novita => &mut providers.novita,
ApiProvider::Fireworks => &mut providers.fireworks,
ApiProvider::Siliconflow => &mut providers.siliconflow,
ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
ApiProvider::Arcee => &mut providers.arcee,
ApiProvider::Moonshot => &mut providers.moonshot,
ApiProvider::Sglang => &mut providers.sglang,
ApiProvider::Vllm => &mut providers.vllm,
ApiProvider::Ollama => &mut providers.ollama,
ApiProvider::Volcengine => &mut providers.volcengine,
ApiProvider::Huggingface => &mut providers.huggingface,
ApiProvider::Deepinfra => &mut providers.deepinfra,
ApiProvider::Together => &mut providers.together,
ApiProvider::Qianfan => &mut providers.qianfan,
ApiProvider::OpenaiCodex => &mut providers.openai_codex,
ApiProvider::Anthropic => &mut providers.anthropic,
ApiProvider::Openmodel => &mut providers.openmodel,
ApiProvider::Zai => &mut providers.zai,
ApiProvider::Stepfun => &mut providers.stepfun,
ApiProvider::Minimax => &mut providers.minimax,
ApiProvider::MinimaxAnthropic => &mut providers.minimax_anthropic,
ApiProvider::Sakana => &mut providers.sakana,
ApiProvider::LongCat => &mut providers.longcat,
ApiProvider::Meta => &mut providers.meta,
ApiProvider::Xai => &mut providers.xai,
ApiProvider::Custom => providers
.custom
.entry(custom_key.expect("custom key captured for custom provider"))
.or_default(),
};
let mut provider_headers = entry.http_headers.clone().unwrap_or_default();
provider_headers.extend(headers);
entry.http_headers = Some(provider_headers);
// Root headers are the canonical header slot for a released literal
// custom route. Creating `[providers.custom]` here would make the route
// ambiguous and disconnect its root endpoint, model, and credential.
if !(provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route()) {
// Capture the custom entry key (the selected provider name) before
// the mutable borrow of `providers` below (#1519).
let custom_key = (provider == ApiProvider::Custom).then(|| {
config
.provider
.clone()
.unwrap_or_else(|| "__custom__".to_string())
});
let providers = config
.providers
.get_or_insert_with(ProvidersConfig::default);
let entry = match provider {
ApiProvider::Deepseek => &mut providers.deepseek,
ApiProvider::DeepseekCN => &mut providers.deepseek_cn,
ApiProvider::DeepseekAnthropic => &mut providers.deepseek_anthropic,
ApiProvider::NvidiaNim => &mut providers.nvidia_nim,
ApiProvider::Openai => &mut providers.openai,
ApiProvider::Atlascloud => &mut providers.atlascloud,
ApiProvider::WanjieArk => &mut providers.wanjie_ark,
ApiProvider::Openrouter => &mut providers.openrouter,
ApiProvider::XiaomiMimo => &mut providers.xiaomi_mimo,
ApiProvider::Novita => &mut providers.novita,
ApiProvider::Fireworks => &mut providers.fireworks,
ApiProvider::Siliconflow => &mut providers.siliconflow,
ApiProvider::SiliconflowCn => &mut providers.siliconflow_cn,
ApiProvider::Arcee => &mut providers.arcee,
ApiProvider::Moonshot => &mut providers.moonshot,
ApiProvider::Sglang => &mut providers.sglang,
ApiProvider::Vllm => &mut providers.vllm,
ApiProvider::Ollama => &mut providers.ollama,
ApiProvider::Volcengine => &mut providers.volcengine,
ApiProvider::Huggingface => &mut providers.huggingface,
ApiProvider::Deepinfra => &mut providers.deepinfra,
ApiProvider::Together => &mut providers.together,
ApiProvider::Qianfan => &mut providers.qianfan,
ApiProvider::OpenaiCodex => &mut providers.openai_codex,
ApiProvider::Anthropic => &mut providers.anthropic,
ApiProvider::Openmodel => &mut providers.openmodel,
ApiProvider::Zai => &mut providers.zai,
ApiProvider::Stepfun => &mut providers.stepfun,
ApiProvider::Minimax => &mut providers.minimax,
ApiProvider::MinimaxAnthropic => &mut providers.minimax_anthropic,
ApiProvider::Sakana => &mut providers.sakana,
ApiProvider::LongCat => &mut providers.longcat,
ApiProvider::Meta => &mut providers.meta,
ApiProvider::Xai => &mut providers.xai,
ApiProvider::Custom => providers
.custom
.entry(custom_key.expect("custom key captured for custom provider"))
.or_default(),
};
let mut provider_headers = entry.http_headers.clone().unwrap_or_default();
provider_headers.extend(headers);
entry.http_headers = Some(provider_headers);
}
}
if matches!(config.api_provider(), ApiProvider::Ollama)
&& let Ok(value) = std::env::var("OLLAMA_BASE_URL")
@@ -5782,19 +6190,21 @@ fn apply_env_overrides(config: &mut Config) {
// (issue #1714). Mirror the OPENAI_MODEL branch above for every
// non-DeepSeek provider.
let provider = config.api_provider();
// Capture the custom entry key before the mutable borrow below (#1519).
let custom_key = (provider == ApiProvider::Custom).then(|| {
config
.provider
.clone()
.unwrap_or_else(|| "__custom__".to_string())
});
if matches!(
provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
) {
if (provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route())
|| matches!(
provider,
ApiProvider::Deepseek | ApiProvider::DeepseekCN | ApiProvider::DeepseekAnthropic
)
{
config.default_text_model = Some(value);
} else {
// Capture the custom entry key before the mutable borrow below (#1519).
let custom_key = (provider == ApiProvider::Custom).then(|| {
config
.provider
.clone()
.unwrap_or_else(|| "__custom__".to_string())
});
let providers = config
.providers
.get_or_insert_with(ProvidersConfig::default);
@@ -7225,7 +7635,8 @@ pub fn has_api_key_for(config: &Config, provider: ApiProvider) -> bool {
return true;
}
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
if (matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
|| (provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route()))
&& config
.api_key
.as_ref()
@@ -7329,12 +7740,37 @@ fn provider_config_is_explicit(entry: &ProviderConfig) -> bool {
/// `[providers.<name>] api_key = "..."` to `~/.codewhale/config.toml`.
/// Returns the config file path.
pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf> {
save_api_key_for_identity(
&ProviderIdentity {
provider,
key: provider.as_str().to_string(),
exact_id: Some(provider.as_str().to_string()),
},
&Config {
provider: Some(provider.as_str().to_string()),
..Config::default()
},
api_key,
)
}
pub(crate) fn save_api_key_for_identity(
identity: &ProviderIdentity,
_route_config: &Config,
api_key: &str,
) -> Result<PathBuf> {
let provider = identity.provider;
if provider == ApiProvider::OpenaiCodex {
anyhow::bail!(
"OpenAI Codex uses OAuth. Run `codex login` or set OPENAI_CODEX_ACCESS_TOKEN; CodeWhale does not store an API key for this provider."
);
}
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
let is_legacy_literal_custom = provider == ApiProvider::Custom
&& identity.key.trim() == ApiProvider::Custom.as_str()
&& identity.persisted_id().is_none();
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
|| is_legacy_literal_custom
{
return match save_api_key(api_key)? {
SavedCredential::KeyringAndConfigFile { path, .. }
| SavedCredential::ConfigFile(path) => Ok(path),
@@ -7345,7 +7781,13 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
let key_inside = provider_config_key(provider).context("provider api key table")?;
let key_inside = if provider == ApiProvider::Custom {
let key = identity.key.trim();
anyhow::ensure!(!key.is_empty(), "custom provider id cannot be empty");
key
} else {
provider_config_key(provider).context("provider api key table")?
};
// Edit the `[providers.<name>]` table in place so unrelated sections,
// comments, and formatting survive the write.
crate::config_persistence::mutate_config_document(&config_path, |doc| {
@@ -7360,7 +7802,7 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
"credential.save",
json!({
"backend": "config_file",
"provider": provider.as_str(),
"provider": identity.key,
"config_path": config_path.display().to_string(),
}),
);
@@ -7371,7 +7813,12 @@ pub fn save_api_key_for(provider: ApiProvider, api_key: &str) -> Result<PathBuf>
/// Persist a default model for `provider` via the comment-preserving config
/// path used by guided provider setup (#3875). DeepSeek writes root
/// `default_text_model`; other hosted providers write `[providers.<name>] model`.
pub fn save_provider_model_for(provider: ApiProvider, model: &str) -> Result<PathBuf> {
pub(crate) fn save_provider_model_for_identity(
identity: &ProviderIdentity,
_route_config: &Config,
model: &str,
) -> Result<PathBuf> {
let provider = identity.provider;
let model = model.trim();
anyhow::ensure!(!model.is_empty(), "model cannot be empty");
@@ -7379,7 +7826,12 @@ pub fn save_provider_model_for(provider: ApiProvider, model: &str) -> Result<Pat
.context("Failed to resolve config path: home directory not found.")?;
ensure_parent_dir(&config_path)?;
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN) {
let is_legacy_literal_custom = provider == ApiProvider::Custom
&& identity.key.trim() == ApiProvider::Custom.as_str()
&& identity.persisted_id().is_none();
if matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
|| is_legacy_literal_custom
{
crate::config_persistence::mutate_config_document(&config_path, |doc| {
crate::config_persistence::set_document_value(doc, &["default_text_model"], model)
})
@@ -7387,7 +7839,13 @@ pub fn save_provider_model_for(provider: ApiProvider, model: &str) -> Result<Pat
return Ok(config_path);
}
let key_inside = provider_config_key(provider).context("provider model table")?;
let key_inside = if provider == ApiProvider::Custom {
let key = identity.key.trim();
anyhow::ensure!(!key.is_empty(), "custom provider id cannot be empty");
key
} else {
provider_config_key(provider).context("provider model table")?
};
crate::config_persistence::mutate_config_document(&config_path, |doc| {
crate::config_persistence::set_document_value(
doc,
@@ -7734,12 +8192,38 @@ pub fn clear_active_provider_api_key(provider: &str) -> Result<()> {
return Ok(());
}
// `custom` is both the legacy root-shaped route id and a valid exact
// `[providers.custom]` table key. Inspect the persisted shape before the
// mutation so logout clears exactly one credential scope.
let persisted = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config from {}", config_path.display()))?;
let persisted_config: Config = toml::from_str(&persisted)
.with_context(|| format!("Failed to parse config from {}", config_path.display()))?;
let exact_literal_custom_table = provider == ApiProvider::Custom.as_str()
&& persisted_config
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(provider))
.is_some();
crate::config_persistence::mutate_config_document(&config_path, |doc| {
// The root-level api_key is the legacy DeepSeek slot.
if provider == "deepseek" {
// The root-level api_key is shared by the legacy DeepSeek and released
// literal-custom config shapes. Exact named custom ids remain scoped
// to their own table.
if matches!(
provider,
value if value == ApiProvider::Deepseek.as_str()
|| value == ApiProvider::DeepseekCN.as_str()
) || (provider == ApiProvider::Custom.as_str() && !exact_literal_custom_table)
{
crate::config_persistence::unset_document_value(doc, &["api_key"])?;
}
crate::config_persistence::unset_document_value(doc, &["providers", provider, "api_key"])?;
if provider != ApiProvider::Custom.as_str() || exact_literal_custom_table {
crate::config_persistence::unset_document_value(
doc,
&["providers", provider, "api_key"],
)?;
}
Ok(())
})
.with_context(|| format!("Failed to write config to {}", config_path.display()))?;
+466 -4
View File
@@ -2929,6 +2929,131 @@ base_url = "https://openrouter.ai/api/v1"
Ok(())
}
#[test]
fn clear_active_provider_api_key_clears_deepseek_cn_root_scope() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-clear-deepseek-cn-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap())?;
fs::write(
&config_path,
r#"provider = "deepseek-cn"
api_key = "deepseek-cn-root-key"
[providers.deepseek-cn]
api_key = "deepseek-cn-table-key"
[providers.openrouter]
api_key = "unrelated-key"
"#,
)?;
clear_active_provider_api_key("deepseek-cn")?;
let after = fs::read_to_string(&config_path)?;
assert!(!after.contains("deepseek-cn-root-key"), "{after}");
assert!(!after.contains("deepseek-cn-table-key"), "{after}");
assert!(after.contains("unrelated-key"), "{after}");
Ok(())
}
#[test]
fn clear_active_provider_api_key_distinguishes_literal_and_named_custom_routes() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-clear-custom-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap())?;
let contents = r#"provider = "custom"
api_key = "legacy-root-key"
base_url = "http://127.0.0.1:1234/v1"
default_text_model = "legacy-model"
[providers.lm-studio]
kind = "openai-compatible"
api_key = "named-route-key"
base_url = "http://127.0.0.1:5678/v1"
model = "named-model"
"#;
fs::write(&config_path, contents)?;
clear_active_provider_api_key("custom")?;
let after_literal = fs::read_to_string(&config_path)?;
assert!(
!after_literal.contains("legacy-root-key"),
"{after_literal}"
);
assert!(after_literal.contains("named-route-key"), "{after_literal}");
fs::write(&config_path, contents)?;
clear_active_provider_api_key("lm-studio")?;
let after_named = fs::read_to_string(&config_path)?;
assert!(after_named.contains("legacy-root-key"), "{after_named}");
assert!(!after_named.contains("named-route-key"), "{after_named}");
Ok(())
}
#[test]
fn clear_active_provider_api_key_prefers_exact_custom_table_over_legacy_root() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-clear-exact-custom-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let config_path = temp_root.join(".deepseek").join("config.toml");
fs::create_dir_all(config_path.parent().unwrap())?;
fs::write(
&config_path,
r#"provider = "custom"
api_key = "legacy-root-key"
base_url = "http://127.0.0.1:1234/v1"
default_text_model = "legacy-model"
[providers.custom]
kind = "openai-compatible"
api_key = "exact-table-key"
base_url = "http://127.0.0.1:5678/v1"
model = "exact-model"
"#,
)?;
clear_active_provider_api_key("custom")?;
let after = fs::read_to_string(&config_path)?;
assert!(after.contains("legacy-root-key"), "{after}");
assert!(!after.contains("exact-table-key"), "{after}");
assert!(after.contains("[providers.custom]"), "{after}");
Ok(())
}
/// Finding #19: workspace-trust saves used to round-trip through
/// `toml::to_string_pretty`, destroying comments in the whole file.
#[test]
@@ -8029,6 +8154,7 @@ fn session_provider_identity_preserves_exact_named_custom_key() {
ProviderIdentity {
provider: ApiProvider::Custom,
key: "lm-studio".to_string(),
exact_id: Some("lm-studio".to_string()),
}
);
assert_eq!(
@@ -8038,15 +8164,351 @@ fn session_provider_identity_preserves_exact_named_custom_key() {
ProviderIdentity {
provider: ApiProvider::Openrouter,
key: "openrouter".to_string(),
exact_id: Some("openrouter".to_string()),
}
);
let migrated = config
.resolve_provider_identity("custom")
.expect("released generic custom record migrates to sole live named route");
assert_eq!(
migrated,
ProviderIdentity {
provider: ApiProvider::Custom,
key: "lm-studio".to_string(),
exact_id: Some("lm-studio".to_string()),
}
);
}
#[test]
fn literal_custom_table_round_trips_as_exact_historical_route() {
let config =
session_custom_provider_config("custom", "openai-compatible", "http://127.0.0.1:1234/v1");
assert_eq!(config.api_provider(), ApiProvider::Custom);
assert!(!config.uses_legacy_literal_custom_route());
let identity = config
.resolve_provider_identity("custom")
.expect("exact [providers.custom] identity");
assert_eq!(identity.key, "custom");
let route = crate::route_runtime::resolve_runtime_route(
&config,
identity.provider,
Some("local-model"),
)
.expect("resolve exact literal table")
.validate()
.expect("preflight exact literal table");
assert_eq!(route.identity.key, "custom");
assert_eq!(route.client.base_url(), "http://127.0.0.1:1234/v1");
assert_eq!(
route
.config
.resolve_provider_identity(&route.identity.key)
.expect("repeat exact literal table resolution"),
identity
);
}
#[test]
fn persisted_custom_fields_distinguish_legacy_root_from_exact_literal_table() {
let table_only =
session_custom_provider_config("custom", "openai-compatible", "http://127.0.0.1:1234/v1");
let table_only_error = table_only
.resolve_persisted_provider_identity(Some("custom"), None)
.expect_err("id-less custom records authorize only the legacy root route");
assert!(
table_only_error.contains("root-level"),
"{table_only_error}"
);
assert!(table_only_error.contains("fall back"), "{table_only_error}");
let mut coexist = table_only.clone();
coexist.base_url = Some("http://127.0.0.1:18180/v1".to_string());
coexist.default_text_model = Some("legacy-root-model".to_string());
let root = coexist
.resolve_persisted_provider_identity(Some("custom"), None)
.expect("id-less record remains bound to the root route");
assert_eq!(root.provider, ApiProvider::Custom);
assert_eq!(root.key, "custom");
assert_eq!(root.exact_id, None);
let root_route = crate::route_runtime::resolve_runtime_route_for_identity(
&coexist,
&root,
Some("legacy-root-model"),
)
.expect("scope root identity")
.validate()
.expect("validate root identity");
assert_eq!(root_route.client.base_url(), "http://127.0.0.1:18180/v1");
assert_eq!(root_route.identity.exact_id, None);
let exact_table = coexist
.resolve_persisted_provider_identity(Some("custom"), Some("custom"))
.expect("additive exact id intentionally selects the table");
assert_eq!(exact_table.provider, ApiProvider::Custom);
assert_eq!(exact_table.key, "custom");
assert_eq!(exact_table.exact_id.as_deref(), Some("custom"));
let root_only = Config {
provider: Some("custom".to_string()),
base_url: Some("http://127.0.0.1:18180/v1".to_string()),
default_text_model: Some("legacy-root-model".to_string()),
..Config::default()
};
let exact_error = root_only
.resolve_persisted_provider_identity(Some("custom"), Some("custom"))
.expect_err("exact table record cannot fall back to a legacy root route");
assert!(exact_error.contains("[providers.custom]"), "{exact_error}");
assert!(exact_error.contains("will not fall back"), "{exact_error}");
let exact_route_error = crate::route_runtime::resolve_runtime_route_for_identity(
&root_only,
&exact_table,
Some("table-model"),
)
.expect_err("runtime route must revalidate exact table provenance");
assert!(
exact_route_error.contains("[providers.custom]"),
"{exact_route_error}"
);
}
#[test]
fn persisted_provider_pair_never_collapses_builtin_into_same_key_custom_route() {
let config =
session_custom_provider_config("openai", "openai-compatible", "http://127.0.0.1:1234/v1");
assert_eq!(
config
.resolve_provider_identity("custom")
.expect("legacy generic custom identity uses active exact table")
.key,
"lm-studio"
.resolve_provider_identity("openai")
.expect("raw exact identity intentionally prefers custom"),
ProviderIdentity {
provider: ApiProvider::Custom,
key: "openai".to_string(),
exact_id: Some("openai".to_string()),
}
);
for provider_id in [None, Some("openai")] {
let error = config
.resolve_persisted_provider_identity(Some("openai"), provider_id)
.expect_err("built-in record must not be captured by the custom table");
assert!(error.contains("requires built-in 'openai'"), "{error}");
assert!(error.contains("shadows"), "{error}");
assert!(error.contains("will not guess or fall back"), "{error}");
}
let exact_custom = config
.resolve_persisted_provider_identity(Some("custom"), Some("openai"))
.expect("custom kind plus exact id intentionally selects the table");
assert_eq!(exact_custom.provider, ApiProvider::Custom);
assert_eq!(exact_custom.key, "openai");
let mismatch = config
.resolve_persisted_provider_identity(Some("openrouter"), Some("openai"))
.expect_err("mismatched built-in kind/id pair must fail closed");
assert!(mismatch.contains("mismatched fields"), "{mismatch}");
}
#[test]
fn case_colliding_custom_table_preserves_exact_spelling_across_receipts() {
let config =
session_custom_provider_config("CUSTOM", "openai-compatible", "http://127.0.0.1:5678/v1");
assert_eq!(config.api_provider(), ApiProvider::Custom);
assert_eq!(config.provider_identity_for(ApiProvider::Custom), "CUSTOM");
let identity = config
.resolve_provider_identity("CUSTOM")
.expect("exact case-colliding custom identity");
assert_eq!(identity.key, "CUSTOM");
let route = crate::route_runtime::resolve_runtime_route(
&config,
identity.provider,
Some("local-model"),
)
.expect("resolve case-colliding custom table")
.validate()
.expect("preflight case-colliding custom table");
assert_eq!(route.identity.key, "CUSTOM");
assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
}
#[test]
fn legacy_literal_custom_identity_requires_one_valid_root_route() {
let _lock = lock_test_env();
let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let legacy = Config {
provider: Some("custom".to_string()),
api_key: Some("legacy-root-key".to_string()),
base_url: Some("http://127.0.0.1:1234/v1".to_string()),
default_text_model: Some("local-legacy-model".to_string()),
..Config::default()
};
assert_eq!(
legacy
.resolve_provider_identity("custom")
.expect("unchanged legacy root route"),
ProviderIdentity {
provider: ApiProvider::Custom,
key: "custom".to_string(),
exact_id: None,
}
);
assert_eq!(legacy.deepseek_base_url(), "http://127.0.0.1:1234/v1");
assert_eq!(legacy.default_model(), "local-legacy-model");
assert_eq!(legacy.deepseek_api_key().unwrap(), "legacy-root-key");
let mut named = session_custom_provider_config(
"lm-studio",
"openai-compatible",
"https://api.example.com/v1",
);
named.api_key = Some("must-not-leak-to-named-route".to_string());
let named_key_error = named
.deepseek_api_key()
.expect_err("root legacy key must never authorize a named custom route")
.to_string();
assert!(named_key_error.contains("lm-studio"), "{named_key_error}");
assert!(!named_key_error.contains("must-not-leak"));
let mut ambiguous_named = named.clone();
ambiguous_named
.providers
.as_mut()
.expect("providers")
.custom
.insert(
"vllm-local".to_string(),
ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:8000/v1".to_string()),
model: Some("other-local-model".to_string()),
..ProviderConfig::default()
},
);
let ambiguous_named_error = ambiguous_named
.resolve_provider_identity("custom")
.expect_err("generic released record cannot choose between named routes");
assert!(
ambiguous_named_error.contains("valid named routes: 2"),
"{ambiguous_named_error}"
);
assert!(ambiguous_named_error.contains("will not guess or fall back"));
let mut missing_model = legacy.clone();
missing_model.default_text_model = None;
let model_error = missing_model
.resolve_provider_identity("custom")
.expect_err("legacy root route needs an explicit model");
assert!(model_error.contains("default_text_model"), "{model_error}");
let mut auto_model = legacy.clone();
auto_model.default_text_model = Some("auto".to_string());
let auto_error = auto_model
.resolve_provider_identity("custom")
.expect_err("legacy root route cannot guess an auto model");
assert!(auto_error.contains("not `auto`"), "{auto_error}");
let mut invalid_url = legacy.clone();
invalid_url.base_url = Some("not a provider URL".to_string());
let url_error = invalid_url
.resolve_provider_identity("custom")
.expect_err("legacy root route needs a valid endpoint");
assert!(url_error.contains("base_url"), "{url_error}");
assert!(url_error.contains("will not fall back"), "{url_error}");
let mut ambiguous = legacy.clone();
ambiguous.providers = Some(ProvidersConfig {
custom: HashMap::from([(
"CUSTOM".to_string(),
ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:5678/v1".to_string()),
model: Some("table-model".to_string()),
..ProviderConfig::default()
},
)]),
..ProvidersConfig::default()
});
let ambiguous_error = ambiguous
.resolve_provider_identity("custom")
.expect_err("root and table routes cannot share the generic identity");
assert!(
ambiguous_error.contains("[providers.custom]") && ambiguous_error.contains("ambiguous"),
"{ambiguous_error}"
);
let removed_named = legacy
.resolve_provider_identity("lm-studio")
.expect_err("a removed named route must not fall back to legacy custom");
assert!(removed_named.contains("[providers.lm-studio]"));
assert!(removed_named.contains("will not fall back"));
}
#[test]
fn legacy_literal_custom_env_overrides_preserve_root_route_shape() -> Result<()> {
let _lock = lock_test_env();
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let temp_root = env::temp_dir().join(format!(
"codewhale-tui-legacy-custom-env-{}-{}",
std::process::id(),
nanos
));
fs::create_dir_all(&temp_root)?;
let _guard = EnvGuard::new(&temp_root);
let _source = EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
let _cli_key = EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
let config_path = temp_root.join(".deepseek").join("config.toml");
ensure_parent_dir(&config_path)?;
fs::write(
&config_path,
r#"provider = "custom"
api_key = "legacy-root-key"
base_url = "http://127.0.0.1:18184/v1"
default_text_model = "legacy-model"
"#,
)?;
// Safety: test-only env mutation guarded by lock_test_env().
unsafe {
env::set_var("CODEWHALE_BASE_URL", "http://127.0.0.1:18185/v1");
env::set_var("CODEWHALE_MODEL", "env-legacy-model");
env::set_var("DEEPSEEK_HTTP_HEADERS", "X-Legacy-Route=kept");
}
let config = Config::load(None, None)?;
assert!(config.uses_legacy_literal_custom_route());
assert!(
config
.providers
.as_ref()
.is_none_or(|providers| !providers.custom.contains_key("custom"))
);
assert_eq!(config.deepseek_base_url(), "http://127.0.0.1:18185/v1");
assert_eq!(config.default_model(), "env-legacy-model");
assert_eq!(config.deepseek_api_key()?, "legacy-root-key");
assert_eq!(
config
.http_headers()
.get("X-Legacy-Route")
.map(String::as_str),
Some("kept")
);
for _ in 0..2 {
assert_eq!(
config
.resolve_provider_identity("custom")
.expect("legacy route remains repeatedly resolvable")
.key,
"custom"
);
}
Ok(())
}
#[test]
+4 -4
View File
@@ -682,10 +682,10 @@ fn validate_document(doc: &ConfigUiDocument) -> Result<()> {
fn reload_runtime_config(app: &mut App, config: &mut Config) -> Result<()> {
let reloaded = Config::load(app.config_path.clone(), app.config_profile.as_deref())?;
*config = reloaded.clone();
app.set_provider_identity(
reloaded.api_provider(),
reloaded.provider_identity_for(reloaded.api_provider()),
);
let identity = reloaded
.active_provider_identity(reloaded.api_provider())
.map_err(anyhow::Error::msg)?;
app.set_provider_identity_record(identity);
app.reasoning_effort =
ReasoningEffort::from_setting(reloaded.reasoning_effort().unwrap_or_else(|| {
app.reasoning_effort
+7 -5
View File
@@ -231,6 +231,8 @@ pub fn build_context_report(app: &App) -> PromptSourceMap {
pub fn build_headless_context_report(config: &Config, workspace: &Path) -> PromptSourceMap {
let model = config.default_model();
let provider = config.api_provider();
let provider_identity = config.provider_identity_for(provider);
let global_skills_dir = config.skills_dir();
let selected_skills_dir =
crate::tui::app::resolve_skills_dir(workspace, &global_skills_dir, config);
@@ -268,16 +270,16 @@ pub fn build_headless_context_report(config: &Config, workspace: &Path) -> Promp
builder.push(SourceEntry::text(
SourceKind::ModelProviderFact,
format!("Provider facts ({})", config.api_provider().as_str()),
format!("Provider facts ({provider_identity})"),
None,
ActivationReason::RuntimeState,
&format!(
"provider: {}\nmodel: {}\ncontext_window: {}",
config.api_provider().as_str(),
provider_identity,
model,
// Route limits aren't resolved in the headless doctor path, so report
// the provider+model capability window (route overlay is unavailable).
provider_capability(config.api_provider(), &model).context_window
provider_capability(provider, &model).context_window
),
CountingConfidence::Approximate,
None,
@@ -289,7 +291,7 @@ pub fn build_headless_context_report(config: &Config, workspace: &Path) -> Promp
.map(|entry| entry.estimated_tokens)
.sum();
builder.finish(
config.api_provider(),
provider,
&model,
// Route limits aren't resolved in the headless doctor path.
None,
@@ -470,7 +472,7 @@ fn add_app_runtime_entries(builder: &mut ReportBuilder, app: &App) {
"workspace: {}\nmodel: {}\nprovider: {}\nmode: {}\napproval: {}",
app.workspace.display(),
app.model,
app.api_provider.as_str(),
app.provider_identity_for_persistence(),
app.mode.label(),
app.approval_mode.permission_chip_label()
),
+245 -102
View File
@@ -41,7 +41,11 @@ use crate::models::{
use crate::prompts;
use crate::purge::{emit_purge_completed, emit_purge_failed, emit_purge_started, run_purge};
use crate::resource_telemetry::ResourceTelemetry;
#[cfg(test)]
use crate::route_runtime::resolve_runtime_route;
use crate::route_runtime::{
ResolvedRuntimeRoute, ValidatedRuntimeRoute, resolve_runtime_route_for_identity,
};
use crate::seam_manager::{SeamConfig, SeamManager};
use crate::tools::goal::{GoalSnapshot, GoalStatus, SharedGoalState, new_shared_goal_state};
use crate::tools::plan::{PlanSnapshot, SharedPlanState, new_shared_plan_state};
@@ -579,6 +583,10 @@ pub struct EngineHandle {
tx_steer: mpsc::Sender<String>,
/// Shared pause flag set by the TUI and read by the turn loop.
shared_paused: Arc<StdMutex<bool>>,
/// Whether the host must construct the route's concrete provider client
/// before it mutates turn state. Real engines own concrete provider I/O;
/// explicit injected/mock engines own that seam themselves.
client_preflight_required: bool,
}
// `impl EngineHandle { ... }` moved to `engine/handle.rs` so the
@@ -590,11 +598,19 @@ pub struct EngineHandle {
pub struct Engine {
config: EngineConfig,
api_config: Config,
/// Runtime-host authority consulted only when constructing a later turn
/// descriptor (goal continuation, idle child completion, `/edit`). Active
/// turns keep their already-installed immutable descriptor.
authoritative_route_config: Option<Arc<parking_lot::RwLock<Config>>>,
deepseek_client: Option<DeepSeekClient>,
/// Provider-neutral client used by the canonical main turn loop. Concrete
/// clients remain temporarily available to provider-specific helper tools
/// while those boundaries migrate independently.
model_client: Option<SharedModelClient>,
/// Test/embedding seam: an explicitly injected provider-neutral client
/// remains the I/O authority while typed routes still validate receipts,
/// endpoint metadata, and budgets.
model_client_injected: bool,
deepseek_client_error: Option<String>,
api_key_env_only_recovery: Option<String>,
session: Session,
@@ -602,6 +618,12 @@ pub struct Engine {
shell_manager: SharedShellManager,
mcp_pool: Option<Arc<AsyncMutex<McpPool>>>,
api_provider: ApiProvider,
/// Exact configured route key. Named custom providers share the `Custom`
/// enum, so the enum alone cannot prove that the active client is current.
api_provider_identity: String,
/// Additive exact provider id. `None` preserves the legacy root-literal
/// custom route across snapshots and config reloads.
api_provider_id: Option<String>,
active_route_limits: Option<codewhale_config::route::RouteLimits>,
rx_op: mpsc::Receiver<Op>,
/// Clone of the op-channel sender, so the engine can self-dispatch ops
@@ -835,49 +857,101 @@ impl Engine {
format!("{message}\n\n{hint}")
}
fn activate_runtime_route(&mut self, provider: ApiProvider, model: &str) -> Result<(), String> {
if self.api_provider == provider
&& self
.deepseek_client
.as_ref()
.is_some_and(|client| client.api_provider() == provider)
{
/// Install a route that the host already resolved and client-preflighted.
/// No identity guessing or config re-resolution is allowed at this
/// boundary: the descriptor is the single authority for the turn.
fn install_validated_runtime_route(&mut self, route: ValidatedRuntimeRoute) {
let provider = route.identity.provider;
let identity = route.identity.key;
let provider_id = route.identity.exact_id;
let model = route.model;
let limits = crate::route_budget::known_route_limits(route.candidate.limits);
let api_config = *route.config;
let client = route.client;
self.api_provider = provider;
self.api_provider_identity = identity;
self.api_provider_id = provider_id;
self.api_config = api_config;
self.active_route_limits = limits;
self.api_key_env_only_recovery = Self::env_only_api_key_recovery_hint(&self.api_config);
self.deepseek_client = Some(client.clone());
if !self.model_client_injected {
self.model_client = Some(Arc::new(client.clone()));
}
self.deepseek_client_error = None;
self.session.model = model;
self.config.model.clone_from(&self.session.model);
self.seam_manager = self
.seam_manager
.as_ref()
.filter(|manager| manager.config().enabled)
.map(|manager| SeamManager::new(client, manager.config().clone()));
}
/// Activate a structurally resolved route at the engine boundary. Normal
/// engines construct the concrete client before any turn state changes.
/// Embedders/tests that explicitly injected a provider-neutral client keep
/// that client as the I/O authority while still installing the exact route
/// identity, model, config, and budget receipt.
fn install_resolved_runtime_route(
&mut self,
mut route: ResolvedRuntimeRoute,
) -> Result<(), String> {
if !self.model_client_injected {
self.install_validated_runtime_route(route.validate()?);
return Ok(());
}
let route =
resolve_runtime_route(&self.api_config, provider, Some(model)).map_err(|reason| {
format!(
"Failed to resolve provider route {} / {}: {reason}",
provider.as_str(),
model
)
})?;
let route_config = route.config;
match DeepSeekClient::from_candidate(&route_config, &route.candidate) {
let preflighted_client = route.take_preflighted_client();
let provider = route.identity.provider;
let identity = route.identity.key;
let provider_id = route.identity.exact_id;
let model = route.model;
let limits = crate::route_budget::known_route_limits(route.candidate.limits);
let api_config = route.config;
let concrete_client = preflighted_client
.map(Ok)
.unwrap_or_else(|| DeepSeekClient::from_candidate(&api_config, &route.candidate));
self.api_provider = provider;
self.api_provider_identity = identity;
self.api_provider_id = provider_id;
self.api_config = api_config;
self.active_route_limits = limits;
self.api_key_env_only_recovery = Self::env_only_api_key_recovery_hint(&self.api_config);
match concrete_client {
Ok(client) => {
self.api_provider = provider;
self.api_config = route_config;
self.active_route_limits =
crate::route_budget::known_route_limits(route.candidate.limits);
self.api_key_env_only_recovery =
Self::env_only_api_key_recovery_hint(&self.api_config);
self.deepseek_client = Some(client.clone());
self.model_client = Some(Arc::new(client.clone()));
self.deepseek_client_error = None;
self.seam_manager = self
.seam_manager
.as_ref()
.filter(|manager| manager.config().enabled)
.map(|manager| SeamManager::new(client, manager.config().clone()));
Ok(())
}
Err(err) => Err(format!(
"Failed to configure provider route {} / {}: {err}",
provider.as_str(),
model
)),
Err(err) => {
self.deepseek_client = None;
self.deepseek_client_error = Some(err.to_string());
self.seam_manager = None;
}
}
self.session.model = model;
self.config.model.clone_from(&self.session.model);
Ok(())
}
fn current_runtime_route(&self) -> Result<ResolvedRuntimeRoute, String> {
let config = self
.authoritative_route_config
.as_ref()
.map(|config| config.read().clone())
.unwrap_or_else(|| self.api_config.clone());
let identity = config.resolve_persisted_provider_identity(
Some(self.api_provider.as_str()),
self.api_provider_id.as_deref(),
)?;
resolve_runtime_route_for_identity(&config, &identity, Some(&self.session.model))
}
/// Create a new engine with the given configuration
@@ -914,6 +988,16 @@ impl Engine {
.as_ref()
.map(|client| Arc::new(client.clone()) as SharedModelClient);
let api_provider = api_config.api_provider();
let (api_provider_identity, api_provider_id) = api_config
.active_provider_identity(api_provider)
.map(|identity| (identity.key, identity.exact_id))
.unwrap_or_else(|_| {
let key = api_config.provider_identity_for(api_provider);
let exact_id = (!(api_provider == ApiProvider::Custom
&& api_config.uses_legacy_literal_custom_route()))
.then(|| key.clone());
(key, exact_id)
});
let api_key_env_only_recovery = Self::env_only_api_key_recovery_hint(api_config);
let mut session = Session::new(
@@ -1052,8 +1136,10 @@ impl Engine {
let engine = Engine {
config,
api_config: api_config.clone(),
authoritative_route_config: None,
deepseek_client,
model_client,
model_client_injected: false,
deepseek_client_error,
api_key_env_only_recovery,
session,
@@ -1061,6 +1147,8 @@ impl Engine {
shell_manager,
mcp_pool: None,
api_provider,
api_provider_identity,
api_provider_id,
active_route_limits,
rx_op,
tx_op: tx_op.clone(),
@@ -1095,6 +1183,7 @@ impl Engine {
tx_user_input,
tx_steer,
shared_paused,
client_preflight_required: true,
};
(engine, handle)
@@ -1110,9 +1199,11 @@ impl Engine {
api_config: &Config,
client: SharedModelClient,
) -> (Self, EngineHandle) {
let (mut engine, handle) = Self::new(config, api_config);
let (mut engine, mut handle) = Self::new(config, api_config);
engine.model_client = Some(client);
engine.model_client_injected = true;
engine.deepseek_client_error = None;
handle.client_preflight_required = false;
(engine, handle)
}
@@ -1433,9 +1524,7 @@ impl Engine {
Op::SendMessage {
content,
mode,
provider,
model,
route_limits,
route,
compaction,
goal_objective,
goal_token_budget,
@@ -1458,9 +1547,7 @@ impl Engine {
self.handle_send_message(
content,
mode,
provider,
model,
route_limits,
*route,
*compaction,
goal_objective,
goal_token_budget,
@@ -1849,7 +1936,17 @@ impl Engine {
.send(Event::status("Session context synced".to_string()))
.await;
}
Op::CompactContext => {
Op::CompactContext { route, compaction } => {
if let Err(err) = self.install_resolved_runtime_route(*route) {
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
"Cannot compact context because its provider route is not ready: {err}"
))))
.await;
continue;
}
self.config.compaction = *compaction;
self.handle_manual_compaction().await;
}
Op::GetSessionSnapshot { tx } => {
@@ -1859,9 +1956,8 @@ impl Engine {
messages: self.session.messages.to_vec(),
total_tokens,
model: self.session.model.clone(),
model_provider: self
.api_config
.provider_identity_for(self.api_provider),
model_provider: self.api_provider.as_str().to_string(),
model_provider_id: self.api_provider_id.clone(),
workspace: self.session.workspace.clone(),
system_prompt: self.session.system_prompt.clone(),
mode: self.current_mode.as_setting().to_string(),
@@ -1896,6 +1992,18 @@ impl Engine {
self.handle_purge().await;
}
Op::EditLastTurn { new_message } => {
let route = match self.current_runtime_route() {
Ok(route) => route,
Err(err) => {
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
"Cannot edit the last turn because its provider route is no longer valid: {err}"
))))
.await;
continue;
}
};
// #383: /edit — remove the last user+assistant exchange
// from the session, then re-send with the new content.
// Pop messages from the tail until we've removed the
@@ -1918,9 +2026,7 @@ impl Engine {
self.handle_send_message(
new_message,
mode,
Some(self.api_provider),
self.session.model.clone(),
self.active_route_limits,
route,
self.config.compaction.clone(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
@@ -2274,11 +2380,27 @@ impl Engine {
return;
}
let count = completions.len();
let claimed_ids = completions
.iter()
.map(|completion| completion.agent_id.clone())
.collect::<Vec<_>>();
let route = match self.current_runtime_route() {
Ok(route) => route,
Err(err) => {
for agent_id in claimed_ids {
self.delivered_subagent_completion_ids.remove(&agent_id);
}
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
"Cannot resume the turn because its provider route is no longer valid: {err}"
))))
.await;
return;
}
};
let count = completions.len();
let content = completions
.iter()
.map(|completion| turn_loop::subagent_completion_runtime_text(&completion.payload))
@@ -2296,9 +2418,7 @@ impl Engine {
.handle_send_message(
content,
self.current_mode,
Some(self.api_provider),
self.session.model.clone(),
self.active_route_limits,
route,
self.config.compaction.clone(),
self.config.goal_objective.clone(),
self.config.goal_token_budget,
@@ -2424,9 +2544,7 @@ impl Engine {
&mut self,
content: String,
mode: AppMode,
provider: Option<ApiProvider>,
model: String,
route_limits: Option<codewhale_config::route::RouteLimits>,
route: ResolvedRuntimeRoute,
compaction: CompactionConfig,
goal_objective: Option<String>,
goal_token_budget: Option<u32>,
@@ -2446,6 +2564,20 @@ impl Engine {
verbosity: Option<String>,
provenance: UserInputProvenance,
) -> bool {
let effective_provider = route.identity.provider;
let provider_identity = route.identity.key.clone();
let model = route.model.clone();
let route_limits = crate::route_budget::known_route_limits(route.candidate.limits);
if let Err(err) = self.install_resolved_runtime_route(route) {
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
"Cannot start the turn because its provider route is not ready: {err}"
))))
.await;
return false;
}
let input_policy = effective_input_policy(
provenance,
mode,
@@ -2473,7 +2605,8 @@ impl Engine {
let mut turn = TurnContext::new(self.config.max_steps);
self.turn_counter = self.turn_counter.saturating_add(1);
let turn_route = TurnRoute {
provider: provider.unwrap_or(self.api_provider),
provider: effective_provider,
provider_identity,
model: model.clone(),
auto_model,
};
@@ -2525,28 +2658,6 @@ impl Engine {
// is moved into `user_text_message_with_turn_metadata_for_route` below.
let snapshot_prompt_post = content.clone();
// Check if we have the appropriate client
if let Some(provider) = provider
&& let Err(message) = self.activate_runtime_route(provider, &model)
{
self.deepseek_client_error = Some(message.clone());
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(message.clone())))
.await;
let _ = self
.tx_event
.send(Event::TurnComplete {
usage: turn.usage.clone(),
status: TurnOutcomeStatus::Failed,
error: Some(message),
tool_catalog: None,
base_url: None,
})
.await;
return false;
}
if self.model_client.is_none() {
let message = self
.deepseek_client_error
@@ -2908,34 +3019,44 @@ impl Engine {
// the prior turn. The non-Copy values were moved into
// `self.config` / `self.session` earlier in this function, so
// we clone them back out here.
let _ = self
.tx_op
.send(Op::SendMessage {
content: continuation,
mode,
provider,
model: self.session.model.clone(),
route_limits: self.active_route_limits,
compaction: Box::new(self.config.compaction.clone()),
goal_objective: None,
goal_token_budget: None,
goal_status: GoalStatus::Active,
reasoning_effort: self.session.reasoning_effort.clone(),
reasoning_effort_auto,
auto_model,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
translation_enabled,
show_thinking,
allowed_tools: self.config.allowed_tools.clone(),
dynamic_tools: dynamic_tools.clone(),
hook_executor: self.config.hook_executor.clone(),
verbosity: self.config.verbosity.clone(),
provenance: UserInputProvenance::Runtime,
})
.await;
match self.current_runtime_route() {
Ok(route) => {
let _ = self
.tx_op
.send(Op::SendMessage {
content: continuation,
mode,
route: Box::new(route),
compaction: Box::new(self.config.compaction.clone()),
goal_objective: None,
goal_token_budget: None,
goal_status: GoalStatus::Active,
reasoning_effort: self.session.reasoning_effort.clone(),
reasoning_effort_auto,
auto_model,
allow_shell,
trust_mode,
auto_approve,
approval_mode,
translation_enabled,
show_thinking,
allowed_tools: self.config.allowed_tools.clone(),
dynamic_tools: dynamic_tools.clone(),
hook_executor: self.config.hook_executor.clone(),
verbosity: self.config.verbosity.clone(),
provenance: UserInputProvenance::Runtime,
})
.await;
}
Err(err) => {
let _ = self
.tx_event
.send(Event::error(ErrorEnvelope::fatal_auth(format!(
"Goal continuation stopped because its provider route is no longer valid: {err}"
))))
.await;
}
}
}
true
}
@@ -3959,6 +4080,27 @@ pub fn spawn_engine(config: EngineConfig, api_config: &Config) -> EngineHandle {
handle
}
/// Spawn a runtime-owned engine whose autonomous later turns resolve against
/// the manager's atomic config snapshot. This does not mutate an active turn.
pub(crate) fn spawn_engine_with_authoritative_route_config(
config: EngineConfig,
api_config: &Config,
authoritative_route_config: Arc<parking_lot::RwLock<Config>>,
) -> EngineHandle {
let (mut engine, handle) = Engine::new(config, api_config);
engine.authoritative_route_config = Some(authoritative_route_config);
spawn_supervised(
"engine-event-loop",
std::panic::Location::caller(),
async move {
engine.run().await;
},
);
handle
}
#[cfg(test)]
pub(crate) struct MockEngineHandle {
pub handle: EngineHandle,
@@ -4017,6 +4159,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle {
tx_user_input,
tx_steer,
shared_paused,
client_preflight_required: false,
};
MockEngineHandle {
+8
View File
@@ -14,6 +14,14 @@ use super::approval::{ApprovalDecision, UserInputDecision};
use super::{CancelReason, EngineHandle, Op, UserInputResponse};
impl EngineHandle {
/// True when the caller must preflight a concrete provider client before
/// committing UI/runtime turn state. Test and embedding handles with an
/// injected model client return false because that client owns model I/O.
#[must_use]
pub(crate) fn client_preflight_required(&self) -> bool {
self.client_preflight_required
}
/// Send an operation to the engine
pub async fn send(&self, op: Op) -> Result<()> {
self.tx_op.send(op).await?;
+437 -23
View File
@@ -18,6 +18,381 @@ use tempfile::tempdir;
const WORKING_SET_SUMMARY_MARKER: &str = "## Repo Working Set";
#[test]
fn custom_route_identity_change_rebuilds_client_for_new_named_endpoint() {
let mut custom = HashMap::new();
for (name, base_url, model) in [
("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
] {
custom.insert(
name.to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
api_key: Some("local-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
);
}
let config = Config {
provider: Some("custom-a".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let (mut engine, _handle) = Engine::new(EngineConfig::default(), &config);
assert_eq!(engine.api_provider_identity, "custom-a");
assert_eq!(
engine
.deepseek_client
.as_ref()
.expect("custom A client")
.base_url(),
"http://127.0.0.1:18181/v1"
);
let mut target = config.clone();
target.provider = Some("custom-b".to_string());
let route = resolve_runtime_route(&target, ApiProvider::Custom, Some("model-b"))
.expect("resolve custom B")
.validate()
.expect("preflight custom B");
engine.install_validated_runtime_route(route);
assert_eq!(engine.api_provider_identity, "custom-b");
assert_eq!(
engine
.deepseek_client
.as_ref()
.expect("custom B client")
.base_url(),
"http://127.0.0.1:18182/v1"
);
}
#[test]
fn custom_route_config_reload_rebuilds_client_when_identity_is_unchanged() {
let mut custom = HashMap::new();
custom.insert(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("local-model".to_string()),
api_key: Some("old-local-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let (mut engine, _handle) = Engine::new(EngineConfig::default(), &config);
let mut reloaded = config;
let provider = reloaded
.providers
.as_mut()
.and_then(|providers| providers.custom.get_mut("lm-studio"))
.expect("named custom provider");
provider.base_url = Some("http://127.0.0.1:18182/v1".to_string());
provider.api_key = Some("new-local-test-key".to_string());
let route = resolve_runtime_route(&reloaded, ApiProvider::Custom, Some("local-model"))
.expect("resolve reloaded route")
.validate()
.expect("preflight reloaded route");
engine.install_validated_runtime_route(route);
assert_eq!(engine.api_provider_identity, "lm-studio");
assert_eq!(
engine
.deepseek_client
.as_ref()
.expect("reloaded custom client")
.base_url(),
"http://127.0.0.1:18182/v1"
);
assert_eq!(
engine.api_config.deepseek_base_url(),
"http://127.0.0.1:18182/v1"
);
}
#[test]
fn failed_same_identity_route_preflight_leaves_old_client_untouched() {
let mut custom = HashMap::new();
custom.insert(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("local-model".to_string()),
api_key: Some("old-local-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let (engine, _handle) = Engine::new(EngineConfig::default(), &config);
assert!(engine.deepseek_client.is_some());
let mut invalid = config;
invalid
.providers
.as_mut()
.and_then(|providers| providers.custom.get_mut("lm-studio"))
.expect("named custom provider")
.base_url = Some("ftp://invalid.example/v1".to_string());
let err = resolve_runtime_route(&invalid, ApiProvider::Custom, Some("local-model"))
.expect_err("invalid route must fail before installation");
assert!(err.contains("must be an http(s) URL with a host"), "{err}");
assert_eq!(engine.api_provider_identity, "lm-studio");
assert!(engine.deepseek_client.is_some());
assert!(engine.model_client.is_some());
assert!(engine.deepseek_client_error.is_none());
}
#[tokio::test]
async fn exact_turn_snapshot_restores_custom_endpoint_and_turn_receipt_after_builtin_route() {
let mut custom = HashMap::new();
custom.insert(
"custom-a".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("local-model".to_string()),
api_key: Some("local-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("custom-a".to_string()),
providers: Some(crate::config::ProvidersConfig {
openai: crate::config::ProviderConfig {
base_url: Some("http://127.0.0.1:18182/v1".to_string()),
model: Some("gpt-5.5".to_string()),
api_key: Some("builtin-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let engine_config = EngineConfig {
max_steps: 0,
snapshots_enabled: false,
..EngineConfig::default()
};
let (mut engine, handle) = Engine::new(engine_config, &config);
let mut builtin_config = config.clone();
builtin_config.provider = Some("openai".to_string());
let builtin_route =
resolve_runtime_route(&builtin_config, ApiProvider::Openai, Some("gpt-5.5"))
.expect("resolve intervening builtin route")
.validate()
.expect("preflight intervening builtin route");
engine.install_validated_runtime_route(builtin_route);
assert_eq!(engine.api_provider, ApiProvider::Openai);
assert_eq!(
engine
.deepseek_client
.as_ref()
.expect("builtin client")
.base_url(),
"http://127.0.0.1:18182/v1"
);
let run_task = tokio::spawn(engine.run());
handle
.send(Op::SendMessage {
content: "verify exact route".to_string(),
mode: AppMode::Agent,
route: Box::new(
resolve_runtime_route(&config, ApiProvider::Custom, Some("local-model"))
.expect("resolve exact custom route"),
),
compaction: Box::new(CompactionConfig::default()),
goal_objective: None,
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: true,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: crate::tui::approval::ApprovalMode::Suggest,
translation_enabled: false,
show_thinking: false,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
.await
.expect("send exact custom turn");
let mut saw_exact_start = false;
let mut saw_exact_endpoint = false;
for _ in 0..20 {
let event = tokio::time::timeout(Duration::from_secs(2), async {
handle.rx_event.write().await.recv().await
})
.await
.expect("engine event timeout")
.expect("engine event");
match event {
Event::TurnStarted {
route: Some(route), ..
} => {
assert_eq!(route.provider, ApiProvider::Custom);
assert_eq!(route.provider_identity, "custom-a");
assert_eq!(route.model, "local-model");
saw_exact_start = true;
}
Event::TurnComplete { base_url, .. } => {
assert_eq!(base_url.as_deref(), Some("http://127.0.0.1:18181/v1"));
saw_exact_endpoint = true;
break;
}
_ => {}
}
}
assert!(saw_exact_start);
assert!(saw_exact_endpoint);
handle.send(Op::Shutdown).await.expect("shutdown engine");
run_task.await.expect("engine task");
}
#[tokio::test]
async fn goal_continuation_resolves_updated_authoritative_route_after_active_turn() {
let mut custom = HashMap::new();
custom.insert(
"custom-a".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("local-model".to_string()),
api_key: Some("local-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("custom-a".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let engine_config = EngineConfig {
max_steps: 0,
snapshots_enabled: false,
goal_objective: Some("keep going".to_string()),
..EngineConfig::default()
};
let authoritative = Arc::new(parking_lot::RwLock::new(config.clone()));
let (mut engine, handle) = Engine::new(engine_config, &config);
engine.authoritative_route_config = Some(Arc::clone(&authoritative));
handle
.send(Op::SendMessage {
content: "first turn".to_string(),
mode: AppMode::Agent,
route: resolved_route_for_test(&config, "local-model"),
compaction: Box::new(CompactionConfig::default()),
goal_objective: Some("keep going".to_string()),
goal_token_budget: None,
goal_status: crate::tools::goal::GoalStatus::Active,
reasoning_effort: None,
reasoning_effort_auto: false,
auto_model: false,
allow_shell: false,
trust_mode: false,
auto_approve: false,
approval_mode: crate::tui::approval::ApprovalMode::Suggest,
translation_enabled: false,
show_thinking: false,
allowed_tools: None,
dynamic_tools: Vec::new(),
hook_executor: None,
verbosity: None,
provenance: UserInputProvenance::ExternalUser,
})
.await
.expect("send first goal turn");
let mut reloaded = config;
reloaded
.providers
.as_mut()
.and_then(|providers| providers.custom.get_mut("custom-a"))
.expect("custom route")
.base_url = Some("http://127.0.0.1:18182/v1".to_string());
*authoritative.write() = reloaded;
let run_task = tokio::spawn(engine.run());
let mut starts = 0;
let mut completes = 0;
while completes < 2 {
let event = tokio::time::timeout(Duration::from_secs(3), async {
handle.rx_event.write().await.recv().await
})
.await
.expect("goal engine event timeout")
.expect("goal engine event");
match event {
Event::TurnStarted {
route: Some(route), ..
} => {
starts += 1;
assert_eq!(route.provider_identity, "custom-a");
if starts == 2 {
handle
.send(Op::SetGoalStatus {
status: crate::tools::goal::GoalStatus::Paused,
clear: false,
})
.await
.expect("queue goal pause");
handle.send(Op::Shutdown).await.expect("queue shutdown");
}
}
Event::TurnComplete { base_url, .. } => {
completes += 1;
let expected = if completes == 1 {
"http://127.0.0.1:18181/v1"
} else {
"http://127.0.0.1:18182/v1"
};
assert_eq!(base_url.as_deref(), Some(expected));
}
_ => {}
}
}
assert_eq!(starts, 2);
run_task.await.expect("engine task");
}
#[test]
fn idle_and_in_turn_subagent_delivery_claim_each_completion_once() {
use crate::tools::subagent::SubAgentCompletion;
@@ -57,10 +432,12 @@ async fn idle_subagent_delivery_releases_claim_when_route_fails_before_recording
};
let (mut engine, _handle) =
Engine::new(deterministic_engine_config(workspace.path()), &api_config);
// Keep the already-built provider client so route activation is a no-op,
// but force the pre-record client check to fail without any network call.
engine.model_client = None;
engine.deepseek_client_error = Some("forced missing model client".to_string());
// Make the persisted exact identity structurally unresolvable. The
// completion is claimed before route resolution, so this exercises the
// early error branch before a transcript record can be written.
engine.api_provider = ApiProvider::Custom;
engine.api_provider_identity = "missing-custom".to_string();
engine.api_provider_id = Some("missing-custom".to_string());
engine
.handle_idle_subagent_completion(SubAgentCompletion {
@@ -538,13 +915,21 @@ fn model_turn_event_timeout() -> Duration {
}
}
fn external_user_message_op(content: &str, mode: AppMode) -> Op {
fn resolved_route_for_test(
config: &Config,
model: &str,
) -> Box<crate::route_runtime::ResolvedRuntimeRoute> {
Box::new(
resolve_runtime_route(config, config.api_provider(), Some(model))
.expect("resolve test route"),
)
}
fn external_user_message_op(content: &str, mode: AppMode, config: &Config) -> Op {
Op::SendMessage {
content: content.to_string(),
mode,
provider: None,
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
route_limits: None,
route: resolved_route_for_test(config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
goal_objective: None,
goal_token_budget: None,
@@ -644,6 +1029,7 @@ async fn injected_model_drives_real_engine_navigation_trajectory() {
.send(external_user_message_op(
"Read README.md and report what it contains.",
AppMode::Agent,
&Config::default(),
))
.await
.expect("send deterministic navigation turn");
@@ -706,6 +1092,7 @@ async fn injected_model_receives_malformed_tool_feedback_and_recovers() {
.send(external_user_message_op(
"Exercise malformed tool feedback.",
AppMode::Agent,
&Config::default(),
))
.await
.expect("send malformed trajectory");
@@ -766,6 +1153,7 @@ async fn engine_cancellation_drops_active_injected_model_request() {
.send(external_user_message_op(
"Block until explicitly cancelled.",
AppMode::Agent,
&Config::default(),
))
.await
.expect("send cancellation trajectory");
@@ -837,6 +1225,7 @@ async fn operate_conversation_reaches_provider_when_workers_are_disabled() {
.send(external_user_message_op(
"what is a Rust worktree?",
AppMode::Operate,
&api_config,
))
.await
.expect("send Operate turn");
@@ -3187,9 +3576,7 @@ async fn yolo_mode_does_not_prompt_for_model_driven_typed_ask_rule() {
.send(Op::SendMessage {
content: "please exercise the shell path".to_string(),
mode: AppMode::Yolo,
provider: None,
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
route_limits: None,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
goal_objective: None,
goal_token_budget: None,
@@ -3315,9 +3702,7 @@ async fn yolo_mode_still_prompts_for_background_destructive_shell() {
.send(Op::SendMessage {
content: "please run a background shell".to_string(),
mode: AppMode::Yolo,
provider: None,
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
route_limits: None,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
goal_objective: None,
goal_token_budget: None,
@@ -3471,9 +3856,7 @@ async fn yolo_mode_does_not_prompt_for_background_shell() {
.send(Op::SendMessage {
content: "please run a background shell".to_string(),
mode: AppMode::Yolo,
provider: None,
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
route_limits: None,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
goal_objective: None,
goal_token_budget: None,
@@ -3607,9 +3990,7 @@ async fn yolo_mode_prompts_for_publish_like_shell_safety_floor() {
.send(Op::SendMessage {
content: "please publish this crate".to_string(),
mode: AppMode::Yolo,
provider: None,
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
route_limits: None,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
goal_objective: None,
goal_token_budget: None,
@@ -3761,9 +4142,7 @@ async fn yolo_mode_does_not_prompt_for_mcp_action() {
.send(Op::SendMessage {
content: "please open the PR".to_string(),
mode: AppMode::Yolo,
provider: None,
model: crate::config::DEFAULT_TEXT_MODEL.to_string(),
route_limits: None,
route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL),
compaction: Box::new(CompactionConfig::default()),
goal_objective: None,
goal_token_budget: None,
@@ -4934,6 +5313,40 @@ async fn sync_session_restores_current_mode() {
run.abort();
}
#[tokio::test]
async fn session_snapshot_omits_id_for_legacy_root_custom_route() {
let tmp = tempdir().expect("tempdir");
let api_config = Config {
provider: Some("custom".to_string()),
base_url: Some("http://127.0.0.1:18180/v1".to_string()),
default_text_model: Some("legacy-root-model".to_string()),
..Config::default()
};
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
model: "legacy-root-model".to_string(),
..Default::default()
};
let (engine, handle) = Engine::new(config, &api_config);
let run = tokio::spawn(engine.run());
let (tx, rx) = tokio::sync::oneshot::channel();
handle
.send(Op::GetSessionSnapshot {
tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))),
})
.await
.expect("request snapshot");
let snapshot = tokio::time::timeout(Duration::from_secs(2), rx)
.await
.expect("snapshot response")
.expect("snapshot");
assert_eq!(snapshot.model_provider, "custom");
assert_eq!(snapshot.model_provider_id, None);
run.abort();
}
#[tokio::test]
async fn edit_last_turn_preserves_current_mode() {
let tmp = tempdir().expect("tempdir");
@@ -7568,6 +7981,7 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() {
tx_user_input: mpsc::channel(1).0,
tx_steer: mpsc::channel(1).0,
shared_paused: Arc::new(StdMutex::new(false)),
client_preflight_required: true,
};
// Fill the op channel with one message (capacity = 1).
+3
View File
@@ -32,6 +32,9 @@ pub enum TurnOutcomeStatus {
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnRoute {
pub provider: ApiProvider,
/// Exact non-secret configured route key. Named custom providers all map
/// to [`ApiProvider::Custom`], so the enum alone is not provenance.
pub provider_identity: String,
pub model: String,
pub auto_model: bool,
}
+15 -10
View File
@@ -6,6 +6,7 @@
use crate::compaction::CompactionConfig;
use crate::config::ApiProvider;
use crate::models::{Message, SystemPrompt};
use crate::route_runtime::ResolvedRuntimeRoute;
use crate::tools::goal::GoalStatus;
use crate::tui::app::AppMode;
use crate::tui::approval::ApprovalMode;
@@ -22,7 +23,10 @@ pub struct SessionSnapshot {
pub messages: Vec<Message>,
pub total_tokens: u64,
pub model: String,
/// Generic provider kind retained for serialized compatibility.
pub model_provider: String,
/// Exact non-secret configured provider key.
pub model_provider_id: Option<String>,
pub workspace: PathBuf,
pub system_prompt: Option<SystemPrompt>,
pub mode: String,
@@ -76,19 +80,16 @@ impl UserInputProvenance {
}
/// Operations that can be submitted to the engine.
#[derive(Debug, Clone)]
#[derive(Debug)]
pub enum Op {
/// Send a message to the AI
SendMessage {
content: String,
mode: AppMode,
/// Provider route to use for this turn. `None` keeps the session
/// provider; auto model routing sets this when the inventory selects a
/// different authenticated provider.
provider: Option<ApiProvider>,
model: String,
/// Provider-route limits resolved by the host for this exact turn.
route_limits: Option<codewhale_config::route::RouteLimits>,
/// Exact, structurally resolved route authority for this turn. The
/// engine activates its client before mutating turn state; injected
/// engines may use their already-supplied client with the same receipt.
route: Box<ResolvedRuntimeRoute>,
/// Compaction policy derived from the same provider route. Carrying it
/// atomically avoids a model/limit mismatch before `SendMessage`.
compaction: Box<CompactionConfig>,
@@ -219,8 +220,12 @@ pub enum Op {
mode: AppMode,
},
/// Run context compaction immediately.
CompactContext,
/// Run context compaction on one exact, structurally resolved provider
/// route with policy derived from that same descriptor.
CompactContext {
route: Box<ResolvedRuntimeRoute>,
compaction: Box<CompactionConfig>,
},
/// Get a snapshot of the current session state (messages, tokens, etc.)
/// for saving to disk. Returns the result via the oneshot sender so
+16 -1
View File
@@ -27,6 +27,7 @@ use super::task_spec::{
record_verification_receipt, validate_task_spec_document, verify_task_result,
};
use super::worker_runtime;
use crate::config::Config;
use crate::tools::subagent::SharedSubAgentManager;
const DEFAULT_STALE_AFTER_SECONDS: u64 = 300;
@@ -50,6 +51,9 @@ pub struct FleetManager {
/// (matching the `/fleet roster` operator row). `None` keeps the legacy
/// `"auto"` fallback for headless callers with no session.
session_model: Option<String>,
/// Live provider-route authority used to mint truthful Fleet receipts.
/// Kept out of Debug because it may contain credentials.
route_config: Option<Config>,
}
impl std::fmt::Debug for FleetManager {
@@ -186,6 +190,7 @@ impl FleetManager {
fleet_config: codewhale_config::FleetConfigToml::default(),
sub_agent_manager: None,
session_model: None,
route_config: None,
})
}
@@ -202,6 +207,11 @@ impl FleetManager {
self
}
pub fn with_route_config(mut self, config: Config) -> Self {
self.route_config = Some(config);
self
}
/// The run-level model handed to worker-spec resolution: the session
/// model when one was adopted, else the legacy `"auto"` sentinel.
fn run_model(&self) -> &str {
@@ -1008,7 +1018,12 @@ impl FleetManager {
/// when resolution is unavailable.
fn resolve_task_route(&self, task_spec: &FleetTaskSpec) -> Option<FleetResolvedRoute> {
let roster = self.agent_roster();
worker_runtime::resolve_fleet_route(task_spec, roster.members(), self.session_model())
worker_runtime::resolve_fleet_route_with_config(
task_spec,
roster.members(),
self.session_model(),
self.route_config.as_ref(),
)
}
/// The adopted session route, if any — the operator's model.
+160 -19
View File
@@ -20,8 +20,8 @@ use codewhale_protocol::fleet::{
};
use super::profile::AgentProfile;
use crate::config::ApiProvider;
use crate::route_runtime::resolve_route_candidate;
use crate::config::{ApiProvider, Config};
use crate::route_runtime::{resolve_route_candidate, resolve_runtime_route};
use crate::tools::subagent::{AgentWorkerSpec, AgentWorkerToolProfile, SubAgentType};
use crate::worker_profile::{ModelRoute, ToolScope, WorkerRuntimeProfile};
@@ -138,6 +138,18 @@ pub(crate) fn resolve_fleet_route(
task_spec: &FleetTaskSpec,
agent_profiles: &[AgentProfile],
session_model: Option<&str>,
) -> Option<FleetResolvedRoute> {
resolve_fleet_route_with_config(task_spec, agent_profiles, session_model, None)
}
/// Resolve a Fleet receipt from the same live Config used to launch workers.
/// Named custom identities are emitted only through this proof-bearing path;
/// the hermetic fallback above cannot truthfully validate arbitrary ids.
pub(crate) fn resolve_fleet_route_with_config(
task_spec: &FleetTaskSpec,
agent_profiles: &[AgentProfile],
session_model: Option<&str>,
config: Option<&Config>,
) -> Option<FleetResolvedRoute> {
let agent_profile = resolve_task_agent_profile(task_spec, agent_profiles)
.ok()
@@ -155,20 +167,39 @@ pub(crate) fn resolve_fleet_route(
fleet_route_model_selector_with_source(worker_profile, agent_profile, session_model);
let model_selector = model_selector.as_deref();
// Resolve within the profile's own explicit provider scope when it has
// one (#4093); otherwise fall back to the existing default scope (mirrors
// `ProviderKind::default()`). The resolver is fully offline/hermetic and
// never reads secrets, env, or config. User-named custom providers need the
// session Config to resolve their table, so this receipt path omits the
// route instead of fabricating DeepSeek details for them (#3965).
let provider = match explicit_fleet_provider_id(agent_profile).as_deref() {
Some(provider_id) => ApiProvider::parse(provider_id)?,
None => ApiProvider::Deepseek,
let explicit_provider_id = explicit_fleet_provider_id(agent_profile);
let (candidate, provider_id, route_source) = if let Some(config) = config {
let identity = match explicit_provider_id.as_deref() {
Some(provider_id) => config.resolve_provider_identity(provider_id).ok()?,
None => config
.resolve_provider_identity(&config.provider_identity_for(config.api_provider()))
.ok()?,
};
let mut scoped = config.clone();
scoped.provider = Some(identity.key.clone());
let route = resolve_runtime_route(&scoped, identity.provider, model_selector)
.ok()?
.validate()
.ok()?;
(route.candidate, route.identity.key, "runtime_route")
} else {
let provider = match explicit_provider_id.as_deref() {
Some(provider_id) => {
let provider = ApiProvider::parse(provider_id)?;
if provider == ApiProvider::Custom {
return None;
}
provider
}
None => ApiProvider::Deepseek,
};
let candidate = resolve_route_candidate(provider, model_selector, None, None, None).ok()?;
let provider_id = candidate.provider_id.as_str().to_string();
(candidate, provider_id, "resolver")
};
let candidate = resolve_route_candidate(provider, model_selector, None, None, None).ok()?;
Some(FleetResolvedRoute {
provider_id: candidate.provider_id.as_str().to_string(),
provider_id,
provider_kind: candidate.provider_kind.as_str().to_string(),
canonical_model: candidate
.canonical_model
@@ -191,7 +222,7 @@ pub(crate) fn resolve_fleet_route(
loadout_source: loadout_source.map(str::to_string),
model_class_source: model_class_source.map(str::to_string),
model_source: Some(model_source.to_string()),
source: "resolver".to_string(),
source: route_source.to_string(),
})
}
@@ -1452,7 +1483,7 @@ mod tests {
}
#[test]
fn resolve_fleet_route_omits_custom_provider_without_config_snapshot() {
fn resolve_fleet_route_preserves_exact_named_custom_provider_without_secrets() {
let mut profile = agent_profile(
"local",
"scout",
@@ -1473,12 +1504,122 @@ mod tests {
)),
);
let route = resolve_fleet_route(&task, &[profile], Some("deepseek-v4-pro"));
assert!(
route.is_none(),
"custom providers need the session Config snapshot; do not fabricate a DeepSeek receipt"
resolve_fleet_route(&task, &[profile.clone()], Some("deepseek-v4-pro")).is_none(),
"a profile string alone is not proof that a named custom route exists"
);
let config = Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom: std::collections::HashMap::from([(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:1234/v1".to_string()),
model: Some("qwen-2.5-7b".to_string()),
api_key: Some("receipt-must-redact-this".to_string()),
..Default::default()
},
)]),
..Default::default()
}),
..Default::default()
};
let route = resolve_fleet_route_with_config(
&task,
&[profile],
Some("deepseek-v4-pro"),
Some(&config),
)
.expect("live config should prove the named custom route");
assert_eq!(route.provider_id, "lm-studio");
assert_eq!(route.provider_kind, "custom");
assert_eq!(route.wire_model_id, "qwen-2.5-7b");
assert_eq!(route.protocol, "chat_completions");
assert_eq!(route.model_source.as_deref(), Some("agent_profile.model"));
assert_eq!(route.source, "runtime_route");
// The exact identity and wire model are durable, while endpoint/auth
// config remains outside the receipt. The generic Custom descriptor's
// placeholder endpoint is never serialized either.
let json = serde_json::to_string(&route).unwrap();
let haystack = json.to_ascii_lowercase();
assert!(haystack.contains("lm-studio"));
assert!(!haystack.contains("base_url"));
assert!(!haystack.contains("http://"));
assert!(!haystack.contains("https://"));
for needle in [
"api_key",
"apikey",
"api-key",
"authorization",
"bearer ",
"auth_token",
"auth-token",
"password",
"credential",
"sk-ant-",
"sk-proj-",
"sk-or-",
"secret",
"receipt-must-redact-this",
] {
assert!(
!haystack.contains(needle),
"named-custom route JSON must not contain secret marker {needle:?}: {json}"
);
}
}
#[test]
fn fleet_receipt_prefers_live_case_colliding_custom_identity() {
let mut profile = agent_profile(
"case-local",
"scout",
None,
codewhale_config::FleetLoadout::Inherit,
);
profile.profile.model = Some("case-model".to_string());
profile.profile.provider = Some("CUSTOM".to_string());
let task = fleet_task(
"case-custom-receipt",
Some(worker_profile(
Some("case-local"),
None,
None,
None,
None,
vec![],
)),
);
let config = Config {
provider: Some("CUSTOM".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom: std::collections::HashMap::from([(
"CUSTOM".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:5678/v1".to_string()),
model: Some("case-model".to_string()),
..Default::default()
},
)]),
..Default::default()
}),
..Default::default()
};
let route = resolve_fleet_route_with_config(
&task,
&[profile],
Some("deepseek-v4-pro"),
Some(&config),
)
.expect("live config route proof");
assert_eq!(route.provider_id, "CUSTOM");
assert_eq!(route.provider_kind, "custom");
assert_eq!(route.source, "runtime_route");
}
#[test]
+758 -125
View File
File diff suppressed because it is too large Load Diff
+86 -8
View File
@@ -64,20 +64,21 @@ pub(crate) fn route_identity_for_model(
.map(str::trim)
.filter(|value| !value.is_empty())
.or_else(|| {
matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
.then(|| config.base_url.as_deref())
.flatten()
.map(str::trim)
.filter(|value| !value.is_empty())
(matches!(provider, ApiProvider::Deepseek | ApiProvider::DeepseekCN)
|| (provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route()))
.then_some(config.base_url.as_deref())
.flatten()
.map(str::trim)
.filter(|value| !value.is_empty())
})
.unwrap_or_else(|| provider.default_base_url())
.trim_end_matches('/')
.to_ascii_lowercase();
.to_string();
ProviderRouteIdentity {
provider,
provider_id: provider_id.to_ascii_lowercase(),
provider_id: provider_id.to_string(),
endpoint,
model: model.trim().to_ascii_lowercase(),
model: model.trim().to_string(),
auth_class: auth_class_for_provider(config, provider),
}
}
@@ -132,6 +133,20 @@ pub(crate) fn credential_state_for_provider(
return CredentialState::Legacy;
}
if provider == ApiProvider::Custom {
if config.uses_legacy_literal_custom_route() {
if config
.base_url
.as_deref()
.is_some_and(crate::config::base_url_uses_local_host)
{
return CredentialState::Local;
}
return if crate::config::has_api_key_for(config, provider) {
CredentialState::Saved
} else {
CredentialState::MissingKey
};
}
let Some(configured) = config.provider_config_for(provider) else {
return CredentialState::MissingKey;
};
@@ -280,6 +295,13 @@ pub(crate) fn route_is_valid_for_model(
saved_provider_model: None,
base_url_override: if provider == ApiProvider::DeepseekCN {
None
} else if provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route() {
config
.base_url
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
} else {
configured
.and_then(|entry| entry.base_url.as_deref())
@@ -546,6 +568,62 @@ mod tests {
);
}
#[test]
fn custom_readiness_identity_preserves_case_sensitive_route_parts() {
let custom = std::collections::HashMap::from([
(
"CUSTOM".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://example.test/TenantA/v1".to_string()),
model: Some("Vendor/ModelA".to_string()),
api_key: Some("test-key-a".to_string()),
..Default::default()
},
),
(
"custom".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://example.test/tenanta/v1".to_string()),
model: Some("vendor/modela".to_string()),
api_key: Some("test-key-b".to_string()),
..Default::default()
},
),
]);
let upper = crate::config::Config {
provider: Some("CUSTOM".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom: custom.clone(),
..Default::default()
}),
..Default::default()
};
let lower = crate::config::Config {
provider: Some("custom".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Default::default()
};
let upper_identity = route_identity_for_model(&upper, ApiProvider::Custom, "Vendor/ModelA");
let lower_identity = route_identity_for_model(&lower, ApiProvider::Custom, "vendor/modela");
assert_ne!(upper_identity, lower_identity);
assert_eq!(upper_identity.provider_id, "CUSTOM");
assert_eq!(upper_identity.endpoint, "https://example.test/TenantA/v1");
assert_eq!(upper_identity.model, "Vendor/ModelA");
let mut checks = ProviderReadinessSnapshot::default();
checks.record_success(&upper, ApiProvider::Custom, "Vendor/ModelA");
assert_eq!(
resolve_for_model(&lower, ApiProvider::Custom, "vendor/modela", &checks),
ResolvedProviderReadiness::SavedUnchecked
);
}
#[test]
fn success_and_provider_failure_replace_session_evidence() {
let config = crate::config::Config::default();
+137 -17
View File
@@ -2,14 +2,105 @@ use codewhale_config::route::{
LogicalModelRef, ReadyRouteCandidate, RouteLimits, RouteRequest, RouteResolver, WireModelId,
};
use crate::client::DeepSeekClient;
use crate::codex_model_cache::{CodexModelCacheFreshness, model_roster};
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL, ProviderIdentity};
#[derive(Debug, Clone)]
#[derive(Clone)]
pub(crate) struct ResolvedRuntimeRoute {
pub(crate) identity: ProviderIdentity,
pub(crate) candidate: ReadyRouteCandidate,
pub(crate) config: Config,
pub(crate) model: String,
preflighted_client: Option<DeepSeekClient>,
}
impl std::fmt::Debug for ResolvedRuntimeRoute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedRuntimeRoute")
.field("provider_identity", &self.identity.key)
.field("provider", &self.identity.provider)
.field("model", &self.model)
.finish_non_exhaustive()
}
}
/// One exact provider route, fully resolved and client-preflighted before a
/// host mutates session/runtime state. The config and client may contain
/// credentials, so diagnostics intentionally expose only non-secret receipt
/// fields.
#[derive(Clone)]
pub(crate) struct ValidatedRuntimeRoute {
pub(crate) identity: ProviderIdentity,
pub(crate) candidate: ReadyRouteCandidate,
pub(crate) config: Box<Config>,
pub(crate) model: String,
pub(crate) client: DeepSeekClient,
}
impl std::fmt::Debug for ValidatedRuntimeRoute {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ValidatedRuntimeRoute")
.field("provider_identity", &self.identity.key)
.field("provider", &self.identity.provider)
.field("model", &self.model)
.finish_non_exhaustive()
}
}
impl ResolvedRuntimeRoute {
pub(crate) fn preflight(mut self) -> Result<Self, String> {
if self.preflighted_client.is_none() {
self.preflighted_client = Some(
DeepSeekClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
format!(
"Failed to configure provider route {} / {}: {err}",
self.identity.key, self.model
)
})?,
);
}
Ok(self)
}
pub(crate) fn validate(mut self) -> Result<ValidatedRuntimeRoute, String> {
let client = match self.preflighted_client.take() {
Some(client) => client,
None => {
DeepSeekClient::from_candidate(&self.config, &self.candidate).map_err(|err| {
format!(
"Failed to configure provider route {} / {}: {err}",
self.identity.key, self.model
)
})?
}
};
Ok(ValidatedRuntimeRoute {
identity: self.identity,
candidate: self.candidate,
config: Box::new(self.config),
model: self.model,
client,
})
}
pub(crate) fn take_preflighted_client(&mut self) -> Option<DeepSeekClient> {
self.preflighted_client.take()
}
}
impl ValidatedRuntimeRoute {
/// Preserve the preflighted client with the exact resolved route receipt
/// so the engine does not repeat environment-sensitive client discovery.
pub(crate) fn into_resolved(self) -> ResolvedRuntimeRoute {
ResolvedRuntimeRoute {
identity: self.identity,
candidate: self.candidate,
config: *self.config,
model: self.model,
preflighted_client: Some(self.client),
}
}
}
pub(crate) fn resolve_route_candidate(
@@ -63,10 +154,30 @@ pub(crate) fn resolve_runtime_route(
provider: ApiProvider,
model_selector: Option<&str>,
) -> Result<ResolvedRuntimeRoute, String> {
let mut route_config = prepared_route_config(config, provider, model_selector);
let saved_provider_model = route_config
.provider_config_for(provider)
.and_then(|provider| provider.model.as_deref());
let identity = if provider == ApiProvider::Custom {
config.active_provider_identity(provider)?
} else {
config
.resolve_persisted_provider_identity(Some(provider.as_str()), Some(provider.as_str()))?
};
resolve_runtime_route_for_identity(config, &identity, model_selector)
}
/// Resolve one persisted/live identity into a scoped runtime config and route
/// candidate. Identity is revalidated against the live registry before any
/// endpoint, model, credential, or client material is read.
pub(crate) fn resolve_runtime_route_for_identity(
config: &Config,
identity: &ProviderIdentity,
model_selector: Option<&str>,
) -> Result<ResolvedRuntimeRoute, String> {
let identity = config.resolve_persisted_provider_identity(
Some(identity.provider.as_str()),
identity.persisted_id(),
)?;
let provider = identity.provider;
let mut route_config = prepared_route_config(config, &identity, model_selector);
let saved_provider_model = configured_model_for_route(&route_config, provider);
let candidate = resolve_route_candidate(
provider,
model_selector,
@@ -75,29 +186,25 @@ pub(crate) fn resolve_runtime_route(
route_config.context_window_for_provider_config(provider),
)?;
let model = candidate.wire_model_id.as_str().to_string();
route_config.provider_config_for_mut(provider).model = Some(model.clone());
set_model_for_route(&mut route_config, provider, &model);
Ok(ResolvedRuntimeRoute {
identity,
candidate,
config: route_config,
model,
preflighted_client: None,
})
}
fn prepared_route_config(
config: &Config,
provider: ApiProvider,
identity: &ProviderIdentity,
model_selector: Option<&str>,
) -> Config {
let mut route_config = config.clone();
// For built-in providers, stamp the canonical provider id. For the dynamic
// custom identity (#1519) the original `provider = "<name>"` IS the lookup
// key into the `[providers.<name>]` flatten map, so it must be preserved —
// overwriting it with the literal "custom" id would break base_url/model
// resolution and silently misroute.
if provider != ApiProvider::Custom {
route_config.provider = Some(provider.as_str().to_string());
}
route_config.scope_to_provider_identity(identity);
let provider = identity.provider;
if matches!(provider, ApiProvider::NvidiaNim)
&& route_config
.base_url
@@ -117,11 +224,24 @@ fn prepared_route_config(
route_config.base_url = None;
}
if let Some(model) = model_selector {
route_config.provider_config_for_mut(provider).model = Some(model.to_string());
set_model_for_route(&mut route_config, provider, model);
}
route_config
}
fn configured_model_for_route(config: &Config, provider: ApiProvider) -> Option<&str> {
if provider == ApiProvider::Custom && config.uses_legacy_literal_custom_route() {
return config.default_text_model.as_deref();
}
config
.provider_config_for(provider)
.and_then(|provider| provider.model.as_deref())
}
fn set_model_for_route(config: &mut Config, provider: ApiProvider, model: &str) {
config.set_provider_model_override(provider, Some(model.to_string()));
}
fn root_base_url_belongs_to_non_deepseek_provider(base_url: &str) -> bool {
let lower = base_url.to_ascii_lowercase();
[
+23 -27
View File
@@ -98,6 +98,9 @@ pub struct RuntimeApiState {
/// GUI-driven config changes target the same file the server was
/// started with, instead of falling back to the default discovery.
config_path: Option<PathBuf>,
/// Effective initial profile (`--profile` or `DEEPSEEK_PROFILE`).
/// Reload must retain this overlay so profile-scoped routes do not vanish.
config_profile: Option<String>,
automations: SharedAutomationManager,
sub_agent_manager: SharedSubAgentManager,
runtime_token: Option<String>,
@@ -140,6 +143,8 @@ pub struct RuntimeApiOptions {
/// `Some`, GUI-driven config reloads and persistence target this file
/// instead of the default discovery path.
pub config_path: Option<PathBuf>,
/// Effective profile used to load the server's initial Config.
pub config_profile: Option<String>,
}
impl Default for RuntimeApiOptions {
@@ -154,6 +159,7 @@ impl Default for RuntimeApiOptions {
mobile: false,
show_qr: false,
config_path: None,
config_profile: None,
}
}
}
@@ -463,6 +469,7 @@ pub async fn run_http_server(
cors_origins: options.cors_origins.clone(),
sessions_dir,
config_path: options.config_path.clone(),
config_profile: options.config_profile.clone(),
automations,
sub_agent_manager,
runtime_token: runtime_token.clone(),
@@ -739,16 +746,6 @@ async fn create_thread(
State(state): State<RuntimeApiState>,
Json(mut req): Json<CreateThreadRequest>,
) -> Result<(StatusCode, Json<ThreadRecord>), ApiError> {
if req.model.as_ref().is_none_or(|m| m.trim().is_empty()) {
req.model = Some(
state
.config
.read()
.default_text_model
.clone()
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string()),
);
}
if req.workspace.is_none() {
req.workspace = Some(state.workspace.clone());
}
@@ -1035,7 +1032,7 @@ async fn stop_fleet_run(
}
fn open_fleet_manager(state: &RuntimeApiState) -> Result<FleetManager, ApiError> {
let (exec_config, session_model) = {
let (exec_config, session_model, route_config) = {
let config = state.config.read();
let exec_config = config
.fleet
@@ -1044,7 +1041,7 @@ fn open_fleet_manager(state: &RuntimeApiState) -> Result<FleetManager, ApiError>
.unwrap_or_default();
// The active session route is the operator: workers without a
// task/profile model pin inherit the model the user picked in /model.
(exec_config, config.default_model())
(exec_config, config.default_model(), config.clone())
};
FleetManager::open(&state.workspace)
.map(|manager| {
@@ -1052,6 +1049,7 @@ fn open_fleet_manager(state: &RuntimeApiState) -> Result<FleetManager, ApiError>
.with_exec_config(exec_config)
.with_sub_agent_manager(state.sub_agent_manager.clone())
.with_session_model(session_model)
.with_route_config(route_config)
})
.map_err(|err| ApiError::internal(format!("Failed to open fleet manager: {err}")))
}
@@ -2592,13 +2590,9 @@ async fn get_config(
let settings = crate::settings::Settings::load_persisted().unwrap_or_default();
let mcp_config_path = config.mcp_config_path().display().to_string();
// Determine effective model: prefer config default, then constant.
let model = config
.default_text_model
.clone()
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
let model = config.default_model();
let provider = config.api_provider().as_str().to_string();
let provider = config.provider_identity_for(config.api_provider());
let approval_mode = config
.approval_policy
.as_deref()
@@ -2824,20 +2818,19 @@ async fn set_config(
async fn reload_config(
State(state): State<RuntimeApiState>,
) -> Result<Json<ReloadConfigResponse>, ApiError> {
let reloaded = Config::load(state.config_path.clone(), None)
let reloaded = Config::load(state.config_path.clone(), state.config_profile.as_deref())
.map_err(|e| ApiError::internal(format!("Failed to reload config: {e}")))?;
state
.runtime_threads
.reload_config(reloaded.clone())
.await
.map_err(|err| ApiError::bad_request(format!("Config reload rejected: {err}")))?;
{
let mut config = state.config.write();
*config = reloaded;
}
// Propagate config to RuntimeThreadManager so model routing uses the new values.
state
.runtime_threads
.reload_config(state.config.read().clone());
// Sync running engines with the new config (model, compaction, timeouts, subagent settings).
state.runtime_threads.sync_engines_with_config().await;
Ok(Json(ReloadConfigResponse {
message: "Config reloaded from disk, propagated to runtime and synced to active engines"
message: "Config reloaded from disk; new turns will resolve the updated provider routes"
.to_string(),
}))
}
@@ -2905,7 +2898,10 @@ fn map_automation_err(err: anyhow::Error) -> ApiError {
fn map_thread_err(err: anyhow::Error) -> ApiError {
let message = err.to_string();
if message.contains("not found") {
let lower = message.to_ascii_lowercase();
if (lower.starts_with("thread '") && lower.ends_with("' not found"))
|| lower.starts_with("thread not found:")
{
ApiError::not_found(message)
} else if message.contains("already has an active turn")
|| message.contains("No active turn")
+105 -16
View File
@@ -139,6 +139,7 @@ pub(super) async fn resume_session_thread(
.create_thread(CreateThreadRequest {
model: Some(model),
model_provider: Some(session.metadata.model_provider.clone()),
model_provider_id: session.metadata.model_provider_id.clone(),
workspace: Some(session.metadata.workspace.clone()),
mode: Some(mode),
allow_shell: None,
@@ -150,7 +151,7 @@ pub(super) async fn resume_session_thread(
..Default::default()
})
.await
.map_err(|e| ApiError::bad_request(format!("Failed to create thread: {e}")))?;
.map_err(map_resume_thread_create_err)?;
let msg_count = session.messages.len();
state
@@ -235,18 +236,16 @@ pub(super) async fn create_session_from_thread(
None,
Some(&detail.thread.mode),
);
session.metadata.model_provider = detail
.thread
.model_provider
.clone()
.or_else(|| {
detail
.turns
.iter()
.rev()
.find_map(|turn| turn.effective_provider.clone())
})
.unwrap_or_else(|| "deepseek".to_string());
{
let config = state.runtime_threads.read_config();
stamp_session_provider_from_thread(&config, &detail, &mut session.metadata).map_err(
|reason| {
ApiError::bad_request(format!(
"Thread {thread_id} provider route is unavailable; session export will not fall back: {reason}"
))
},
)?;
}
session.system_prompt = detail.thread.system_prompt.clone();
if let Some(title) =
@@ -288,6 +287,53 @@ pub(super) async fn create_session_from_thread(
))
}
pub(super) fn stamp_session_provider_from_thread(
config: &crate::config::Config,
detail: &ThreadDetail,
metadata: &mut crate::session_manager::SessionMetadata,
) -> Result<(), String> {
let thread_has_route = detail
.thread
.model_provider
.as_deref()
.is_some_and(|provider| !provider.trim().is_empty())
|| detail
.thread
.model_provider_id
.as_deref()
.is_some_and(|provider| !provider.trim().is_empty());
let provider_identity = if thread_has_route {
config.resolve_persisted_provider_identity(
detail.thread.model_provider.as_deref(),
detail.thread.model_provider_id.as_deref(),
)?
} else if let Some(turn) = detail.turns.iter().rev().find(|turn| {
turn.effective_provider
.as_deref()
.is_some_and(|provider| !provider.trim().is_empty())
|| turn
.effective_provider_id
.as_deref()
.is_some_and(|provider| !provider.trim().is_empty())
}) {
config.resolve_persisted_provider_identity(
turn.effective_provider.as_deref(),
turn.effective_provider_id.as_deref(),
)?
} else {
let key = config
.provider
.as_deref()
.unwrap_or(crate::config::ApiProvider::Deepseek.as_str());
config.resolve_provider_identity(key)?
};
metadata.set_model_provider_route(
provider_identity.provider.as_str(),
provider_identity.persisted_id(),
);
Ok(())
}
fn thread_detail_has_live_work(detail: &ThreadDetail) -> bool {
detail.turns.iter().any(|turn| {
matches!(
@@ -485,7 +531,10 @@ pub(super) async fn save_current_session(
snapshot.system_prompt.as_ref(),
);
updated.metadata.model = snapshot.model.clone();
updated.metadata.model_provider = snapshot.model_provider.clone();
updated.metadata.set_model_provider_route(
&snapshot.model_provider,
snapshot.model_provider_id.as_deref(),
);
updated.metadata.mode = Some(snapshot.mode.clone());
updated
}
@@ -500,7 +549,10 @@ pub(super) async fn save_current_session(
snapshot.system_prompt.as_ref(),
Some(snapshot.mode.as_str()),
);
session.metadata.model_provider = snapshot.model_provider.clone();
session.metadata.set_model_provider_route(
&snapshot.model_provider,
snapshot.model_provider_id.as_deref(),
);
session
} else {
return Err(ApiError::internal(format!(
@@ -518,7 +570,10 @@ pub(super) async fn save_current_session(
snapshot.system_prompt.as_ref(),
Some(snapshot.mode.as_str()),
);
session.metadata.model_provider = snapshot.model_provider.clone();
session.metadata.set_model_provider_route(
&snapshot.model_provider,
snapshot.model_provider_id.as_deref(),
);
session
};
@@ -679,3 +734,37 @@ fn map_session_err(id: &str, err: std::io::Error, action: &str) -> ApiError {
_ => ApiError::internal(format!("Failed to {action} session '{id}': {err}")),
}
}
fn map_resume_thread_create_err(err: anyhow::Error) -> ApiError {
let reason = err.to_string();
let message = format!("Failed to create thread: {reason}");
if reason.starts_with("saved session has an empty provider identity")
|| reason.starts_with("saved session requires custom provider")
|| reason.starts_with("legacy session records only the generic `custom` provider kind")
|| reason.starts_with("legacy `provider = \"custom\"`")
{
ApiError::bad_request(message)
} else {
// Thread-store writes, event persistence, and other runtime failures
// are server-side faults; never disguise them as a client config error.
ApiError::internal(message)
}
}
#[cfg(test)]
mod resume_thread_error_tests {
use super::*;
#[test]
fn provider_config_errors_are_client_errors_but_storage_errors_stay_internal() {
let provider = map_resume_thread_create_err(anyhow::anyhow!(
"saved session requires custom provider 'lm-studio', but `[providers.lm-studio]` is missing"
));
assert_eq!(provider.status, StatusCode::BAD_REQUEST);
let storage = map_resume_thread_create_err(anyhow::anyhow!(
"Failed to save runtime thread: permission denied"
));
assert_eq!(storage.status, StatusCode::INTERNAL_SERVER_ERROR);
}
}
+217 -4
View File
@@ -14,6 +14,15 @@ use uuid::Uuid;
struct MockExecutor;
#[test]
fn thread_route_credential_error_is_bad_request_not_not_found() {
let credential = map_thread_err(anyhow::anyhow!("DeepSeek API key not found"));
assert_eq!(credential.status, StatusCode::BAD_REQUEST);
let missing = map_thread_err(anyhow::anyhow!("thread 'thr_missing' not found"));
assert_eq!(missing.status, StatusCode::NOT_FOUND);
}
#[test]
fn runtime_tui_settings_reject_legacy_modes_and_do_not_save_env_overlays() -> Result<()> {
let _lock = lock_test_env();
@@ -91,6 +100,7 @@ fn saved_session_with_blocks(blocks: Vec<crate::models::ContentBlock>) -> SavedS
total_tokens: 0,
model: "test-model".to_string(),
model_provider: "deepseek".to_string(),
model_provider_id: None,
workspace: PathBuf::from("."),
mode: None,
cost: Default::default(),
@@ -214,6 +224,7 @@ fn messages_from_thread_detail_batches_tool_results() {
updated_at: now,
model: DEFAULT_TEXT_MODEL.to_string(),
model_provider: None,
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
allow_shell: false,
@@ -239,6 +250,7 @@ fn messages_from_thread_detail_batches_tool_results() {
duration_ms: Some(0),
usage: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
effective_model: None,
error: None,
@@ -376,6 +388,71 @@ fn messages_from_thread_detail_batches_tool_results() {
}
}
#[test]
fn legacy_exact_thread_export_normalizes_provider_kind_and_id() {
let now = Utc::now();
let detail = ThreadDetail {
thread: ThreadRecord {
schema_version: 2,
id: "thr_legacy_custom".to_string(),
created_at: now,
updated_at: now,
model: "local-model".to_string(),
// Pre-additive records overloaded this legacy field with the exact id.
model_provider: Some("lm-studio".to_string()),
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
allow_shell: false,
trust_mode: false,
auto_approve: false,
latest_turn_id: None,
latest_response_bookmark: None,
archived: false,
system_prompt: None,
task_id: None,
title: None,
session_id: None,
},
turns: Vec::new(),
items: Vec::new(),
latest_seq: 0,
};
let config = Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom: std::collections::HashMap::from([(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:1234/v1".to_string()),
model: Some("local-model".to_string()),
..Default::default()
},
)]),
..Default::default()
}),
..Default::default()
};
let mut session = crate::session_manager::create_saved_session_with_mode(
&[],
"local-model",
std::path::Path::new("."),
0,
None,
Some("agent"),
);
sessions::stamp_session_provider_from_thread(&config, &detail, &mut session.metadata)
.expect("normalize legacy exact provider");
assert_eq!(session.metadata.model_provider, "custom");
assert_eq!(
session.metadata.model_provider_id.as_deref(),
Some("lm-studio")
);
}
#[test]
fn runtime_auth_generates_token_by_default() {
let auth = resolve_runtime_auth(None, None, false);
@@ -548,7 +625,7 @@ async fn spawn_test_server_with_root_token_mobile_workspace_and_subagents(
mobile_enabled,
workspace,
sub_agent_manager,
None,
(None, None),
)
.await
}
@@ -560,7 +637,7 @@ async fn spawn_test_server_with_root_token_mobile_workspace_subagents_and_config
mobile_enabled: bool,
workspace: PathBuf,
sub_agent_manager: Option<SharedSubAgentManager>,
config_path: Option<PathBuf>,
config_source: (Option<PathBuf>, Option<String>),
) -> Result<
Option<(
SocketAddr,
@@ -568,10 +645,16 @@ async fn spawn_test_server_with_root_token_mobile_workspace_subagents_and_config
tokio::task::JoinHandle<()>,
)>,
> {
let (config_path, config_profile) = config_source;
let _ = rustls::crypto::ring::default_provider().install_default();
fs::create_dir_all(&sessions_dir)?;
fs::create_dir_all(&workspace)?;
let config = Config {
// Runtime-API tests that exercise a real turn boundary must pass the
// same synchronous client preflight as production. Keep the client
// hermetic; any later request fails fast against loopback.
api_key: Some("runtime-api-test-key".to_string()),
base_url: Some("http://127.0.0.1:1/v1".to_string()),
mcp_config_path: Some(root.join("mcp.json").to_string_lossy().to_string()),
..Config::default()
};
@@ -610,6 +693,7 @@ async fn spawn_test_server_with_root_token_mobile_workspace_subagents_and_config
cors_origins: Vec::new(),
sessions_dir,
config_path: config_path.clone(),
config_profile,
mcp_pool: Arc::new(Mutex::new(None)),
automations,
sub_agent_manager,
@@ -667,7 +751,33 @@ async fn spawn_test_server_with_config_path(
false,
workspace,
None,
Some(config_path),
(Some(config_path), None),
)
.await
}
async fn spawn_test_server_with_config_path_and_profile(
config_path: PathBuf,
config_profile: String,
) -> Result<
Option<(
SocketAddr,
SharedRuntimeThreadManager,
tokio::task::JoinHandle<()>,
)>,
> {
let root = std::env::temp_dir().join(format!("codewhale-config-api-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let workspace = root.join("workspace");
fs::create_dir_all(&root)?;
spawn_test_server_with_root_token_mobile_workspace_subagents_and_config_path(
root,
sessions_dir,
None,
false,
workspace,
None,
(Some(config_path), Some(config_profile)),
)
.await
}
@@ -1737,7 +1847,7 @@ async fn thread_endpoints_expose_lifecycle_contract() -> Result<()> {
})
.await;
}
Op::CompactContext => {
Op::CompactContext { .. } => {
let _ = tx_event
.send(EngineEvent::TurnComplete {
usage: Usage {
@@ -2375,6 +2485,63 @@ async fn session_resume_thread_returns_404_for_missing_session() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn session_resume_thread_returns_400_when_saved_custom_provider_was_removed() -> Result<()> {
let root = std::env::temp_dir().join(format!(
"codewhale-session-removed-provider-{}",
Uuid::new_v4()
));
let sessions_dir = root.join("sessions");
fs::create_dir_all(&sessions_dir)?;
let session = json!({
"schema_version": 1,
"metadata": {
"id": "sess_removed_custom_provider",
"title": "Removed custom provider",
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:10:00Z",
"message_count": 1,
"total_tokens": 10,
"model": "local-code-model",
"model_provider": "lm-studio",
"workspace": "/tmp/test",
"mode": "agent"
},
"messages": [{
"role": "user",
"content": [{ "type": "text", "text": "Resume me" }]
}],
"system_prompt": null
});
fs::write(
sessions_dir.join("sess_removed_custom_provider.json"),
serde_json::to_string_pretty(&session)?,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_root(root, sessions_dir).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let resp = client
.post(format!(
"http://{addr}/v1/sessions/sess_removed_custom_provider/resume-thread"
))
.json(&json!({}))
.send()
.await?;
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let body: serde_json::Value = resp.json().await?;
let message = body["error"]["message"].as_str().unwrap_or_default();
assert!(message.contains("[providers.lm-studio]"), "{message}");
assert!(message.contains("will not fall back"), "{message}");
handle.abort();
Ok(())
}
#[tokio::test]
async fn session_resume_thread_creates_thread_from_saved_session() -> Result<()> {
let root = std::env::temp_dir().join(format!("deepseek-session-resume-{}", Uuid::new_v4()));
@@ -4210,6 +4377,52 @@ async fn reload_config_reads_from_config_path_and_updates_in_memory_state() -> R
Ok(())
}
#[tokio::test]
async fn reload_config_preserves_profile_selected_named_custom_route() -> Result<()> {
let root = std::env::temp_dir().join(format!(
"codewhale-config-reload-profile-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root)?;
let config_file = root.join("custom-config.toml");
fs::write(
&config_file,
r#"provider = "deepseek"
default_text_model = "deepseek-v4-pro"
[profiles.local]
provider = "lm-studio"
[profiles.local.providers.lm-studio]
kind = "openai-compatible"
base_url = "http://127.0.0.1:18190/v1"
model = "profile-local-model"
api_key = "profile-test-key"
"#,
)?;
let Some((addr, _runtime_threads, handle)) =
spawn_test_server_with_config_path_and_profile(config_file, "local".to_string()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let response = client
.post(format!("http://{addr}/v1/config/reload"))
.send()
.await?;
assert_eq!(response.status(), StatusCode::OK);
let config = get_config(&client, &addr).await;
assert_eq!(config["provider"], "lm-studio");
assert_eq!(config["model"], "profile-local-model");
assert_eq!(config["base_url"], "http://127.0.0.1:18190/v1");
handle.abort();
Ok(())
}
#[tokio::test]
async fn reload_config_refreshes_mcp_config_path() -> Result<()> {
// Fix #3: After reload, list_mcp_servers should see the new mcp_config_path
+246 -168
View File
@@ -27,8 +27,12 @@ use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::compaction::CompactionConfig;
use crate::config::{ApiProvider, Config, DEFAULT_TEXT_MODEL, MAX_SUBAGENTS, ProviderIdentity};
use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
#[cfg(test)]
use crate::config::DEFAULT_TEXT_MODEL;
use crate::config::{ApiProvider, Config, MAX_SUBAGENTS, ProviderIdentity};
use crate::core::engine::{
EngineConfig, EngineHandle, spawn_engine_with_authoritative_route_config,
};
use crate::core::events::{Event as EngineEvent, TurnOutcomeStatus};
use crate::core::ops::Op;
use crate::models::{ContentBlock, Message, SystemPrompt, Usage};
@@ -36,7 +40,9 @@ use crate::route_budget::{
auto_compact_default_for_route, compaction_threshold_for_route_at_percent, known_route_limits,
route_context_window_tokens,
};
use crate::route_runtime::resolve_runtime_route;
use crate::route_runtime::{
ResolvedRuntimeRoute, resolve_runtime_route, resolve_runtime_route_for_identity,
};
use crate::tools::plan::new_shared_plan_state;
use crate::tools::subagent::SubAgentStatus;
use crate::tools::todo::new_shared_todo_list;
@@ -200,11 +206,13 @@ pub struct ThreadRecord {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub model: String,
/// Exact non-secret provider key for this thread's model route. Built-ins
/// use canonical slugs; named custom routes retain `[providers.<name>]`.
/// Legacy runtime records omit this and continue using the live default.
/// Generic provider kind for this thread's model route. Named custom
/// routes remain `custom` for compatibility with enum-only consumers.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
/// Exact non-secret configured provider key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider_id: Option<String>,
pub workspace: PathBuf,
pub mode: String,
pub allow_shell: bool,
@@ -251,10 +259,12 @@ pub struct TurnRecord {
pub duration_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub usage: Option<Usage>,
/// Concrete provider selected for this turn. Additive so legacy records
/// deserialize without inventing provider provenance.
/// Concrete generic provider kind selected for this turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_provider: Option<String>,
/// Exact non-secret configured provider key selected for this turn.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effective_provider_id: Option<String>,
/// Non-secret discriminator for routes whose provider/model pair spans
/// different billing systems (for example StepFun PAYG vs Step Plan).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -271,6 +281,19 @@ pub struct TurnRecord {
pub steer_count: usize,
}
impl TurnRecord {
pub(crate) fn effective_provider_label(&self) -> Option<&str> {
self.effective_provider_id
.as_deref()
.filter(|identity| !identity.trim().is_empty())
.or_else(|| {
self.effective_provider
.as_deref()
.filter(|provider| !provider.trim().is_empty())
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TurnItemRecord {
#[serde(default = "default_runtime_schema_version")]
@@ -724,8 +747,12 @@ pub enum ThreadListFilter {
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct CreateThreadRequest {
pub model: Option<String>,
/// Generic provider kind or, for legacy clients, an exact provider id.
#[serde(default)]
pub model_provider: Option<String>,
/// Exact configured provider key. Takes precedence over `model_provider`.
#[serde(default)]
pub model_provider_id: Option<String>,
pub workspace: Option<PathBuf>,
pub mode: Option<String>,
pub allow_shell: Option<bool>,
@@ -835,29 +862,22 @@ pub struct UsageAggregation {
pub buckets: Vec<UsageBucket>,
}
#[derive(Debug, Clone)]
struct RuntimeThreadRoute {
provider: ApiProvider,
provider_identity: String,
model: String,
config: Config,
limits: Option<codewhale_config::route::RouteLimits>,
}
fn resolve_runtime_thread_route(
config: &Config,
provider: ApiProvider,
model_selector: Option<&str>,
) -> Result<RuntimeThreadRoute> {
let route = resolve_runtime_route(config, provider, model_selector)
.map_err(|reason| anyhow!("Failed to resolve runtime thread route: {reason}"))?;
Ok(RuntimeThreadRoute {
provider,
provider_identity: route.config.provider_identity_for(provider),
model: route.model,
config: route.config,
limits: known_route_limits(route.candidate.limits),
})
) -> Result<ResolvedRuntimeRoute> {
resolve_runtime_route(config, provider, model_selector)
.map_err(|reason| anyhow!("Failed to resolve runtime thread route: {reason}"))
}
fn resolve_runtime_thread_route_for_identity(
config: &Config,
identity: &ProviderIdentity,
model_selector: Option<&str>,
) -> Result<ResolvedRuntimeRoute> {
resolve_runtime_route_for_identity(config, identity, model_selector)
.map_err(|reason| anyhow!("Failed to resolve runtime thread route: {reason}"))
}
fn runtime_compaction_config(
@@ -898,8 +918,11 @@ struct ActiveTurnState {
struct ActiveThreadState {
engine: EngineHandle,
active_turn: Option<ActiveTurnState>,
route_provider: ApiProvider,
route_identity: ProviderIdentity,
route_model: String,
/// Real engines client-preflight before an in-progress record is written.
/// Explicitly injected test engines own their client seam.
client_preflight_required: bool,
}
#[derive(Default)]
@@ -983,7 +1006,7 @@ pub enum ExternalApprovalDecision {
impl RuntimeThreadManager {
/// Helper to read the current config under RwLock.
fn read_config(&self) -> parking_lot::RwLockReadGuard<'_, Config> {
pub(crate) fn read_config(&self) -> parking_lot::RwLockReadGuard<'_, Config> {
self.config.read()
}
@@ -991,34 +1014,46 @@ impl RuntimeThreadManager {
&self,
config: &Config,
thread: &ThreadRecord,
) -> Result<RuntimeThreadRoute> {
) -> Result<ResolvedRuntimeRoute> {
let provider_identity = self.provider_identity_for_thread(config, thread)?;
let mut thread_config = config.clone();
thread_config.provider = Some(provider_identity.key.clone());
if !thread.model.trim().eq_ignore_ascii_case("auto") {
return resolve_runtime_thread_route(
&thread_config,
provider_identity.provider,
return resolve_runtime_thread_route_for_identity(
config,
&provider_identity,
Some(&thread.model),
);
}
let mut thread_config = config.clone();
thread_config.scope_to_provider_identity(&provider_identity);
let restored = self
.store
.list_turns_for_thread(&thread.id)?
.into_iter()
.rev()
.find_map(|turn| {
let provider = turn
.effective_provider
.as_deref()
.and_then(ApiProvider::parse)?;
let model = turn.effective_model?.trim().to_string();
(!model.is_empty()).then_some((provider, model))
let provider_kind = turn
.effective_provider
.filter(|provider| !provider.trim().is_empty());
let provider_id = turn
.effective_provider_id
.filter(|provider| !provider.trim().is_empty());
((provider_kind.is_some() || provider_id.is_some()) && !model.is_empty())
.then_some((provider_kind, provider_id, model))
});
match restored {
Some((provider, model)) => resolve_runtime_thread_route(config, provider, Some(&model)),
None => resolve_runtime_thread_route(&thread_config, provider_identity.provider, None),
Some((restored_kind, restored_id, model)) => {
let identity = thread_config
.resolve_persisted_provider_identity(
restored_kind.as_deref(),
restored_id.as_deref(),
)
.map_err(|reason| anyhow!(reason))?;
resolve_runtime_thread_route_for_identity(config, &identity, Some(&model))
}
None => resolve_runtime_thread_route_for_identity(config, &provider_identity, None),
}
}
@@ -1027,52 +1062,30 @@ impl RuntimeThreadManager {
config: &Config,
thread: &ThreadRecord,
) -> Result<ProviderIdentity> {
let key = thread.model_provider.as_deref().unwrap_or_else(|| {
config
.provider
let has_persisted_route = thread
.model_provider
.as_deref()
.is_some_and(|provider| !provider.trim().is_empty())
|| thread
.model_provider_id
.as_deref()
.unwrap_or(ApiProvider::Deepseek.as_str())
});
config
.resolve_provider_identity(key)
.map_err(|reason| anyhow!(reason))
}
/// Reload config from an updated Config instance (called after /v1/config/reload).
pub fn reload_config(&self, new_config: Config) {
let mut guard = self.config.write();
*guard = new_config;
}
/// Propagate the current config to all active engines by sending
/// `Op::SetModel`, `Op::SetCompaction`, `Op::SetStreamChunkTimeout`, and
/// `Op::SetSubagentRuntimeConfig`. This mirrors what the TUI does via
/// `apply_model_and_compaction_update` after a config change, ensuring
/// running engines pick up the new settings without a restart.
pub async fn sync_engines_with_config(&self) {
let (
config,
auto_compact,
auto_compact_explicit,
auto_compact_threshold_percent,
stream_chunk_timeout_secs,
) = {
let cfg = self.read_config();
let settings = crate::settings::Settings::load().unwrap_or_default();
(
cfg.clone(),
settings.auto_compact,
crate::settings::Settings::auto_compact_explicitly_configured(),
settings.auto_compact_threshold_percent,
cfg.stream_chunk_timeout_secs(),
.is_some_and(|provider| !provider.trim().is_empty());
let identity = if has_persisted_route {
config.resolve_persisted_provider_identity(
thread.model_provider.as_deref(),
thread.model_provider_id.as_deref(),
)
} else {
config.active_provider_identity(config.api_provider())
};
identity.map_err(|reason| anyhow!(reason))
}
// Keep each already-loaded engine on its actual provider/model route.
// `SetModel` cannot swap the provider client; the next `SendMessage`
// performs provider activation if a turn explicitly selects another
// route.
let entries: Vec<(String, EngineHandle, ApiProvider, String)> = {
/// Atomically replace the authoritative runtime config after preflighting
/// every loaded thread's exact route. Active turns retain their immutable
/// descriptor; the next `start_turn` resolves and installs the new route.
pub async fn reload_config(&self, new_config: Config) -> Result<()> {
let entries: Vec<(String, EngineHandle, ProviderIdentity, String)> = {
let active = self.active.lock().await;
active
.engines
@@ -1081,44 +1094,51 @@ impl RuntimeThreadManager {
(
id.clone(),
state.engine.clone(),
state.route_provider,
state.route_identity.clone(),
state.route_model.clone(),
)
})
.collect()
};
for (thread_id, engine, provider, engine_model) in entries {
let route = match resolve_runtime_thread_route(&config, provider, Some(&engine_model)) {
Ok(route) => route,
Err(err) => {
tracing::warn!(
thread_id = %thread_id,
provider = provider.as_str(),
model = %engine_model,
error = %err,
"Skipped runtime engine route sync"
);
continue;
}
};
let route_limits = route.limits;
let mut validated = Vec::with_capacity(entries.len());
let mut failures = Vec::new();
for (thread_id, engine, provider_identity, engine_model) in entries {
match resolve_runtime_thread_route_for_identity(
&new_config,
&provider_identity,
Some(&engine_model),
) {
Ok(route) => validated.push((thread_id, engine, route)),
Err(err) => failures.push(format!("{thread_id}: {err}")),
}
}
if !failures.is_empty() {
bail!(
"Config reload rejected because active thread routes are invalid: {}",
failures.join("; ")
);
}
{
let mut guard = self.config.write();
*guard = new_config;
}
let settings = crate::settings::Settings::load().unwrap_or_default();
let stream_chunk_timeout_secs = self.read_config().stream_chunk_timeout_secs();
for (thread_id, engine, route) in validated {
let provider = route.identity.provider;
let route_limits = known_route_limits(route.candidate.limits);
let engine_compaction = runtime_compaction_config(
provider,
&route.model,
route_limits,
auto_compact,
auto_compact_explicit,
auto_compact_threshold_percent,
settings.auto_compact,
crate::settings::Settings::auto_compact_explicitly_configured(),
settings.auto_compact_threshold_percent,
);
let _ = engine
.send(Op::SetModel {
model: route.model.clone(),
mode: crate::tui::app::AppMode::Agent,
route_limits,
})
.await;
let route_config = route.config;
let _ = engine
.send(Op::SetCompaction {
config: engine_compaction,
@@ -1131,24 +1151,23 @@ impl RuntimeThreadManager {
.await;
let _ = engine
.send(Op::SetSubagentRuntimeConfig {
enabled: config.subagents_enabled_for_provider(provider),
max_subagents: config
enabled: route_config.subagents_enabled_for_provider(provider),
max_subagents: route_config
.max_subagents_for_provider(provider)
.clamp(1, crate::config::MAX_SUBAGENTS),
launch_concurrency: config.launch_concurrency_for_provider(provider),
max_spawn_depth: config.subagent_max_spawn_depth_for_provider(provider),
api_timeout_secs: config.subagent_api_timeout_secs_for_provider(provider),
heartbeat_timeout_secs: config
launch_concurrency: route_config.launch_concurrency_for_provider(provider),
max_spawn_depth: route_config.subagent_max_spawn_depth_for_provider(provider),
api_timeout_secs: route_config.subagent_api_timeout_secs_for_provider(provider),
heartbeat_timeout_secs: route_config
.subagent_heartbeat_timeout_secs_for_provider(provider),
})
.await;
if let Some(state) = self.active.lock().await.engines.get_mut(&thread_id) {
state.route_model = route.model;
}
tracing::info!(thread_id = %thread_id, "Synced engine with reloaded config");
tracing::info!(
thread_id = %thread_id,
"Reloaded runtime controls; provider route will apply on the next turn"
);
}
Ok(())
}
pub fn open(
@@ -1360,24 +1379,39 @@ impl RuntimeThreadManager {
pub async fn create_thread(&self, req: CreateThreadRequest) -> Result<ThreadRecord> {
let now = Utc::now();
let model_provider = {
let config = self.read_config();
let requested = req.model_provider.as_deref().unwrap_or_else(|| {
config
let (model_provider, model_provider_id, default_model) = {
let config = self.read_config().clone();
let requested_kind = req
.model_provider
.as_deref()
.filter(|provider| !provider.trim().is_empty());
let requested_id = req
.model_provider_id
.as_deref()
.filter(|provider| !provider.trim().is_empty());
let identity = if requested_kind.is_some() || requested_id.is_some() {
config.resolve_persisted_provider_identity(requested_kind, requested_id)
} else {
let selected = config
.provider
.as_deref()
.unwrap_or(ApiProvider::Deepseek.as_str())
});
config
.resolve_provider_identity(requested)
.unwrap_or(ApiProvider::Deepseek.as_str());
config.resolve_provider_identity(selected)
}
.map_err(|reason| anyhow!(reason))?;
let default_model = resolve_runtime_route_for_identity(&config, &identity, None)
.map_err(|reason| anyhow!(reason))?
.key
.model;
(
identity.provider.as_str().to_string(),
identity.exact_id,
default_model,
)
};
let model = req
.model
.filter(|m| !m.trim().is_empty())
.or_else(|| self.read_config().default_text_model.clone())
.unwrap_or_else(|| DEFAULT_TEXT_MODEL.to_string());
.unwrap_or(default_model);
let workspace = req.workspace.unwrap_or_else(|| self.workspace.clone());
let mode = req
.mode
@@ -1396,6 +1430,7 @@ impl RuntimeThreadManager {
updated_at: now,
model,
model_provider: Some(model_provider),
model_provider_id,
workspace,
mode,
allow_shell,
@@ -1482,12 +1517,13 @@ impl RuntimeThreadManager {
.as_deref()
.filter(|model| !model.trim().is_empty())
.unwrap_or(&thread.model);
let provider_label = turn
let provider_kind = turn
.effective_provider
.as_deref()
.filter(|provider| !provider.trim().is_empty())
.unwrap_or("unknown");
let provider = ApiProvider::parse(provider_label);
let provider_label = turn.effective_provider_label().unwrap_or(provider_kind);
let provider = ApiProvider::parse(provider_kind);
let cost = provider
.and_then(|provider| {
crate::pricing::calculate_turn_cost_estimate_for_route_at(
@@ -2220,6 +2256,7 @@ impl RuntimeThreadManager {
duration_ms: Some(0),
usage: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
effective_model: None,
error: None,
@@ -2253,14 +2290,18 @@ impl RuntimeThreadManager {
let mut thread = self.get_thread(thread_id).await?;
let engine = self.ensure_engine_loaded(&thread).await?;
{
let client_preflight_required = {
let active = self.active.lock().await;
if let Some(active_thread) = active.engines.get(thread_id)
&& active_thread.active_turn.is_some()
{
bail!("Thread already has an active turn");
}
}
active
.engines
.get(thread_id)
.is_none_or(|state| state.client_preflight_required)
};
// Resolve the concrete provider/model before persisting a turn. Auto
// routing can fail, and such a failure must not leave a zombie
@@ -2273,10 +2314,13 @@ impl RuntimeThreadManager {
let requested_model = req.model.as_deref().unwrap_or(&thread.model).to_string();
let auto_model = requested_model.trim().eq_ignore_ascii_case("auto");
let cfg_snapshot = self.config.read().clone();
let verbosity = cfg_snapshot.verbosity.clone();
let identity = self.provider_identity_for_thread(&cfg_snapshot, &thread)?;
let mut thread_config = cfg_snapshot.clone();
thread_config.scope_to_provider_identity(&identity);
let verbosity = thread_config.verbosity.clone();
let (route, reasoning_effort) = if auto_model {
let selection = crate::model_routing::resolve_auto_route_with_inventory(
&cfg_snapshot,
&thread_config,
&prompt,
"",
"auto",
@@ -2284,7 +2328,7 @@ impl RuntimeThreadManager {
)
.await?;
let route = resolve_runtime_thread_route(
&cfg_snapshot,
&thread_config,
selection.provider,
Some(&selection.model),
)?;
@@ -2295,22 +2339,26 @@ impl RuntimeThreadManager {
.map(|effort| effort.as_setting().to_string()),
)
} else {
let identity = self.provider_identity_for_thread(&cfg_snapshot, &thread)?;
let mut thread_config = cfg_snapshot.clone();
thread_config.provider = Some(identity.key);
(
resolve_runtime_thread_route(
&thread_config,
identity.provider,
resolve_runtime_thread_route_for_identity(
&cfg_snapshot,
&identity,
Some(&requested_model),
)?,
None,
)
};
let provider = route.provider;
let provider_identity = route.provider_identity.clone();
let model = route.model;
let route_limits = route.limits;
let route = if client_preflight_required {
route
.preflight()
.map_err(|reason| anyhow!("Failed to validate runtime thread route: {reason}"))?
} else {
route
};
let provider = route.identity.provider;
let provider_identity = route.identity.clone();
let model = route.model.clone();
let route_limits = known_route_limits(route.candidate.limits);
let settings = crate::settings::Settings::load().unwrap_or_default();
let compaction = runtime_compaction_config(
provider,
@@ -2337,7 +2385,8 @@ impl RuntimeThreadManager {
ended_at: None,
duration_ms: None,
usage: None,
effective_provider: Some(provider_identity),
effective_provider: Some(provider.as_str().to_string()),
effective_provider_id: provider_identity.exact_id.clone(),
effective_billing_surface: None,
effective_model: Some(model.clone()),
error: None,
@@ -2404,7 +2453,7 @@ impl RuntimeThreadManager {
auto_approve: req.auto_approve.unwrap_or(thread.auto_approve),
trust_mode: req.trust_mode.unwrap_or(thread.trust_mode),
});
state.route_provider = provider;
state.route_identity = provider_identity;
state.route_model.clone_from(&model);
touch_lru(&mut active.lru, thread_id);
}
@@ -2416,9 +2465,7 @@ impl RuntimeThreadManager {
.send(Op::SendMessage {
content: prompt,
mode,
provider: Some(provider),
model: model.clone(),
route_limits,
route: Box::new(route),
compaction: Box::new(compaction),
goal_objective: None,
goal_token_budget: None,
@@ -2601,7 +2648,7 @@ impl RuntimeThreadManager {
let mut thread = self.get_thread(thread_id).await?;
let engine = self.ensure_engine_loaded(&thread).await?;
let (route_provider, route_model) = {
let client_preflight_required = {
let active = self.active.lock().await;
let Some(active_thread) = active.engines.get(thread_id) else {
bail!("Thread engine not loaded");
@@ -2609,11 +2656,29 @@ impl RuntimeThreadManager {
if active_thread.active_turn.is_some() {
bail!("Thread already has an active turn");
}
(
active_thread.route_provider,
active_thread.route_model.clone(),
)
active_thread.client_preflight_required
};
let route = self.resolved_route_for_thread(&self.read_config(), &thread)?;
let route = if client_preflight_required {
route
.preflight()
.map_err(|reason| anyhow!("Failed to validate runtime thread route: {reason}"))?
} else {
route
};
let route_provider = route.identity.provider;
let route_identity = route.identity.clone();
let route_model = route.model.clone();
let route_limits = known_route_limits(route.candidate.limits);
let settings = crate::settings::Settings::load().unwrap_or_default();
let compaction = runtime_compaction_config(
route_provider,
&route_model,
route_limits,
settings.auto_compact,
crate::settings::Settings::auto_compact_explicitly_configured(),
settings.auto_compact_threshold_percent,
);
let now = Utc::now();
let turn_id = format!("turn_{}", &Uuid::new_v4().to_string()[..8]);
@@ -2633,8 +2698,9 @@ impl RuntimeThreadManager {
duration_ms: None,
usage: None,
effective_provider: Some(route_provider.as_str().to_string()),
effective_provider_id: route_identity.exact_id.clone(),
effective_billing_surface: None,
effective_model: Some(route_model),
effective_model: Some(route_model.clone()),
error: None,
item_ids: Vec::new(),
steer_count: 0,
@@ -2656,6 +2722,8 @@ impl RuntimeThreadManager {
auto_approve: thread.auto_approve,
trust_mode: thread.trust_mode,
});
state.route_identity = route_identity;
state.route_model = route_model;
touch_lru(&mut active.lru, thread_id);
}
@@ -2669,7 +2737,10 @@ impl RuntimeThreadManager {
.await?;
engine
.send(Op::CompactContext)
.send(Op::CompactContext {
route: Box::new(route),
compaction: Box::new(compaction),
})
.await
.map_err(|e| anyhow!("Failed to trigger compaction: {e}"))?;
@@ -2737,9 +2808,10 @@ impl RuntimeThreadManager {
// route limits, compaction budget, and restored session all agree.
let base_config = self.read_config().clone();
let route = self.resolved_route_for_thread(&base_config, thread)?;
let provider = route.provider;
let provider = route.identity.provider;
let route_identity = route.identity;
let route_model = route.model;
let route_limits = route.limits;
let route_limits = known_route_limits(route.candidate.limits);
let cfg = route.config;
// Resolve the provider-route-aware auto-compaction default unless the
@@ -2855,7 +2927,11 @@ impl RuntimeThreadManager {
terminal_chrome_enabled: false,
};
let engine = spawn_engine(engine_cfg, &cfg);
let engine = spawn_engine_with_authoritative_route_config(
engine_cfg,
&cfg,
Arc::clone(&self.config),
);
// When the thread has an associated session, load the full message history
// (including thinking/tool blocks) from the session file. This preserves
@@ -2923,8 +2999,9 @@ impl RuntimeThreadManager {
ActiveThreadState {
engine: engine.clone(),
active_turn: None,
route_provider: provider,
route_identity,
route_model,
client_preflight_required: true,
},
);
touch_lru(&mut active.lru, &thread.id);
@@ -4076,8 +4153,9 @@ impl RuntimeThreadManager {
ActiveThreadState {
engine,
active_turn: None,
route_provider: route.provider,
route_identity: route.identity,
route_model: route.model,
client_preflight_required: false,
},
);
touch_lru(&mut active.lru, thread_id);
+759 -20
View File
@@ -51,6 +51,7 @@ fn sample_thread(thread_id: &str) -> ThreadRecord {
updated_at: now,
model: DEFAULT_TEXT_MODEL.to_string(),
model_provider: None,
model_provider_id: None,
workspace: PathBuf::from("."),
mode: AppMode::Agent.as_setting().to_string(),
allow_shell: false,
@@ -80,6 +81,7 @@ fn sample_turn(thread_id: &str, turn_id: &str, status: RuntimeTurnStatus) -> Tur
duration_ms: None,
usage: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
effective_model: None,
error: None,
@@ -117,6 +119,7 @@ fn legacy_turn_record_has_no_invented_route_provenance() {
let mut value = serde_json::to_value(turn).expect("serialize turn");
let object = value.as_object_mut().expect("turn object");
object.remove("effective_provider");
object.remove("effective_provider_id");
object.remove("effective_billing_surface");
object.remove("effective_model");
@@ -160,14 +163,16 @@ async fn named_custom_thread_identity_round_trips_and_fails_closed_when_removed(
})
.await?;
let persisted = manager.get_thread(&thread.id).await?;
assert_eq!(persisted.model_provider.as_deref(), Some("lm-studio"));
assert_eq!(persisted.model_provider.as_deref(), Some("custom"));
assert_eq!(persisted.model_provider_id.as_deref(), Some("lm-studio"));
let serialized = serde_json::to_string(&persisted)?;
assert!(serialized.contains("\"model_provider\":\"lm-studio\""));
assert!(serialized.contains("\"model_provider\":\"custom\""));
assert!(serialized.contains("\"model_provider_id\":\"lm-studio\""));
assert!(!serialized.contains("127.0.0.1:1234"));
let route = manager.resolved_route_for_thread(&config, &persisted)?;
assert_eq!(route.provider, ApiProvider::Custom);
assert_eq!(route.provider_identity, "lm-studio");
assert_eq!(route.identity.provider, ApiProvider::Custom);
assert_eq!(route.identity.key, "lm-studio");
assert_eq!(route.model, "local-code-model");
assert_eq!(route.config.deepseek_base_url(), "http://127.0.0.1:1234/v1");
@@ -183,11 +188,681 @@ async fn named_custom_thread_identity_round_trips_and_fails_closed_when_removed(
.as_object_mut()
.expect("thread object")
.remove("model_provider");
legacy_value
.as_object_mut()
.expect("thread object")
.remove("model_provider_id");
let legacy: ThreadRecord = serde_json::from_value(legacy_value)?;
assert_eq!(legacy.model_provider, None);
Ok(())
}
#[test]
fn legacy_literal_custom_thread_resume_requires_and_keeps_root_route() -> Result<()> {
let config = Config {
provider: Some("custom".to_string()),
base_url: Some("http://127.0.0.1:18180/v1".to_string()),
default_text_model: Some("legacy-default-model".to_string()),
..Config::default()
};
let manager = RuntimeThreadManager::open(
config.clone(),
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let mut persisted = sample_thread("thr_legacy_custom");
persisted.model = "legacy-saved-model".to_string();
persisted.model_provider = Some("custom".to_string());
let restored: ThreadRecord = serde_json::from_str(&serde_json::to_string(&persisted)?)?;
let route = manager.resolved_route_for_thread(&config, &restored)?;
assert_eq!(route.identity.provider, ApiProvider::Custom);
assert_eq!(route.identity.key, "custom");
assert_eq!(route.model, "legacy-saved-model");
assert_eq!(
route.config.deepseek_base_url(),
"http://127.0.0.1:18180/v1"
);
assert!(
route
.config
.providers
.as_ref()
.is_none_or(|providers| !providers.custom.contains_key("custom")),
"route resolution must not synthesize an ambiguous [providers.custom] table"
);
assert_eq!(
route
.config
.resolve_provider_identity("custom")
.map_err(anyhow::Error::msg)?,
crate::config::ProviderIdentity {
provider: ApiProvider::Custom,
key: "custom".to_string(),
exact_id: None,
}
);
let repeated = manager.resolved_route_for_thread(&route.config, &restored)?;
assert_eq!(repeated.identity.key, "custom");
assert_eq!(repeated.model, "legacy-saved-model");
assert_eq!(
repeated.config.deepseek_base_url(),
"http://127.0.0.1:18180/v1"
);
let named_config = {
let mut custom = std::collections::HashMap::new();
custom.insert(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("named-model".to_string()),
..crate::config::ProviderConfig::default()
},
);
Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
}
};
let error = manager
.resolved_route_for_thread(&named_config, &restored)
.expect_err("id-less root record must not migrate to a named table")
.to_string();
assert!(error.contains("root-level"), "{error}");
assert!(error.contains("will not guess or fall back"), "{error}");
Ok(())
}
#[tokio::test]
async fn root_custom_thread_and_turn_writers_omit_exact_id() -> Result<()> {
let config = Config {
provider: Some("custom".to_string()),
base_url: Some("http://127.0.0.1:18180/v1".to_string()),
default_text_model: Some("legacy-root-model".to_string()),
..Config::default()
};
let manager = RuntimeThreadManager::open(
config,
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest {
model: Some("legacy-root-model".to_string()),
..CreateThreadRequest::default()
})
.await?;
assert_eq!(thread.model_provider.as_deref(), Some("custom"));
assert_eq!(thread.model_provider_id, None);
assert!(!serde_json::to_string(&thread)?.contains("model_provider_id"));
let mut harness = install_mock_engine(&manager, &thread.id).await;
let turn = manager
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "keep the root route".to_string(),
..StartTurnRequest::default()
},
)
.await?;
assert_eq!(turn.effective_provider.as_deref(), Some("custom"));
assert_eq!(turn.effective_provider_id, None);
assert!(!serde_json::to_string(&turn)?.contains("effective_provider_id"));
match harness.rx_op.recv().await {
Some(Op::SendMessage { route, .. }) => {
assert_eq!(route.identity.key, "custom");
assert_eq!(route.identity.exact_id, None);
assert_eq!(
route.config.deepseek_base_url(),
"http://127.0.0.1:18180/v1"
);
}
other => panic!("expected root custom send, got {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn real_turn_client_preflight_failure_writes_no_in_progress_record() -> Result<()> {
let mut custom = std::collections::HashMap::new();
custom.insert(
"preflight-failure".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("https://preflight.invalid/v1".to_string()),
model: Some("preflight-model".to_string()),
api_key: Some("test-key".to_string()),
// Client construction rejects this independently of ambient auth,
// keeping the async regression hermetic without a global env lock.
insecure_skip_tls_verify: Some(true),
..crate::config::ProviderConfig::default()
},
);
let manager = RuntimeThreadManager::open(
Config {
provider: Some("preflight-failure".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
},
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest::default())
.await?;
let error = manager
.start_turn(
&thread.id,
StartTurnRequest {
prompt: "must not become a zombie turn".to_string(),
..StartTurnRequest::default()
},
)
.await
.expect_err("missing credentials must fail before turn persistence")
.to_string();
assert!(
error.contains("TLS certificate verification cannot be disabled"),
"{error}"
);
assert!(manager.store.list_turns_for_thread(&thread.id)?.is_empty());
assert_eq!(manager.get_thread(&thread.id).await?.latest_turn_id, None);
Ok(())
}
#[test]
fn legacy_custom_thread_stays_on_root_when_literal_table_coexists() -> Result<()> {
let mut custom = std::collections::HashMap::new();
custom.insert(
"custom".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18182/v1".to_string()),
model: Some("table-model".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("custom".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
default_text_model: Some("legacy-root-model".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let manager = RuntimeThreadManager::open(
config.clone(),
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let mut legacy = sample_thread("thr_ambiguous_legacy_custom");
legacy.model = "legacy-saved-model".to_string();
legacy.model_provider = Some("custom".to_string());
legacy.model_provider_id = None;
let root = manager.resolved_route_for_thread(&config, &legacy)?;
assert_eq!(root.identity.provider, ApiProvider::Custom);
assert_eq!(root.identity.key, "custom");
assert_eq!(root.identity.exact_id, None);
assert_eq!(root.config.deepseek_base_url(), "http://127.0.0.1:18181/v1");
legacy.model_provider_id = Some("custom".to_string());
let exact = manager.resolved_route_for_thread(&config, &legacy)?;
assert_eq!(exact.identity.provider, ApiProvider::Custom);
assert_eq!(exact.identity.key, "custom");
assert_eq!(exact.identity.exact_id.as_deref(), Some("custom"));
assert_eq!(
exact.config.deepseek_base_url(),
"http://127.0.0.1:18182/v1"
);
let root_only = Config {
provider: Some("custom".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
default_text_model: Some("legacy-root-model".to_string()),
..Config::default()
};
let error = manager
.resolved_route_for_thread(&root_only, &legacy)
.expect_err("exact literal table thread must not fall back to root")
.to_string();
assert!(error.contains("[providers.custom]"), "{error}");
assert!(error.contains("will not fall back"), "{error}");
Ok(())
}
#[tokio::test]
async fn thread_records_and_create_requests_preserve_provider_kind_id_pairing() -> Result<()> {
let mut custom = std::collections::HashMap::new();
custom.insert(
"openai".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18183/v1".to_string()),
model: Some("custom-openai-model".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("openai".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let manager = RuntimeThreadManager::open(
config.clone(),
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
for provider_id in [None, Some("openai".to_string())] {
let mut built_in = sample_thread("thr_builtin_openai_collision");
built_in.model_provider = Some("openai".to_string());
built_in.model_provider_id = provider_id;
let error = manager
.resolved_route_for_thread(&config, &built_in)
.expect_err("built-in thread must not route through same-key custom endpoint")
.to_string();
assert!(error.contains("requires built-in 'openai'"), "{error}");
assert!(error.contains("shadows"), "{error}");
}
let mut exact_custom = sample_thread("thr_custom_openai_collision");
exact_custom.model = "custom-openai-model".to_string();
exact_custom.model_provider = Some("custom".to_string());
exact_custom.model_provider_id = Some("openai".to_string());
let route = manager.resolved_route_for_thread(&config, &exact_custom)?;
assert_eq!(route.identity.provider, ApiProvider::Custom);
assert_eq!(route.identity.key, "openai");
assert_eq!(
route.config.deepseek_base_url(),
"http://127.0.0.1:18183/v1"
);
let mut auto_thread = exact_custom.clone();
auto_thread.id = "thr_auto_openai_collision".to_string();
auto_thread.model = "auto".to_string();
manager.store.save_thread(&auto_thread)?;
let mut restored_turn = sample_turn(
&auto_thread.id,
"turn_openai_collision",
RuntimeTurnStatus::Completed,
);
restored_turn.effective_provider = Some("openai".to_string());
restored_turn.effective_provider_id = None;
restored_turn.effective_model = Some("custom-openai-model".to_string());
manager.store.save_turn(&restored_turn)?;
let turn_error = manager
.resolved_route_for_thread(&config, &auto_thread)
.expect_err("restored built-in turn must not be captured by custom endpoint")
.to_string();
assert!(
turn_error.contains("requires built-in 'openai'"),
"{turn_error}"
);
restored_turn.effective_provider = Some("custom".to_string());
restored_turn.effective_provider_id = Some("openai".to_string());
manager.store.save_turn(&restored_turn)?;
let restored_custom = manager.resolved_route_for_thread(&config, &auto_thread)?;
assert_eq!(restored_custom.identity.provider, ApiProvider::Custom);
assert_eq!(restored_custom.identity.key, "openai");
assert_eq!(restored_custom.model, "custom-openai-model");
let request_error = manager
.create_thread(CreateThreadRequest {
model_provider: Some("openai".to_string()),
model_provider_id: Some("openai".to_string()),
..CreateThreadRequest::default()
})
.await
.expect_err("built-in request must fail closed under exact custom shadow")
.to_string();
assert!(
request_error.contains("requires built-in 'openai'"),
"{request_error}"
);
let created = manager
.create_thread(CreateThreadRequest {
model_provider: Some("custom".to_string()),
model_provider_id: Some("openai".to_string()),
..CreateThreadRequest::default()
})
.await?;
assert_eq!(created.model_provider.as_deref(), Some("custom"));
assert_eq!(created.model_provider_id.as_deref(), Some("openai"));
assert_eq!(created.model, "custom-openai-model");
Ok(())
}
#[tokio::test]
async fn config_reload_updates_next_turn_route_without_mutating_engine_route() -> Result<()> {
let mut custom = std::collections::HashMap::new();
custom.insert(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("local-model".to_string()),
api_key: Some("old-local-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let manager = RuntimeThreadManager::open(
config.clone(),
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest {
model: Some("local-model".to_string()),
model_provider: Some("lm-studio".to_string()),
..CreateThreadRequest::default()
})
.await?;
let mut harness = install_mock_engine(&manager, &thread.id).await;
let mut reloaded = config;
let provider = reloaded
.providers
.as_mut()
.and_then(|providers| providers.custom.get_mut("lm-studio"))
.expect("named custom provider");
provider.base_url = Some("http://127.0.0.1:18182/v1".to_string());
provider.api_key = Some("new-local-test-key".to_string());
manager.reload_config(reloaded).await?;
let refreshed = manager.resolved_route_for_thread(&manager.read_config(), &thread)?;
assert_eq!(refreshed.identity.key, "lm-studio");
assert_eq!(
refreshed.config.deepseek_base_url(),
"http://127.0.0.1:18182/v1"
);
for _ in 0..3 {
let op = harness.rx_op.recv().await.expect("runtime control op");
assert!(
matches!(
op,
Op::SetCompaction { .. }
| Op::SetStreamChunkTimeout { .. }
| Op::SetSubagentRuntimeConfig { .. }
),
"reload must not mutate an engine provider route: {op:?}"
);
}
let compact_turn = manager
.compact_thread(
&thread.id,
CompactThreadRequest {
reason: Some("verify refreshed route".to_string()),
},
)
.await?;
assert_eq!(compact_turn.effective_provider.as_deref(), Some("custom"));
assert_eq!(
compact_turn.effective_provider_id.as_deref(),
Some("lm-studio")
);
assert_eq!(compact_turn.effective_model.as_deref(), Some("local-model"));
match harness.rx_op.recv().await {
Some(Op::CompactContext { route, compaction }) => {
assert_eq!(route.identity.key, "lm-studio");
assert_eq!(
route.config.deepseek_base_url(),
"http://127.0.0.1:18182/v1"
);
assert_eq!(compaction.model, "local-model");
assert_eq!(
compaction.effective_context_window,
Some(crate::route_budget::route_context_window_tokens(
ApiProvider::Custom,
"local-model",
crate::route_budget::known_route_limits(route.candidate.limits),
))
);
}
other => panic!("expected typed compact route, got {other:?}"),
}
Ok(())
}
#[tokio::test]
async fn config_sync_reports_removed_named_custom_route_and_keeps_mailbox_clean() -> Result<()> {
let mut custom = std::collections::HashMap::new();
custom.insert(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("local-model".to_string()),
api_key: Some("local-test-key".to_string()),
..crate::config::ProviderConfig::default()
},
);
let config = Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..crate::config::ProvidersConfig::default()
}),
..Config::default()
};
let manager = RuntimeThreadManager::open(
config,
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest {
model: Some("local-model".to_string()),
model_provider: Some("lm-studio".to_string()),
..CreateThreadRequest::default()
})
.await?;
let mut harness = install_mock_engine(&manager, &thread.id).await;
let err = manager
.reload_config(Config::default())
.await
.expect_err("removed named custom route must fail config reload");
let message = err.to_string();
assert!(message.contains(&thread.id), "{message}");
assert!(message.contains("lm-studio"), "{message}");
assert!(harness.rx_op.try_recv().is_err());
Ok(())
}
#[tokio::test]
async fn create_thread_uses_requested_named_custom_provider_default_model() -> Result<()> {
let mut custom = std::collections::HashMap::new();
for (name, base_url, model) in [
("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
] {
custom.insert(
name.to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
..Default::default()
},
);
}
let config = Config {
provider: Some("custom-b".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Default::default()
};
let manager = RuntimeThreadManager::open(
config.clone(),
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest {
model_provider: Some("custom-a".to_string()),
..Default::default()
})
.await?;
assert_eq!(thread.model_provider.as_deref(), Some("custom"));
assert_eq!(thread.model_provider_id.as_deref(), Some("custom-a"));
assert_eq!(thread.model, "model-a");
let route = manager.resolved_route_for_thread(&config, &thread)?;
assert_eq!(route.identity.key, "custom-a");
assert_eq!(
route.config.deepseek_base_url(),
"http://127.0.0.1:18181/v1"
);
Ok(())
}
#[tokio::test]
async fn create_thread_uses_requested_non_current_builtin_default_model() -> Result<()> {
let config = Config {
provider: Some("openrouter".to_string()),
default_text_model: Some(DEFAULT_TEXT_MODEL.to_string()),
..Default::default()
};
let manager = RuntimeThreadManager::open(
config,
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest {
model_provider: Some("zai".to_string()),
..Default::default()
})
.await?;
assert_eq!(thread.model_provider.as_deref(), Some("zai"));
assert_eq!(thread.model, crate::config::DEFAULT_ZAI_MODEL);
Ok(())
}
#[tokio::test]
async fn simultaneous_named_custom_auto_threads_keep_exact_routes() -> Result<()> {
let mut custom = std::collections::HashMap::new();
for (name, base_url, model) in [
("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
] {
custom.insert(
name.to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
..Default::default()
},
);
}
let manager = RuntimeThreadManager::open(
Config {
provider: Some("custom-b".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Default::default()
},
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread_a = manager
.create_thread(CreateThreadRequest {
model: Some("auto".to_string()),
model_provider: Some("custom-a".to_string()),
..Default::default()
})
.await?;
let thread_b = manager
.create_thread(CreateThreadRequest {
model: Some("auto".to_string()),
model_provider: Some("custom-b".to_string()),
..Default::default()
})
.await?;
let mut harness_a = install_mock_engine(&manager, &thread_a.id).await;
let mut harness_b = install_mock_engine(&manager, &thread_b.id).await;
let request_a = manager.start_turn(
&thread_a.id,
StartTurnRequest {
prompt: "route A".to_string(),
..Default::default()
},
);
let request_b = manager.start_turn(
&thread_b.id,
StartTurnRequest {
prompt: "route B".to_string(),
..Default::default()
},
);
let (turn_a, turn_b) = tokio::join!(request_a, request_b);
let turn_a = turn_a?;
let turn_b = turn_b?;
assert_eq!(turn_a.effective_provider.as_deref(), Some("custom"));
assert_eq!(turn_a.effective_provider_id.as_deref(), Some("custom-a"));
assert_eq!(turn_a.effective_model.as_deref(), Some("model-a"));
assert_eq!(turn_b.effective_provider.as_deref(), Some("custom"));
assert_eq!(turn_b.effective_provider_id.as_deref(), Some("custom-b"));
assert_eq!(turn_b.effective_model.as_deref(), Some("model-b"));
match harness_a.rx_op.recv().await {
Some(Op::SendMessage { route, .. }) => {
assert_eq!(route.identity.provider, ApiProvider::Custom);
assert_eq!(route.identity.key, "custom-a");
assert_eq!(route.model, "model-a");
}
other => panic!("expected custom A send, got {other:?}"),
}
match harness_b.rx_op.recv().await {
Some(Op::SendMessage { route, .. }) => {
assert_eq!(route.identity.provider, ApiProvider::Custom);
assert_eq!(route.identity.key, "custom-b");
assert_eq!(route.model, "model-b");
}
other => panic!("expected custom B send, got {other:?}"),
}
Ok(())
}
#[test]
fn turn_record_persists_billing_surface_without_raw_endpoint() {
let mut turn = sample_turn("thr_surface", "turn_surface", RuntimeTurnStatus::Completed);
@@ -347,17 +1022,10 @@ async fn install_mock_engine(
thread_id: &str,
) -> crate::core::engine::MockEngineHandle {
let harness = mock_engine_handle();
let mut active = manager.active.lock().await;
active.engines.insert(
thread_id.to_string(),
ActiveThreadState {
engine: harness.handle.clone(),
active_turn: None,
route_provider: ApiProvider::Deepseek,
route_model: DEFAULT_TEXT_MODEL.to_string(),
},
);
touch_lru(&mut active.lru, thread_id);
manager
.install_test_engine(thread_id, harness.handle.clone())
.await
.expect("install mock engine");
harness
}
@@ -746,8 +1414,13 @@ fn enforce_lru_capacity_does_not_loop_when_all_threads_are_active() {
auto_approve: true,
trust_mode: false,
}),
route_provider: ApiProvider::Deepseek,
route_identity: crate::config::ProviderIdentity {
provider: ApiProvider::Deepseek,
key: "deepseek".to_string(),
exact_id: Some("deepseek".to_string()),
},
route_model: DEFAULT_TEXT_MODEL.to_string(),
client_preflight_required: false,
},
);
active.engines.insert(
@@ -760,8 +1433,13 @@ fn enforce_lru_capacity_does_not_loop_when_all_threads_are_active() {
auto_approve: true,
trust_mode: false,
}),
route_provider: ApiProvider::Deepseek,
route_identity: crate::config::ProviderIdentity {
provider: ApiProvider::Deepseek,
key: "deepseek".to_string(),
exact_id: Some("deepseek".to_string()),
},
route_model: DEFAULT_TEXT_MODEL.to_string(),
client_preflight_required: false,
},
);
active.lru.push_back("thr_a".to_string());
@@ -1357,7 +2035,10 @@ async fn compact_thread_preserves_thread_auto_approve_policy() -> Result<()> {
.compact_thread(&thread.id, CompactThreadRequest::default())
.await?;
assert!(matches!(rx_op.recv().await, Some(Op::CompactContext)));
assert!(matches!(
rx_op.recv().await,
Some(Op::CompactContext { .. })
));
assert_eq!(
manager.active_turn_flags(&thread.id, &turn.id).await,
Some((false, false))
@@ -1366,9 +2047,63 @@ async fn compact_thread_preserves_thread_auto_approve_policy() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn compact_thread_receipt_keeps_exact_named_custom_identity() -> Result<()> {
let mut custom = std::collections::HashMap::new();
custom.insert(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:1234/v1".to_string()),
model: Some("local-code-model".to_string()),
..Default::default()
},
);
let manager = RuntimeThreadManager::open(
Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Default::default()
},
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest::default())
.await?;
let harness = install_mock_engine(&manager, &thread.id).await;
let mut rx_op = harness.rx_op;
let turn = manager
.compact_thread(&thread.id, CompactThreadRequest::default())
.await?;
assert!(matches!(
rx_op.recv().await,
Some(Op::CompactContext { .. })
));
assert_eq!(turn.effective_provider.as_deref(), Some("custom"));
assert_eq!(turn.effective_provider_id.as_deref(), Some("lm-studio"));
Ok(())
}
#[tokio::test]
async fn compact_thread_with_real_engine_reaches_terminal_status() -> Result<()> {
let manager = test_manager(test_runtime_dir())?;
let manager = RuntimeThreadManager::open(
Config {
// This test intentionally crosses the real-engine boundary. Give
// client preflight a hermetic credential and closed-loopback URL;
// the assertion permits the resulting terminal failure.
api_key: Some("runtime-thread-test-key".to_string()),
base_url: Some("http://127.0.0.1:1/v1".to_string()),
..Config::default()
},
PathBuf::from("."),
test_manager_config(test_runtime_dir()),
)?;
let thread = manager
.create_thread(CreateThreadRequest {
model: None,
@@ -2576,7 +3311,7 @@ async fn compaction_lifecycle_emits_item_events_with_compaction_counts() -> Resu
})
.await;
}
Op::CompactContext => {
Op::CompactContext { .. } => {
op_count = op_count.saturating_add(1);
let _ = tx_event
.send(EngineEvent::CompactionStarted {
@@ -2742,6 +3477,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
updated_at: created_at,
model: DEFAULT_TEXT_MODEL.to_string(),
model_provider: None,
model_provider_id: None,
workspace: PathBuf::from("."),
mode: "agent".to_string(),
allow_shell: false,
@@ -2812,6 +3548,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
duration_ms: None,
usage: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
effective_model: None,
error: None,
@@ -2830,6 +3567,7 @@ fn opening_manager_recovers_stale_queued_and_in_progress_work() -> Result<()> {
duration_ms: None,
usage: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
effective_model: None,
error: None,
@@ -3078,6 +3816,7 @@ fn seed_turns_with_user_messages(
duration_ms: Some(0),
usage: None,
effective_provider: None,
effective_provider_id: None,
effective_billing_surface: None,
effective_model: None,
error: None,
+15
View File
@@ -118,6 +118,11 @@ pub struct SessionMetadata {
/// Provider used for the session model. Defaults for legacy saved sessions.
#[serde(default = "default_model_provider")]
pub model_provider: String,
/// Exact configured provider key. This is separate from `model_provider`
/// so old consumers can keep treating that field as the built-in provider
/// kind (`custom` for every named custom route).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider_id: Option<String>,
/// Workspace directory
pub workspace: PathBuf,
/// Optional mode label (agent/plan/etc.)
@@ -144,6 +149,13 @@ fn default_model_provider() -> String {
"deepseek".to_string()
}
impl SessionMetadata {
pub(crate) fn set_model_provider_route(&mut self, kind: &str, identity: Option<&str>) {
self.model_provider = kind.to_string();
self.model_provider_id = identity.map(str::to_string);
}
}
/// Cost and high-water-mark fields persisted with each session.
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct SessionCostSnapshot {
@@ -858,6 +870,7 @@ pub fn create_saved_session_with_id_and_mode(
total_tokens,
model: model.to_string(),
model_provider: default_model_provider(),
model_provider_id: None,
workspace: workspace.to_path_buf(),
mode: mode.map(str::to_string),
cost: SessionCostSnapshot::default(),
@@ -1174,6 +1187,7 @@ mod tests {
total_tokens: 0,
model: "deepseek-v4-flash".to_string(),
model_provider: "deepseek".to_string(),
model_provider_id: None,
workspace: workspace.to_path_buf(),
mode: None,
cost: SessionCostSnapshot::default(),
@@ -1207,6 +1221,7 @@ mod tests {
total_tokens: 0,
model: "deepseek-v4-pro".to_string(),
model_provider: "deepseek".to_string(),
model_provider_id: None,
workspace: workspace.to_path_buf(),
mode: Some("yolo".to_string()),
cost: SessionCostSnapshot::default(),
+82 -38
View File
@@ -1070,7 +1070,14 @@ fn worker_profile_for_spawn(
let mut requested = WorkerRuntimeProfile::for_role(agent_type.clone());
requested.tools = worker_tool_scope(tool_profile);
requested.model = model_route.unwrap_or_else(|| ModelRoute::Fixed(effective_model.to_string()));
requested.provider = Some(runtime.client.api_provider().as_str().to_string());
let provider = runtime.client.api_provider();
requested.provider = Some(
runtime
.api_config
.as_ref()
.map(|config| config.provider_identity_for(provider))
.unwrap_or_else(|| provider.as_str().to_string()),
);
requested.max_spawn_depth = runtime.max_spawn_depth.saturating_sub(runtime.spawn_depth);
requested.background = true;
runtime.worker_profile.derive_child(&requested)
@@ -1770,7 +1777,10 @@ impl SubAgentRuntime {
/// credentials/base URL cannot be resolved. Callers MUST surface that error
/// rather than fall back to the session client: a silent fallback would send
/// the pinned model id to the session provider's endpoint (#4093).
fn client_for_provider_id(&self, provider_id: &str) -> Result<DeepSeekClient, String> {
fn scoped_config_for_provider_id(
&self,
provider_id: &str,
) -> Result<(crate::config::Config, crate::config::ProviderIdentity), String> {
let Some(api_config) = self.api_config.as_ref() else {
return Err(
"session Config was not threaded into this runtime; cannot build a \
@@ -1782,31 +1792,15 @@ impl SubAgentRuntime {
if provider_id.is_empty() {
return Err("provider pin was blank".to_string());
}
let built_in = crate::config::ApiProvider::parse(provider_id);
let custom = built_in.is_none()
&& api_config
.providers
.as_ref()
.and_then(|providers| providers.custom_provider_config(provider_id))
.is_some();
if built_in.is_none() && !custom {
return Err(format!(
"provider '{provider_id}' is neither a built-in provider nor a configured \
[providers.{provider_id}] custom provider"
));
}
let identity = api_config.resolve_provider_identity(provider_id)?;
let mut provider_config = (**api_config).clone();
// EPIC #2608: the provider is taken verbatim from the profile pin
// (built-in id or configured custom id), never inferred from the model
// id. Overriding only `provider` makes `Config::api_provider`,
// `deepseek_base_url`, and `deepseek_api_key` all re-resolve for the
// pinned provider.
provider_config.provider = Some(
built_in
.map(|provider| provider.as_str().to_string())
.unwrap_or_else(|| provider_id.to_string()),
);
DeepSeekClient::new(&provider_config).map_err(|err| err.to_string())
provider_config.provider = Some(identity.key.clone());
Ok((provider_config, identity))
}
/// Install the merged fleet roster (#fleet-roster cutover (v0.8.67)).
@@ -5035,6 +5029,18 @@ async fn wait_result_payload(
fn provider_pin_matches_session(runtime: &SubAgentRuntime, provider_id: &str) -> bool {
let provider_id = provider_id.trim();
let session_provider = runtime.client.api_provider();
if let Some(config) = runtime.api_config.as_ref() {
let Ok(pinned) = config.resolve_provider_identity(provider_id) else {
return false;
};
let active_identity = config.provider_identity_for(session_provider);
if pinned.provider == crate::config::ApiProvider::Custom
|| session_provider == crate::config::ApiProvider::Custom
{
return pinned.provider == session_provider && pinned.key == active_identity;
}
return pinned.provider == session_provider;
}
if let Some(provider) = crate::config::ApiProvider::parse(provider_id) {
return provider == session_provider;
}
@@ -5047,6 +5053,51 @@ fn provider_pin_matches_session(runtime: &SubAgentRuntime, provider_id: &str) ->
.is_some_and(|active| active == provider_id)
}
struct ChildProviderBinding {
client: DeepSeekClient,
api_config: Option<std::sync::Arc<crate::config::Config>>,
}
fn child_provider_binding(
runtime: &SubAgentRuntime,
member: Option<&crate::fleet::profile::AgentProfile>,
) -> Result<ChildProviderBinding, ToolError> {
let session_provider = runtime.client.api_provider();
match crate::fleet::worker_runtime::explicit_fleet_provider_id(member) {
Some(pinned_id) if !provider_pin_matches_session(runtime, &pinned_id) => {
let (scoped_config, _) =
runtime
.scoped_config_for_provider_id(&pinned_id)
.map_err(|err| {
ToolError::execution_failed(format!(
"fleet profile pins provider '{}' but its client could not be built \
({err}). Configure that provider's credentials/base URL, or drop the \
provider pin to inherit the session provider '{}'.",
pinned_id,
session_provider.as_str()
))
})?;
let client = DeepSeekClient::new(&scoped_config).map_err(|err| {
ToolError::execution_failed(format!(
"fleet profile pins provider '{}' but its client could not be built \
({err}). Configure that provider's credentials/base URL, or drop the \
provider pin to inherit the session provider '{}'.",
pinned_id,
session_provider.as_str()
))
})?;
Ok(ChildProviderBinding {
client,
api_config: Some(std::sync::Arc::new(scoped_config)),
})
}
_ => Ok(ChildProviderBinding {
client: runtime.client.clone(),
api_config: runtime.api_config.clone(),
}),
}
}
/// Resolve the LLM client a freshly spawned in-process child should run on,
/// honoring a fleet roster member's explicit provider pin (#4193).
///
@@ -5063,25 +5114,12 @@ fn provider_pin_matches_session(runtime: &SubAgentRuntime, provider_id: &str) ->
/// to the session client (that silent fallback IS the #4093 misroute). The
/// provider comes only from the explicit pin ([`explicit_fleet_provider`]),
/// never inferred from the model id (EPIC #2608).
#[cfg(test)]
fn child_client_for_member(
runtime: &SubAgentRuntime,
member: Option<&crate::fleet::profile::AgentProfile>,
) -> Result<DeepSeekClient, ToolError> {
let session_provider = runtime.client.api_provider();
match crate::fleet::worker_runtime::explicit_fleet_provider_id(member) {
Some(pinned_id) if !provider_pin_matches_session(runtime, &pinned_id) => {
runtime.client_for_provider_id(&pinned_id).map_err(|err| {
ToolError::execution_failed(format!(
"fleet profile pins provider '{}' but its client could not be built \
({err}). Configure that provider's credentials/base URL, or drop the \
provider pin to inherit the session provider '{}'.",
pinned_id,
session_provider.as_str()
))
})
}
_ => Ok(runtime.client.clone()),
}
child_provider_binding(runtime, member).map(|binding| binding.client)
}
async fn spawn_subagent_from_input(
@@ -5125,7 +5163,9 @@ async fn spawn_subagent_from_input(
// `child_runtime.client.api_provider()`, so swapping the client here is what
// actually routes the request to provider B's endpoint with B's creds —
// rather than tagging `provider = B` on a client still pointed at A (#4093).
child_runtime.client = child_client_for_member(&runtime, profile_member.as_ref())?;
let provider_binding = child_provider_binding(&runtime, profile_member.as_ref())?;
child_runtime.client = provider_binding.client;
child_runtime.api_config = provider_binding.api_config;
child_runtime.max_spawn_depth = child_max_spawn_depth_for_spawn(
child_runtime.max_spawn_depth,
child_runtime.spawn_depth,
@@ -5240,7 +5280,11 @@ async fn spawn_subagent_from_input(
.map(|member| member.id.clone())
.or_else(|| spawn_request.profile.clone());
let spawn_metadata = WorkflowTaskSpawnMetadata {
resolved_provider: child_runtime.client.api_provider().as_str().to_string(),
resolved_provider: child_runtime
.api_config
.as_ref()
.map(|config| config.provider_identity_for(child_runtime.client.api_provider()))
.unwrap_or_else(|| child_runtime.client.api_provider().as_str().to_string()),
resolved_model: effective_model.clone(),
route_source: model_selection.source.as_str().to_string(),
resolved_role,
+129
View File
@@ -5278,6 +5278,24 @@ fn cross_provider_config() -> crate::config::Config {
..Default::default()
},
);
for (name, base_url, model) in [
("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
("CUSTOM", "http://127.0.0.1:18183/v1", "model-upper"),
("custom", "http://127.0.0.1:18184/v1", "model-literal"),
("OPENAI", "http://127.0.0.1:18185/v1", "model-openai"),
] {
custom.insert(
name.to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
api_key: Some("local-test-key".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
..Default::default()
},
);
}
let providers = crate::config::ProvidersConfig {
deepseek: crate::config::ProviderConfig {
api_key: Some("session-key".to_string()),
@@ -5385,6 +5403,117 @@ fn spawn_child_client_targets_custom_profile_provider() {
assert_eq!(child_client.base_url(), "http://127.0.0.1:1234/v1");
}
#[test]
fn spawn_child_client_switches_between_exact_named_custom_endpoints() {
let mut config = cross_provider_config();
config.provider = Some("custom-a".to_string());
let client = DeepSeekClient::new(&config).expect("custom A session client");
assert_eq!(client.base_url(), "http://127.0.0.1:18181/v1");
let mut runtime = stub_runtime().with_api_config(config);
runtime.client = client;
let member = member_pinning_provider("custom-b", "model-b");
let child_client =
child_client_for_member(&runtime, Some(&member)).expect("custom B child client builds");
assert_eq!(
child_client.api_provider(),
crate::config::ApiProvider::Custom
);
assert_eq!(child_client.base_url(), "http://127.0.0.1:18182/v1");
}
#[test]
fn cross_custom_child_rebinds_config_receipts_and_grandchild_route_atomically() {
let mut config = cross_provider_config();
config.provider = Some("custom-a".to_string());
let client = DeepSeekClient::new(&config).expect("custom A session client");
let mut runtime = stub_runtime().with_api_config(config);
runtime.client = client;
let member_b = member_pinning_provider("custom-b", "model-b");
let binding_b =
child_provider_binding(&runtime, Some(&member_b)).expect("custom B child provider binding");
let mut child_runtime = runtime.background_runtime();
child_runtime.client = binding_b.client;
child_runtime.api_config = binding_b.api_config;
assert_eq!(child_runtime.client.base_url(), "http://127.0.0.1:18182/v1");
assert_eq!(
child_runtime
.api_config
.as_ref()
.and_then(|config| config.provider.as_deref()),
Some("custom-b")
);
let worker_profile = worker_profile_for_spawn(
&child_runtime,
&SubAgentType::Implementer,
&AgentWorkerToolProfile::Inherited,
"model-b",
None,
);
assert_eq!(worker_profile.provider.as_deref(), Some("custom-b"));
assert!(!provider_pin_matches_session(&child_runtime, "custom-a"));
let member_a = member_pinning_provider("custom-a", "model-a");
let binding_a = child_provider_binding(&child_runtime, Some(&member_a))
.expect("grandchild rebinds to custom A");
assert_eq!(binding_a.client.base_url(), "http://127.0.0.1:18181/v1");
assert_eq!(
binding_a
.api_config
.as_ref()
.and_then(|config| config.provider.as_deref()),
Some("custom-a")
);
}
#[test]
fn spawn_child_client_does_not_collapse_case_colliding_custom_pins() {
let mut config = cross_provider_config();
config.provider = Some("custom-a".to_string());
let client = DeepSeekClient::new(&config).expect("custom A session client");
let mut runtime = stub_runtime().with_api_config(config);
runtime.client = client;
for (provider_id, model, endpoint) in [
("CUSTOM", "model-upper", "http://127.0.0.1:18183/v1"),
("custom", "model-literal", "http://127.0.0.1:18184/v1"),
("OPENAI", "model-openai", "http://127.0.0.1:18185/v1"),
] {
assert!(!provider_pin_matches_session(&runtime, provider_id));
let member = member_pinning_provider(provider_id, model);
let child = child_client_for_member(&runtime, Some(&member))
.expect("case-colliding custom client builds from exact table");
assert_eq!(child.api_provider(), crate::config::ApiProvider::Custom);
assert_eq!(child.base_url(), endpoint);
}
}
#[test]
fn removed_case_colliding_custom_pin_fails_closed() {
let mut config = cross_provider_config();
config.provider = Some("custom-a".to_string());
config
.providers
.as_mut()
.expect("providers")
.custom
.remove("CUSTOM");
let client = DeepSeekClient::new(&config).expect("custom A session client");
let mut runtime = stub_runtime().with_api_config(config);
runtime.client = client;
assert!(!provider_pin_matches_session(&runtime, "CUSTOM"));
let member = member_pinning_provider("CUSTOM", "model-upper");
let err = match child_client_for_member(&runtime, Some(&member)) {
Ok(_) => panic!("removed custom pin must not inherit active custom client"),
Err(err) => err,
};
assert!(err.to_string().contains("CUSTOM"), "{err}");
}
#[test]
fn spawn_child_client_inherits_session_provider_without_pin() {
// Regression: profile-less members and members that pin no provider (or the
+94 -9
View File
@@ -191,6 +191,9 @@ pub struct TurnCacheRecord {
/// API provider used for the turn. This is recorded so cache misses can be
/// correlated with provider/model route changes.
pub provider: Option<ApiProvider>,
/// Exact non-secret configured route key. This distinguishes named custom
/// providers which all share [`ApiProvider::Custom`].
pub provider_identity: Option<String>,
/// Concrete model used for the turn. For auto-model turns this is the
/// routed model, not the literal `auto` setting.
pub model: Option<String>,
@@ -1777,6 +1780,9 @@ pub struct App {
/// Built-ins use their canonical slug; named custom providers retain the
/// user-owned key instead of collapsing to `custom`.
pub(crate) provider_identity: String,
/// Additive exact configured id for persistence. `None` preserves the
/// legacy root-level custom route even when a same-key table appears.
pub(crate) provider_exact_id: Option<String>,
/// Primary provider plus configured fallback providers for this session.
pub provider_chain: Option<ProviderChain>,
/// Per-provider auth/local readiness snapshot for the fallback chain (#2574).
@@ -2662,14 +2668,28 @@ impl App {
.provider
.as_deref()
.is_some_and(|provider| !provider.trim().is_empty());
let mut provider_identity = config.provider_identity_for(provider);
let mut provider_identity_record = config
.active_provider_identity(provider)
.unwrap_or_else(|_| {
let key = config.provider_identity_for(provider);
let exact_id = (!(provider == ApiProvider::Custom
&& config.uses_legacy_literal_custom_route()))
.then(|| key.clone());
crate::config::ProviderIdentity {
provider,
key,
exact_id,
}
});
if !config_explicitly_selects_provider
&& let Some(ref provider_str) = settings.default_provider
&& let Ok(resolved) = config.resolve_provider_identity(provider_str)
{
provider = resolved.provider;
provider_identity = resolved.key;
provider_identity_record = resolved;
}
let provider_identity = provider_identity_record.key;
let provider_exact_id = provider_identity_record.exact_id;
let mut effective_auth_config = config.clone();
effective_auth_config.provider = Some(provider_identity.clone());
let model_ids_passthrough = effective_auth_config.model_ids_pass_through();
@@ -3005,6 +3025,7 @@ impl App {
active_turn: None,
api_provider: provider,
provider_identity,
provider_exact_id,
provider_chain,
provider_readiness,
provider_health: crate::provider_readiness::ProviderReadinessSnapshot::default(),
@@ -6498,13 +6519,31 @@ impl App {
}
}
#[must_use]
pub(crate) fn provider_id_for_persistence(&self) -> Option<&str> {
self.provider_exact_id.as_deref()
}
pub(crate) fn set_provider_identity(
&mut self,
provider: ApiProvider,
identity: impl Into<String>,
) {
let identity = identity.into();
self.api_provider = provider;
self.provider_identity = identity.into();
self.provider_exact_id = (!(provider == ApiProvider::Custom
&& identity.eq_ignore_ascii_case(ApiProvider::Custom.as_str())))
.then(|| identity.clone());
self.provider_identity = identity;
}
pub(crate) fn set_provider_identity_record(
&mut self,
identity: crate::config::ProviderIdentity,
) {
self.api_provider = identity.provider;
self.provider_identity = identity.key;
self.provider_exact_id = identity.exact_id;
}
pub fn accepts_custom_model_ids(&self) -> bool {
@@ -6572,6 +6611,18 @@ impl App {
(self.api_provider, self.model_display_label())
}
/// Exact non-secret route label for user-visible status surfaces.
#[must_use]
pub fn effective_route_identity_display(&self) -> (String, String) {
let (provider, model) = self.effective_route_display();
let identity = if provider == ApiProvider::Custom {
self.provider_identity_for_persistence()
} else {
provider.display_name()
};
(identity.to_string(), model)
}
pub fn reasoning_effort_display_label(&self) -> String {
if self.auto_model || self.reasoning_effort == ReasoningEffort::Auto {
if let Some(effective) = self.last_effective_reasoning_effort {
@@ -6588,14 +6639,48 @@ impl App {
}
pub fn compaction_config(&self) -> CompactionConfig {
let mut config = self.compaction_config_for_route(
self.api_provider,
self.effective_model_for_budget(),
self.active_route_limits,
);
// These cached fields are the active-route compatibility authority and
// are updated together by `update_model_compaction_budget`. Commands
// and embedders may also adjust them directly between route updates.
config.enabled = self.auto_compact;
config.token_threshold = self.compact_threshold;
config
}
/// Build compaction policy from one already-resolved provider route.
///
/// Auto routing can select a provider/model whose context limits differ
/// from the route currently displayed by the app. Callers dispatching that
/// turn must derive every compaction input from the selected descriptor,
/// not from the previous route cached in `App`.
pub(crate) fn compaction_config_for_route(
&self,
provider: ApiProvider,
model: &str,
route_limits: Option<RouteLimits>,
) -> CompactionConfig {
CompactionConfig {
enabled: self.auto_compact,
token_threshold: self.compact_threshold,
model: self.effective_model_for_budget().to_string(),
enabled: if self.auto_compact_user_configured {
self.auto_compact
} else {
crate::route_budget::auto_compact_default_for_route(provider, model, route_limits)
},
token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent(
provider,
model,
route_limits,
self.auto_compact_threshold_percent,
),
model: model.to_string(),
effective_context_window: Some(crate::route_budget::route_context_window_tokens(
self.api_provider,
self.effective_model_for_budget(),
self.active_route_limits,
provider,
model,
route_limits,
)),
..Default::default()
}
+1 -1
View File
@@ -255,7 +255,7 @@ pub(crate) fn maybe_log_provider_wait_incident(app: &mut App) {
idle_secs={} stream_idle_budget_secs={} max_subagents={} \
fanout_running={fanout_running} fanout_total={fanout_total} \
running_agents={} pending_dispatch={pending_dispatch}",
app.api_provider.as_str(),
app.provider_identity_for_persistence(),
app.model,
provider_wait_idle_secs(app),
app.stream_chunk_timeout_secs,
+55 -3
View File
@@ -561,12 +561,14 @@ impl ModelPickerView {
}
fn build_event(&self) -> ViewEvent {
let provider = self
.resolved_provider()
.filter(|provider| *provider != self.initial_provider);
let resolved_provider = self.resolved_provider().unwrap_or(self.initial_provider);
let provider = (resolved_provider != self.initial_provider).then_some(resolved_provider);
let provider_id = (resolved_provider == ApiProvider::Custom)
.then(|| self.route_config.provider_identity_for(resolved_provider));
ViewEvent::ModelPickerApplied {
model: self.resolved_model(),
provider,
provider_id,
effort: self.resolved_effort(),
previous_model: self.previous_model.clone(),
previous_effort: self.initial_effort,
@@ -3246,6 +3248,56 @@ mod tests {
);
}
#[test]
fn named_custom_picker_event_keeps_exact_target_identity() {
let (mut app, mut config, _lock) = create_test_app();
app.set_provider_identity(crate::config::ApiProvider::Custom, "custom-a");
app.model_ids_passthrough = true;
app.model = "model-a".to_string();
app.auto_model = false;
let mut custom = std::collections::HashMap::new();
for (name, base_url, model) in [
("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
] {
custom.insert(
name.to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
api_key: Some("local-test-key".to_string()),
..Default::default()
},
);
}
config.provider = Some("custom-b".to_string());
config.providers = Some(crate::config::ProvidersConfig {
custom,
..Default::default()
});
let mut view = ModelPickerView::new(&app, &config);
view.selected_model_idx = view
.visible_model_rows()
.iter()
.position(|row| row.provider == Some(ApiProvider::Custom) && row.id == "model-b")
.expect("custom B row");
match view.build_event() {
ViewEvent::ModelPickerApplied {
model,
provider,
provider_id,
..
} => {
assert_eq!(model, "model-b");
assert_eq!(provider, None, "both routes share the Custom enum");
assert_eq!(provider_id.as_deref(), Some("custom-b"));
}
other => panic!("expected model picker apply event, got {other:?}"),
}
}
#[test]
fn picker_exposes_saved_model_for_active_provider() {
let (mut app, mut config, _lock) = create_test_app();
+54 -2
View File
@@ -347,7 +347,14 @@ impl ProviderDashboardRow {
config: &Config,
runtime_status: Option<&ProviderRuntimeStatus>,
) -> Self {
Self::from_config_with_provider_id(provider, active, config, None, runtime_status)
Self::from_config_with_provider_id(
provider,
active,
config,
None,
config.provider.as_deref(),
runtime_status,
)
}
fn from_custom_config_with_runtime_status(
@@ -363,6 +370,7 @@ impl ProviderDashboardRow {
active,
&scoped,
Some(provider_id),
config.provider.as_deref(),
runtime_status,
)
}
@@ -372,6 +380,7 @@ impl ProviderDashboardRow {
active: ApiProvider,
config: &Config,
provider_id_override: Option<&str>,
active_provider_id: Option<&str>,
runtime_status: Option<&ProviderRuntimeStatus>,
) -> Self {
let configured = config.provider_config_for(provider);
@@ -403,7 +412,7 @@ impl ProviderDashboardRow {
let is_active = if provider == ApiProvider::Custom {
active == ApiProvider::Custom
&& match provider_id_override {
Some(id) => config.provider.as_deref() == Some(id),
Some(id) => active_provider_id == Some(id),
None => true,
}
} else {
@@ -3747,6 +3756,49 @@ mod tests {
assert_eq!(picker.rows[picker.selected_idx].provider_id, "my_thing");
}
#[test]
fn provider_picker_marks_only_exact_active_custom_row() {
let custom = std::collections::HashMap::from([
(
"custom-a".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18181/v1".to_string()),
model: Some("model-a".to_string()),
api_key: Some("test-key-a".to_string()),
..Default::default()
},
),
(
"custom-b".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:18182/v1".to_string()),
model: Some("model-b".to_string()),
api_key: Some("test-key-b".to_string()),
..Default::default()
},
),
]);
let config = Config {
provider: Some("custom-a".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let rows = custom_provider_dashboard_rows(ApiProvider::Custom, &config, None);
let active_ids: Vec<_> = rows
.iter()
.filter(|row| row.is_active)
.map(|row| row.provider_id.as_str())
.collect();
assert_eq!(active_ids, vec!["custom-a"]);
}
#[test]
fn provider_picker_marks_custom_provider_ready_when_env_auth_is_set() {
let _lock = ENV_LOCK.lock().expect("env lock poisoned");
+1
View File
@@ -1097,6 +1097,7 @@ mod tests {
total_tokens: 100,
model: "deepseek-v4-pro".to_string(),
model_provider: "deepseek".to_string(),
model_provider_id: None,
workspace: std::path::PathBuf::from("/tmp"),
mode: Some("agent".to_string()),
cost: crate::session_manager::SessionCostSnapshot::default(),
+45 -2
View File
@@ -310,7 +310,11 @@ impl SetupRuntimeFacts {
crate::provider_readiness::ResolvedProviderReadiness::SavedLastCheckFailed { .. }
);
let model = app.model_display_label();
let provider = app.api_provider.display_name().to_string();
let provider = if app.api_provider == crate::config::ApiProvider::Custom {
app.provider_identity_for_persistence().to_string()
} else {
app.api_provider.display_name().to_string()
};
let auth = readiness.label().into_owned();
let health = if provider_ready {
format!("{}; route can be attempted", readiness.label())
@@ -331,7 +335,7 @@ impl SetupRuntimeFacts {
};
let provider_result = format!(
"provider={}, model={}, auth={}, health={}",
app.api_provider.as_str(),
app.provider_identity_for_persistence(),
model,
readiness.label(),
if provider_ready {
@@ -4566,6 +4570,45 @@ mod tests {
);
}
#[test]
fn remote_runtime_on_ramp_never_substitutes_deepseek_for_named_custom_route() {
let _guard = crate::test_support::lock_test_env();
let tmp = tempfile::TempDir::new().expect("tempdir");
let workspace = tmp.path().join("workspace");
std::fs::create_dir_all(&workspace).expect("workspace dir");
let codewhale_home = tmp.path().join(".codewhale");
let _home = crate::test_support::EnvVarGuard::set("HOME", tmp.path());
let _userprofile = crate::test_support::EnvVarGuard::set("USERPROFILE", tmp.path());
let _codewhale_home =
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
let mut custom = std::collections::HashMap::new();
custom.insert(
"lm-studio".to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some("http://127.0.0.1:1234/v1".to_string()),
model: Some("local-code-model".to_string()),
..Default::default()
},
);
let config = Config {
provider: Some("lm-studio".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Config::default()
};
let app = App::new(setup_test_options(workspace), &config);
let facts = SetupRuntimeFacts::from_app_config(&app, &config);
let content = remote_runtime_on_ramp_text(Locale::En, &facts);
assert!(content.contains("active route lm-studio"), "{content}");
assert!(content.contains("--provider lm-studio"), "{content}");
assert!(!content.contains("--provider deepseek"), "{content}");
}
#[test]
fn remote_runtime_on_ramp_is_localized_for_shipped_locales() {
let facts = SetupRuntimeFacts {
+4 -15
View File
@@ -26,6 +26,7 @@ impl Default for SetupOperateFacts {
impl SetupOperateFacts {
pub(super) fn from_app_config(app: &App, config: &Config, provider_ready: bool) -> Self {
let provider_identity = app.provider_identity_for_persistence();
let subagents_enabled = config.subagents_enabled_for_provider(app.api_provider);
let max_subagents = config.max_subagents_for_provider(app.api_provider);
let launch_concurrency = config.launch_concurrency_for_provider(app.api_provider);
@@ -51,29 +52,17 @@ impl SetupOperateFacts {
} else if runtime_ready {
format!(
"worker runtime ready for {}; max_subagents={}, launch_concurrency={}, admission={}, max_spawn_depth={}; background dispatch and completion receipts are available",
app.api_provider.as_str(),
max_subagents,
launch_concurrency,
max_admitted,
max_spawn_depth
provider_identity, max_subagents, launch_concurrency, max_admitted, max_spawn_depth
)
} else if runtime_configured {
format!(
"worker runtime configured for {}, but the active provider route is not ready; max_subagents={}, launch_concurrency={}, admission={}, max_spawn_depth={}",
app.api_provider.as_str(),
max_subagents,
launch_concurrency,
max_admitted,
max_spawn_depth
provider_identity, max_subagents, launch_concurrency, max_admitted, max_spawn_depth
)
} else {
format!(
"worker runtime has no launch capacity for {}; max_subagents={}, launch_concurrency={}, admission={}, max_spawn_depth={}",
app.api_provider.as_str(),
max_subagents,
launch_concurrency,
max_admitted,
max_spawn_depth
provider_identity, max_subagents, launch_concurrency, max_admitted, max_spawn_depth
)
};
+6 -4
View File
@@ -22,9 +22,11 @@ impl SetupRemoteFacts {
.map(|bridge| bridge.slug)
.collect::<Vec<_>>();
let provider_count = codewhale_config::ProviderKind::all().len();
let command_provider =
crate::remote_setup::bundle::ProviderInfo::from_slug(app.api_provider.as_str())
.map_or_else(|| "deepseek".to_string(), |provider| provider.slug);
// Keep the exact route identity. Named custom routes are not yet
// representable by the remote bundle registry, so the generated CLI
// command must fail explicitly for that name instead of silently
// substituting DeepSeek's endpoint and credential contract.
let command_provider = app.provider_identity_for_persistence().to_string();
Self {
clouds_result: format!(
@@ -39,7 +41,7 @@ impl SetupRemoteFacts {
),
providers_result: format!(
"{provider_count} providers from the provider registry; active route {} / {}",
app.api_provider.as_str(),
app.provider_identity_for_persistence(),
app.model
),
mode_result: format!(
+730 -167
View File
File diff suppressed because it is too large Load Diff
+18 -3
View File
@@ -1555,11 +1555,26 @@ fn turn_route_lines(app: &App) -> Vec<String> {
.as_ref()
.and_then(|turn| turn.route.as_ref())
{
(route.provider.display_name(), route.model.clone())
let provider = if route.provider == crate::config::ApiProvider::Custom {
route.provider_identity.clone()
} else {
route.provider.display_name().to_string()
};
(provider, route.model.clone())
} else if let Some((provider, model, _auto_model)) = app.pending_turn_route.as_ref() {
(provider.display_name(), model.clone())
let provider = if *provider == crate::config::ApiProvider::Custom {
app.provider_identity_for_persistence().to_string()
} else {
provider.display_name().to_string()
};
(provider, model.clone())
} else {
(app.api_provider.display_name(), app.model.clone())
let provider = if app.api_provider == crate::config::ApiProvider::Custom {
app.provider_identity_for_persistence().to_string()
} else {
app.api_provider.display_name().to_string()
};
(provider, app.model.clone())
};
lines.push(format!("Route: {provider} · {model}"));
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -624,8 +624,8 @@ pub fn render_header(area: Rect, buf: &mut Buffer, app: &App) {
.style(Style::default().bg(app.ui_theme.header_bg))
.render(area, buf);
let (effective_provider, effective_model) = app.effective_route_display();
let route_label = format!("{} · {effective_model}", effective_provider.display_name());
let (effective_provider, effective_model) = app.effective_route_identity_display();
let route_label = format!("{effective_provider} · {effective_model}");
let mut left = vec![
Span::styled(
"cw",
@@ -944,6 +944,21 @@ mod tests {
);
}
#[test]
fn header_shows_exact_named_custom_provider() {
let mut app = test_app();
app.set_provider_identity(crate::config::ApiProvider::Custom, "lm-studio");
app.model = "local-code-model".to_string();
let header = header_text(&app, 100);
assert!(
header.contains("lm-studio · local-code-model"),
"{header:?}"
);
assert!(!header.contains("Custom ·"), "{header:?}");
}
/// The footer consumes the toast system, not the legacy status sink: an
/// informational acknowledgement must leave on its own instead of
/// becoming permanent idle chrome.
+6 -1
View File
@@ -55,8 +55,13 @@ impl OperatorInfo {
} else {
app.model.clone()
};
let provider = if app.api_provider == crate::config::ApiProvider::Custom {
app.provider_identity_for_persistence().to_string()
} else {
app.api_provider.display_name().to_string()
};
Self {
provider: app.api_provider.display_name().to_string(),
provider,
model,
reasoning: app.reasoning_effort_display_label(),
}
+195 -44
View File
@@ -188,20 +188,19 @@ pub struct FleetSetupSnapshot {
/// config / project), so the wizard can say when a chosen role would
/// override an existing roster member.
roster_members: Vec<(String, String)>,
/// `(canonical provider id, model id, readiness label, selectable)` routes for a worker,
/// `(exact provider id, model id, readiness label, selectable)` routes for a worker,
/// drawn from ALL configured providers — not only the active one (#4093).
/// Shown after `inherit` in the Model step so a Fleet worker can be pinned
/// to a route independent of the parent/current provider. The provider id
/// is the canonical [`crate::config::ApiProvider::as_str`] identifier
/// (e.g. `"deepseek"`), not a display label — see
/// [`cross_provider_model_routes`].
/// is a canonical built-in id or the exact named custom table key, not a
/// display label — see [`cross_provider_model_routes`].
available_models: Vec<(String, String, String, bool)>,
}
impl FleetSetupSnapshot {
#[must_use]
pub fn from_app(app: &App, config: &Config) -> Self {
let provider = app.api_provider.display_name().to_string();
let provider = app.effective_route_identity_display().0;
let model = if app.auto_model {
app.last_effective_model
.as_deref()
@@ -261,10 +260,9 @@ impl FleetSetupSnapshot {
/// so the Model step must offer the same cross-provider catalog the model
/// picker does, instead of the active provider's models alone.
///
/// The provider id here is the canonical [`crate::config::ApiProvider::as_str`]
/// identifier (e.g. `"deepseek"`), not a display label — this is the exact
/// value persisted into the saved profile's `provider` field and read back by
/// the loader (#4093), so it must round-trip through `ApiProvider::parse`.
/// The provider id here is the exact non-secret configured route key. Built-ins
/// use their canonical id; named custom routes keep their table key so saved
/// Fleet profiles can rebuild the same child client.
/// Callers derive a human-readable label from it for UI text.
fn cross_provider_model_routes(
config: &Config,
@@ -272,42 +270,95 @@ fn cross_provider_model_routes(
health: &crate::provider_readiness::ProviderReadinessSnapshot,
) -> Vec<(String, String, String, bool)> {
let mut routes = Vec::new();
for provider in crate::provider_lake::configured_providers(config, active) {
// The bundled lake is only the baseline. A user may pin a valid
// provider-specific preview or private deployment outside that
// catalog, and Fleet must offer the same saved route that `/model`
// preserves. Keep saved/active models first, then append the lake.
let mut models = Vec::new();
if let Some(model) = config
.provider_config_for(provider)
.and_then(|entry| entry.model.as_deref())
{
push_unique_model(&mut models, model);
}
if provider == active {
let active_model = config.default_model();
if !active_model.trim().eq_ignore_ascii_case("auto") {
push_unique_model(&mut models, &active_model);
}
}
for model in crate::provider_lake::models_for_provider(config, active, provider) {
push_unique_model(&mut models, &model);
}
let configured = crate::provider_lake::configured_providers(config, active);
let legacy_custom_configured = configured.contains(&crate::config::ApiProvider::Custom);
for provider in configured
.into_iter()
.filter(|provider| *provider != crate::config::ApiProvider::Custom)
{
append_provider_model_routes(
&mut routes,
config,
active,
provider,
provider.as_str(),
health,
);
}
for model in models {
let readiness =
crate::provider_readiness::resolve_for_model(config, provider, &model, health);
routes.push((
provider.as_str().to_string(),
model,
readiness.label().into_owned(),
readiness.can_attempt(),
));
}
// `ApiProvider::Custom` is an enum class, not a route identity. Enumerate
// every named custom table so a Fleet on custom A can still pin a worker
// to custom B and persist B's exact client route.
let mut custom_names = config
.providers
.as_ref()
.map(|providers| providers.custom.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
custom_names.sort();
if custom_names.is_empty() && legacy_custom_configured {
append_provider_model_routes(
&mut routes,
config,
active,
crate::config::ApiProvider::Custom,
crate::config::ApiProvider::Custom.as_str(),
health,
);
}
for name in custom_names {
let mut named_config = config.clone();
named_config.provider = Some(name.clone());
append_provider_model_routes(
&mut routes,
&named_config,
active,
crate::config::ApiProvider::Custom,
&name,
health,
);
}
routes
}
fn append_provider_model_routes(
routes: &mut Vec<(String, String, String, bool)>,
config: &Config,
active: crate::config::ApiProvider,
provider: crate::config::ApiProvider,
provider_id: &str,
health: &crate::provider_readiness::ProviderReadinessSnapshot,
) {
// The bundled lake is only the baseline. A user may pin a valid
// provider-specific preview or private deployment outside that catalog.
let mut models = Vec::new();
if let Some(model) = config
.provider_config_for(provider)
.and_then(|entry| entry.model.as_deref())
{
push_unique_model(&mut models, model);
}
if provider == active {
let active_model = config.default_model();
if !active_model.trim().eq_ignore_ascii_case("auto") {
push_unique_model(&mut models, &active_model);
}
}
for model in crate::provider_lake::models_for_provider(config, active, provider) {
push_unique_model(&mut models, &model);
}
for model in models {
let readiness =
crate::provider_readiness::resolve_for_model(config, provider, &model, health);
routes.push((
provider_id.to_string(),
model,
readiness.label().into_owned(),
readiness.can_attempt(),
));
}
}
fn push_unique_model(models: &mut Vec<String>, model: &str) {
let model = model.trim();
if !model.is_empty()
@@ -319,12 +370,11 @@ fn push_unique_model(models: &mut Vec<String>, model: &str) {
}
}
/// Human-readable label for a canonical provider id, falling back to the raw
/// id verbatim when it doesn't parse (defensive — every id this module hands
/// out itself comes from [`crate::config::ApiProvider::as_str`], so this only
/// matters for a foreign/stale id read back from an old snapshot).
/// Human-readable label for a built-in provider id, falling back to an exact
/// named custom id verbatim.
fn provider_display_label(provider_id: &str) -> String {
crate::config::ApiProvider::parse(provider_id)
.filter(|provider| provider.as_str() == provider_id)
.map(|provider| provider.display_name().to_string())
.unwrap_or_else(|| provider_id.to_string())
}
@@ -1364,6 +1414,13 @@ mod tests {
draft
}
#[test]
fn provider_display_label_preserves_case_colliding_custom_ids() {
assert_eq!(provider_display_label("deepseek"), "DeepSeek");
assert_eq!(provider_display_label("CUSTOM"), "CUSTOM");
assert_eq!(provider_display_label("OPENAI"), "OPENAI");
}
fn to_review(view: &mut FleetSetupView) {
view.handle_key(key(KeyCode::Enter)); // Role -> Model
view.handle_key(key(KeyCode::Enter)); // Model -> Review
@@ -1909,6 +1966,100 @@ mod tests {
);
}
#[test]
fn fleet_routes_and_saved_draft_keep_exact_named_custom_provider() {
let mut custom = std::collections::HashMap::new();
for (name, base_url, model) in [
("custom-a", "http://127.0.0.1:18181/v1", "model-a"),
("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
] {
custom.insert(
name.to_string(),
crate::config::ProviderConfig {
kind: Some("openai-compatible".to_string()),
base_url: Some(base_url.to_string()),
model: Some(model.to_string()),
api_key: Some("local-test-key".to_string()),
..Default::default()
},
);
}
let config = Config {
provider: Some("custom-a".to_string()),
providers: Some(crate::config::ProvidersConfig {
custom,
..Default::default()
}),
..Default::default()
};
let routes = cross_provider_model_routes(
&config,
crate::config::ApiProvider::Custom,
&crate::provider_readiness::ProviderReadinessSnapshot::default(),
);
assert!(
routes
.iter()
.any(|(provider, model, _, _)| { provider == "custom-a" && model == "model-a" })
);
assert!(
routes
.iter()
.any(|(provider, model, _, _)| { provider == "custom-b" && model == "model-b" })
);
assert!(
!routes
.iter()
.any(|(provider, _, _, _)| provider == "custom")
);
let mut view = FleetSetupView::from_snapshot(FleetSetupSnapshot {
available_models: routes,
provider: "custom-a".to_string(),
model: "model-a".to_string(),
..snapshot()
});
let route = view
.model_routes
.iter()
.find(|(provider, model)| provider == "custom-b" && model == "model-b")
.cloned()
.expect("custom B route selectable while A is active");
let draft = sample_draft();
let (_, rendered) =
view.install_model_draft(draft, "model-b".to_string(), Some(route), None);
assert!(rendered.contains("provider = \"custom-b\""), "{rendered}");
}
#[test]
fn fleet_routes_keep_legacy_literal_custom_without_named_tables() {
let config = Config {
provider: Some("custom".to_string()),
base_url: Some("http://127.0.0.1:18080/v1".to_string()),
api_key: Some("local-test-key".to_string()),
default_text_model: Some("legacy-custom-model".to_string()),
..Default::default()
};
let routes = cross_provider_model_routes(
&config,
crate::config::ApiProvider::Custom,
&crate::provider_readiness::ProviderReadinessSnapshot::default(),
);
assert!(
routes
.iter()
.any(|(provider, model, readiness, selectable)| {
provider == "custom"
&& model == "legacy-custom-model"
&& readiness == "local · not checked"
&& *selectable
}),
"{routes:?}"
);
}
#[test]
fn role_step_keeps_list_and_detail_separate_at_80_columns() {
let rows = render_through_stack(|| FleetSetupView::from_snapshot(snapshot()), 80, 24);
+17 -3
View File
@@ -663,6 +663,9 @@ pub enum ViewEvent {
ModelPickerApplied {
model: String,
provider: Option<crate::config::ApiProvider>,
/// Exact named custom route key when the selected provider enum is
/// `Custom`; built-in routes leave this unset.
provider_id: Option<String>,
effort: crate::tui::app::ReasoningEffort,
previous_model: String,
previous_effort: crate::tui::app::ReasoningEffort,
@@ -2143,8 +2146,13 @@ fn config_base_url_row_key(provider: ApiProvider) -> &'static str {
}
}
fn config_provider_row_value(app: &App, _config: &Config) -> String {
app.provider_identity_for_persistence().to_string()
fn config_provider_row_value(app: &App, config: &Config) -> String {
config
.provider
.as_deref()
.filter(|provider| !provider.trim().is_empty())
.unwrap_or_else(|| app.provider_identity_for_persistence())
.to_string()
}
fn config_base_url_row_value(app: &App) -> String {
@@ -2152,7 +2160,13 @@ fn config_base_url_row_value(app: &App) -> String {
.map(|mut config| {
// A named custom provider is represented at runtime as `Custom`,
// but its table lookup still needs the original provider ID.
config.provider = Some(app.provider_identity_for_persistence().to_string());
if config
.provider
.as_deref()
.is_none_or(|provider| provider.trim().is_empty())
{
config.provider = Some(app.provider_identity_for_persistence().to_string());
}
config.deepseek_base_url()
})
.unwrap_or_else(|_| tr(app.ui_locale, MessageId::ConfigUnavailable).to_string())