Skip to content

Commit c8d3513

Browse files
committed
fix: route model picker by provider binding
1 parent dd78f04 commit c8d3513

17 files changed

Lines changed: 571 additions & 95 deletions

crates/cli/src/agent_command.rs

Lines changed: 66 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,12 @@ pub(crate) async fn run_agent(
7070
Some(binding.model_name.clone())
7171
}
7272
});
73+
let model_binding_id = active_model_binding
74+
.as_ref()
75+
.map(|binding| binding.binding_id.clone());
7376
let model = active_model_binding
74-
.map(|binding| binding.model_slug)
77+
.as_ref()
78+
.map(|binding| binding.model_slug.clone())
7579
.unwrap_or(model);
7680

7781
tracing::info!("starting interactive tui");
@@ -81,6 +85,7 @@ pub(crate) async fn run_agent(
8185
session_id: initial_session_id,
8286
model,
8387
request_model,
88+
model_binding_id,
8489
provider: wire_api,
8590
thinking_selection: model_thinking_selection,
8691
permission_preset,
@@ -143,9 +148,9 @@ fn saved_model_entries(app_config: &AppConfig) -> Vec<SavedModelEntry> {
143148
let stored_config = &app_config.provider;
144149
let mut entries = stored_config
145150
.model_bindings
146-
.values()
147-
.filter(|binding| binding.enabled)
148-
.filter_map(|binding| {
151+
.iter()
152+
.filter(|(_, binding)| binding.enabled)
153+
.filter_map(|(binding_id, binding)| {
149154
let provider = stored_config.providers.get(&binding.provider)?;
150155
let request_model = if binding.model_name == binding.model_slug {
151156
None
@@ -156,46 +161,62 @@ fn saved_model_entries(app_config: &AppConfig) -> Vec<SavedModelEntry> {
156161
.display_name
157162
.clone()
158163
.or_else(|| request_model.clone());
164+
let provider_name = provider
165+
.name
166+
.trim()
167+
.is_empty()
168+
.then(|| binding.provider.clone())
169+
.unwrap_or_else(|| provider.name.clone());
159170
Some(SavedModelEntry {
171+
binding_id: Some(binding_id.clone()),
160172
model: binding.model_slug.clone(),
161173
request_model,
162174
display_name,
175+
provider_id: Some(binding.provider.clone()),
176+
provider_name: Some(provider_name),
163177
wire_api: binding.invocation_method,
164178
base_url: provider.base_url.clone(),
165179
api_key: None,
166180
})
167181
})
168182
.collect::<Vec<_>>();
169183

170-
entries.extend(
171-
stored_config
172-
.model_providers
173-
.values()
174-
.flat_map(|provider_config| {
175-
// Older config entries may not have persisted `wire_api`; keep them
176-
// on the historical OpenAI-compatible chat-completions default.
177-
let wire_api = provider_config
178-
.wire_api
179-
.unwrap_or(ProviderWireApi::OpenAIChatCompletions);
180-
provider_config
181-
.models
182-
.iter()
183-
.map(move |model| SavedModelEntry {
184-
model: model.model.clone(),
185-
request_model: None,
186-
display_name: None,
187-
wire_api,
188-
base_url: model
189-
.base_url
190-
.clone()
191-
.or_else(|| provider_config.base_url.clone()),
192-
api_key: model
193-
.api_key
194-
.clone()
195-
.or_else(|| provider_config.api_key.clone()),
196-
})
197-
}),
198-
);
184+
entries.extend(stored_config.model_providers.iter().flat_map(
185+
|(provider_id, provider_config)| {
186+
// Older config entries may not have persisted `wire_api`; keep them
187+
// on the historical OpenAI-compatible chat-completions default.
188+
let wire_api = provider_config
189+
.wire_api
190+
.unwrap_or(ProviderWireApi::OpenAIChatCompletions);
191+
let provider_name = provider_config
192+
.name
193+
.as_deref()
194+
.map(str::trim)
195+
.filter(|name| !name.is_empty())
196+
.map(ToOwned::to_owned)
197+
.unwrap_or_else(|| provider_id.clone());
198+
provider_config
199+
.models
200+
.iter()
201+
.map(move |model| SavedModelEntry {
202+
binding_id: None,
203+
model: model.model.clone(),
204+
request_model: None,
205+
display_name: None,
206+
provider_id: Some(provider_id.clone()),
207+
provider_name: Some(provider_name.clone()),
208+
wire_api,
209+
base_url: model
210+
.base_url
211+
.clone()
212+
.or_else(|| provider_config.base_url.clone()),
213+
api_key: model
214+
.api_key
215+
.clone()
216+
.or_else(|| provider_config.api_key.clone()),
217+
})
218+
},
219+
));
199220
entries
200221
}
201222

@@ -421,17 +442,23 @@ mod tests {
421442
saved_model_entries(&app_config),
422443
vec![
423444
SavedModelEntry {
445+
binding_id: Some("openai".to_string()),
424446
model: "provider-defaults".to_string(),
425447
request_model: None,
426448
display_name: None,
449+
provider_id: Some("openai".to_string()),
450+
provider_name: Some("openai".to_string()),
427451
wire_api: ProviderWireApi::OpenAIResponses,
428452
base_url: Some("https://provider.example".to_string()),
429453
api_key: None,
430454
},
431455
SavedModelEntry {
456+
binding_id: None,
432457
model: "model-overrides".to_string(),
433458
request_model: None,
434459
display_name: None,
460+
provider_id: Some("legacy".to_string()),
461+
provider_name: Some("legacy".to_string()),
435462
wire_api: ProviderWireApi::OpenAIResponses,
436463
base_url: Some("https://model.example".to_string()),
437464
api_key: Some("model-key".to_string()),
@@ -469,9 +496,12 @@ mod tests {
469496
assert_eq!(
470497
saved_model_entries(&app_config),
471498
vec![SavedModelEntry {
499+
binding_id: Some("deepseek".to_string()),
472500
model: "deepseek-v4-flash".to_string(),
473501
request_model: Some("DeepSeek-V4-Flash".to_string()),
474502
display_name: Some("DeepSeek-V4-Flash".to_string()),
503+
provider_id: Some("deepseek".to_string()),
504+
provider_name: Some("deepseek".to_string()),
475505
wire_api: ProviderWireApi::OpenAIChatCompletions,
476506
base_url: Some("https://api.deepseek.com".to_string()),
477507
api_key: None,
@@ -498,9 +528,12 @@ mod tests {
498528
assert_eq!(
499529
saved_model_entries(&app_config),
500530
vec![SavedModelEntry {
531+
binding_id: None,
501532
model: "default-wire-api".to_string(),
502533
request_model: None,
503534
display_name: None,
535+
provider_id: Some("openai".to_string()),
536+
provider_name: Some("openai".to_string()),
504537
wire_api: ProviderWireApi::OpenAIChatCompletions,
505538
base_url: None,
506539
api_key: None,

crates/config/src/provider.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,38 @@ model_name = "gpt-5.4"
169169
assert_eq!(binding, expected_resolved_binding("two"));
170170
}
171171

172+
#[test]
173+
fn enabled_model_binding_resolves_requested_binding_id_before_slug() {
174+
let mut config = provider_config_with_bindings();
175+
config.model_bindings.insert(
176+
"catalog-two".to_string(),
177+
ModelBindingConfig {
178+
enabled: true,
179+
model_slug: "catalog-one".to_string(),
180+
provider: "direct".to_string(),
181+
model_name: "direct/one".to_string(),
182+
invocation_method: ProviderWireApi::OpenAIChatCompletions,
183+
..ModelBindingConfig::default()
184+
},
185+
);
186+
187+
let binding =
188+
resolve_enabled_model_binding(&config, Some("catalog-two")).expect("resolve binding");
189+
190+
assert_eq!(
191+
binding,
192+
ResolvedModelBinding {
193+
binding_id: "catalog-two".to_string(),
194+
model_slug: "catalog-one".to_string(),
195+
model_name: "direct/one".to_string(),
196+
provider_id: "direct".to_string(),
197+
invocation_method: ProviderWireApi::OpenAIChatCompletions,
198+
default_reasoning_effort: None,
199+
enabled: true,
200+
}
201+
);
202+
}
203+
172204
#[test]
173205
fn enabled_model_binding_uses_default_binding_without_requested_model() {
174206
let config = provider_config_with_bindings();

crates/config/src/provider/resolve.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ pub fn resolve_model_binding(
154154
// a configured-but-disabled binding visible so the caller can report
155155
// "binding is disabled" instead of silently selecting another model.
156156
if let Some(requested_model) = requested_model {
157+
if let Some((binding_id, binding)) = config
158+
.model_bindings
159+
.iter()
160+
.find(|(binding_id, _)| binding_id.as_str() == requested_model)
161+
{
162+
return Some(ResolvedModelBinding::from_config(binding_id, binding));
163+
}
164+
157165
return config
158166
.model_bindings
159167
.iter()
@@ -195,9 +203,18 @@ pub fn resolve_enabled_model_binding(
195203
requested_model: Option<&str>,
196204
) -> Option<ResolvedModelBinding> {
197205
// Runtime turn selection only uses enabled bindings. A user-facing model
198-
// override may name either the local catalog slug or the provider wire name:
199-
// e.g. `deepseek-v4-pro` or `deepseek/deepseek-v4-pro`.
206+
// override may name the binding id, the local catalog slug, or the provider
207+
// wire name: e.g. `deepseek-main`, `deepseek-v4-pro`, or
208+
// `deepseek/deepseek-v4-pro`.
200209
if let Some(requested_model) = requested_model {
210+
if let Some((binding_id, binding)) = config
211+
.model_bindings
212+
.iter()
213+
.find(|(binding_id, binding)| binding.enabled && binding_id.as_str() == requested_model)
214+
{
215+
return Some(ResolvedModelBinding::from_config(binding_id, binding));
216+
}
217+
201218
return config
202219
.model_bindings
203220
.iter()

crates/tui/src/app.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub struct InitialTuiSession {
3434
pub model: String,
3535
/// Optional provider-specific model name used for requests when it differs from `model`.
3636
pub request_model: Option<String>,
37+
/// Active model binding id when startup resolved one from `[model_bindings]`.
38+
pub model_binding_id: Option<String>,
3739
/// Provider family used for the initial runtime connection and picker fallback.
3840
pub provider: ProviderWireApi,
3941
/// Initial thinking selection restored from persisted config.

crates/tui/src/bottom_pane/mod.rs

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ use ratatui::text::Line;
2020
use ratatui::text::Span;
2121
use ratatui::widgets::Paragraph;
2222
use ratatui::widgets::Widget;
23+
use unicode_width::UnicodeWidthStr;
2324

2425
mod approval_overlay;
2526
pub(crate) mod bottom_pane_view;
@@ -195,9 +196,10 @@ pub(crate) enum InputResult {
195196

196197
#[derive(Debug, Clone, PartialEq, Eq)]
197198
pub(crate) struct ModelPickerEntry {
198-
pub(crate) slug: String,
199+
pub(crate) selection_value: String,
199200
pub(crate) display_name: String,
200201
pub(crate) description: Option<String>,
202+
pub(crate) right_hint: Option<String>,
201203
pub(crate) is_current: bool,
202204
}
203205

@@ -1038,11 +1040,11 @@ impl ModelPickerView {
10381040
self.selected_model = self
10391041
.entries
10401042
.get(self.selection)
1041-
.map(|entry| entry.slug.clone());
1043+
.map(|entry| entry.selection_value.clone());
10421044
self.complete = true;
10431045
}
10441046

1045-
fn render_lines(&self) -> Vec<Line<'static>> {
1047+
fn render_lines(&self, width: u16) -> Vec<Line<'static>> {
10461048
let mut lines: Vec<Line<'static>> = Vec::new();
10471049
for (index, entry) in self.entries.iter().enumerate() {
10481050
let is_selected = index == self.selection;
@@ -1058,17 +1060,33 @@ impl ModelPickerView {
10581060
} else {
10591061
Style::default()
10601062
};
1061-
let mut title = Line::from(vec![
1063+
let label_style = if is_selected {
1064+
Style::default().fg(self.accent_color).bold()
1065+
} else if !entry.is_current {
1066+
Style::default().dim()
1067+
} else {
1068+
Style::default()
1069+
};
1070+
let mut title_spans = vec![
10621071
Span::styled(marker.to_string(), marker_style),
10631072
Span::raw(" "),
1064-
Span::raw(entry.display_name.clone()),
1065-
]);
1066-
if is_selected {
1067-
title = title.style(Style::default().fg(self.accent_color).bold());
1068-
} else if !entry.is_current {
1069-
title = title.dim();
1073+
Span::styled(entry.display_name.clone(), label_style),
1074+
];
1075+
if let Some(right_hint) = entry
1076+
.right_hint
1077+
.as_deref()
1078+
.map(str::trim)
1079+
.filter(|right_hint| !right_hint.is_empty())
1080+
{
1081+
let title_width = Line::from(title_spans.clone()).width();
1082+
let right_hint_width = UnicodeWidthStr::width(right_hint);
1083+
let padding = usize::from(width)
1084+
.saturating_sub(title_width + right_hint_width)
1085+
.max(2);
1086+
title_spans.push(Span::raw(" ".repeat(padding)));
1087+
title_spans.push(Span::styled(right_hint.to_string(), Style::default().dim()));
10701088
}
1071-
lines.push(title);
1089+
lines.push(Line::from(title_spans));
10721090
if let Some(description) = entry.description.as_deref()
10731091
&& !description.trim().is_empty()
10741092
{
@@ -1106,10 +1124,10 @@ impl BottomPaneView for ModelPickerView {
11061124

11071125
impl Renderable for ModelPickerView {
11081126
fn render(&self, area: Rect, buf: &mut Buffer) {
1109-
Paragraph::new(self.render_lines()).render(area, buf);
1127+
Paragraph::new(self.render_lines(area.width)).render(area, buf);
11101128
}
11111129

1112-
fn desired_height(&self, _width: u16) -> u16 {
1113-
u16::try_from(self.render_lines().len()).unwrap_or(u16::MAX)
1130+
fn desired_height(&self, width: u16) -> u16 {
1131+
u16::try_from(self.render_lines(width).len()).unwrap_or(u16::MAX)
11141132
}
11151133
}

0 commit comments

Comments
 (0)