use std::collections::HashMap;
use murmur_artifact::{InferenceConfig, PRIMARY_DRIVER_CHOICE};
use serde_json::{json, Value};
use crate::model_limits::{ChoiceModelLimits, ResolvedModelLimits};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DriverChoice {
pub(crate) name: String,
pub(crate) driver: String,
pub(crate) artifact: String,
pub(crate) model: String,
pub(crate) credential_source: &'static str,
pub(crate) injected_secret: Option<String>,
pub(crate) unresolved_credential: Option<String>,
pub(crate) limits: Option<ResolvedModelLimits>,
}
impl DriverChoice {
pub(crate) fn staged(&self) -> bool {
self.unresolved_credential.is_none()
}
pub(crate) fn producer(&self) -> Value {
json!({"driver": self.driver, "model": self.model})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct DriverChoices {
choices: Vec<DriverChoice>,
}
impl DriverChoices {
pub(crate) fn declared(
inference: &InferenceConfig,
full_names: &HashMap<String, String>,
) -> Self {
let full = |driver: &str| crate::tool_names::full_name_of(full_names, driver).to_string();
let primary_driver = inference
.driver
.as_ref()
.map(|driver| driver.artifact.clone())
.unwrap_or_default();
let primary = DriverChoice {
name: PRIMARY_DRIVER_CHOICE.to_string(),
artifact: full(&primary_driver),
driver: primary_driver,
model: inference.model.clone(),
credential_source: "none",
injected_secret: None,
unresolved_credential: None,
limits: None,
};
let alternates = inference.alternates.iter().map(|alternate| DriverChoice {
name: alternate.name.clone(),
driver: alternate.driver.clone(),
artifact: full(&alternate.driver),
model: alternate.model.clone(),
credential_source: "none",
injected_secret: None,
unresolved_credential: None,
limits: None,
});
Self {
choices: std::iter::once(primary).chain(alternates).collect(),
}
}
pub(crate) fn record_credential(
&mut self,
driver: &str,
credential_source: &'static str,
injected_secret: Option<&str>,
unresolved_credential: Option<&str>,
) {
for choice in self
.choices
.iter_mut()
.filter(|choice| choice.driver == driver)
{
choice.credential_source = credential_source;
choice.injected_secret = injected_secret.map(str::to_string);
choice.unresolved_credential = unresolved_credential.map(str::to_string);
}
}
pub(crate) fn record_limits(&mut self, resolved: &[ChoiceModelLimits]) {
for choice in &mut self.choices {
choice.limits = resolved
.iter()
.find(|entry| entry.choice == choice.name)
.map(|entry| entry.limits);
}
}
pub(crate) fn primary(&self) -> &DriverChoice {
&self.choices[0]
}
pub(crate) fn get(&self, index: usize) -> Option<&DriverChoice> {
self.choices.get(index)
}
pub(crate) fn index_of(&self, name: &str) -> Option<usize> {
self.choices.iter().position(|choice| choice.name == name)
}
pub(crate) fn has_alternates(&self) -> bool {
self.choices.len() > 1
}
pub(crate) fn iter(&self) -> impl Iterator<Item = &DriverChoice> {
self.choices.iter()
}
pub(crate) fn names(&self) -> String {
self.choices
.iter()
.map(|choice| choice.name.as_str())
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn listing(&self, available: impl Fn(&DriverChoice) -> bool) -> Vec<Value> {
self.choices
.iter()
.map(|choice| {
json!({
"name": choice.name,
"driver": choice.artifact,
"model": choice.model,
"available": available(choice),
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use murmur_artifact::{InferenceAlternate, InferenceDriver, ToolRefresh};
fn inference() -> InferenceConfig {
InferenceConfig {
transport: "http".to_string(),
model: "claude-sonnet-4-5".to_string(),
driver: Some(InferenceDriver {
artifact: "driver-anthropic".to_string(),
config: None,
}),
command: None,
compaction: None,
system_prompt: None,
system_prompt_file: None,
system_prompt_artifact: None,
instruction_files: None,
max_turns: Some(10),
max_tokens: None,
max_session_tokens: None,
tool_refresh: ToolRefresh::Compaction,
alternates: vec![
InferenceAlternate {
name: "haiku".to_string(),
model: "claude-haiku-4-5".to_string(),
driver: "driver-anthropic".to_string(),
},
InferenceAlternate {
name: "gpt".to_string(),
model: "gpt-5".to_string(),
driver: "driver-openai".to_string(),
},
],
retry: murmur_artifact::InferenceRetry::default(),
}
}
fn full_names() -> HashMap<String, String> {
HashMap::from([
(
"driver-anthropic".to_string(),
"murmur/driver-anthropic".to_string(),
),
(
"driver-openai".to_string(),
"murmur/driver-openai".to_string(),
),
])
}
#[test]
fn driver_choice_table_puts_primary_first_then_alternates_in_order() {
let choices = DriverChoices::declared(&inference(), &full_names());
let names: Vec<&str> = choices.iter().map(|choice| choice.name.as_str()).collect();
assert_eq!(names, vec!["primary", "haiku", "gpt"]);
assert_eq!(choices.primary().driver, "driver-anthropic");
assert_eq!(choices.primary().artifact, "murmur/driver-anthropic");
assert_eq!(choices.get(2).unwrap().artifact, "murmur/driver-openai");
assert_eq!(choices.primary().model, "claude-sonnet-4-5");
assert_eq!(choices.index_of("gpt"), Some(2));
assert_eq!(choices.index_of("driver-openai"), None);
assert!(choices.has_alternates());
assert_eq!(choices.names(), "primary, haiku, gpt");
}
#[test]
fn driver_choice_without_alternates_is_the_primary_alone() {
let mut bare = inference();
bare.alternates.clear();
let choices = DriverChoices::declared(&bare, &HashMap::new());
assert!(!choices.has_alternates());
assert_eq!(choices.get(1), None);
}
#[test]
fn driver_choice_credential_is_recorded_for_every_choice_on_the_driver() {
let mut choices = DriverChoices::declared(&inference(), &full_names());
choices.record_credential("driver-anthropic", "config", None, None);
choices.record_credential("driver-openai", "none", None, Some("OPENAI_API_KEY"));
assert_eq!(choices.get(1).unwrap().credential_source, "config");
assert!(choices.get(1).unwrap().staged());
let gpt = choices.get(2).unwrap();
assert!(!gpt.staged());
assert_eq!(gpt.unresolved_credential.as_deref(), Some("OPENAI_API_KEY"));
assert_eq!(
gpt.producer(),
json!({"driver": "driver-openai", "model": "gpt-5"})
);
assert_eq!(
choices.listing(DriverChoice::staged),
vec![
json!({"name": "primary", "driver": "murmur/driver-anthropic", "model": "claude-sonnet-4-5", "available": true}),
json!({"name": "haiku", "driver": "murmur/driver-anthropic", "model": "claude-haiku-4-5", "available": true}),
json!({"name": "gpt", "driver": "murmur/driver-openai", "model": "gpt-5", "available": false}),
]
);
}
}