use crate::config::Config;
use crate::session::Session;
use super::AnyClient;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelRoute {
Active(String),
Provider { alias: String, model: String },
Unroutable { model: String, family: String },
}
impl ModelRoute {
pub fn model(&self) -> &str {
match self {
ModelRoute::Active(model)
| ModelRoute::Provider { model, .. }
| ModelRoute::Unroutable { model, .. } => model,
}
}
pub fn pinned(alias: impl Into<String>, model: impl Into<String>) -> Self {
ModelRoute::Provider {
alias: alias.into(),
model: model.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteRefusal {
NoProviderForFamily { model: String, family: String },
ProviderBuildFailed {
alias: String,
model: String,
error: String,
},
}
impl std::fmt::Display for RouteRefusal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RouteRefusal::NoProviderForFamily { model, family } => write!(
f,
"'{model}' matches the {family} model family, but no {family} provider is \
configured. Add a provider of type {family} to config.json to use it."
),
RouteRefusal::ProviderBuildFailed {
alias,
model,
error,
} => write!(
f,
"'{model}' routes to provider '{alias}', which failed to build ({error})."
),
}
}
}
pub fn resolve_model_route(cfg: &Config, active_provider: &str, model: &str) -> ModelRoute {
use super::ModelSwitch;
let model = model.to_string();
match super::resolve_model_switch(&cfg.providers_map(), active_provider, &model) {
ModelSwitch::Keep => ModelRoute::Active(model),
ModelSwitch::Switch(alias) => ModelRoute::Provider { alias, model },
ModelSwitch::NoProviderForFamily(family) => ModelRoute::Unroutable { model, family },
}
}
pub fn build_route_client(
cfg: &Config,
alias: &str,
model: &str,
) -> Result<AnyClient, RouteRefusal> {
super::create_client_with_auth(alias, None, &cfg.providers_map(), cfg.auth).map_err(|e| {
RouteRefusal::ProviderBuildFailed {
alias: alias.to_string(),
model: model.to_string(),
error: e.to_string(),
}
})
}
pub fn swap_client_for_route(
cfg: &Config,
client: &mut AnyClient,
active_provider: &str,
route: &ModelRoute,
) -> Result<Option<String>, RouteRefusal> {
match route {
ModelRoute::Active(_) => Ok(None),
ModelRoute::Provider { alias, .. } if alias.eq_ignore_ascii_case(active_provider) => {
Ok(None)
}
ModelRoute::Provider { alias, model } => {
let built = build_route_client(cfg, alias, model)?;
*client = built;
tracing::info!(
target: "dirge::provider",
alias = %alias,
model = %model,
"model routed to a different provider; client rebuilt",
);
Ok(Some(alias.clone()))
}
ModelRoute::Unroutable { model, family } => Err(RouteRefusal::NoProviderForFamily {
model: model.clone(),
family: family.clone(),
}),
}
}
pub fn apply_model_route(
cfg: &Config,
client: &mut AnyClient,
session: &mut Session,
route: ModelRoute,
) -> Result<Option<String>, RouteRefusal> {
let switched_to = swap_client_for_route(cfg, client, session.provider.as_str(), &route)?;
session.model = compact_str::CompactString::new(route.model());
session.context_window = cfg.resolve_context_window(route.model());
if let Some(alias) = &switched_to {
session.provider = compact_str::CompactString::new(alias);
}
Ok(switched_to)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ProviderEntry;
use std::collections::HashMap;
fn entry(provider_type: &str, model: &str) -> ProviderEntry {
ProviderEntry {
provider_type: Some(provider_type.to_string()),
model: Some(model.to_string()),
api_key: Some("sk-test".to_string()),
..Default::default()
}
}
fn cfg() -> Config {
Config {
providers: Some(HashMap::from([
("gpt-sol".to_string(), entry("openai", "gpt-5.5")),
("glm".to_string(), entry("glm", "glm-5.2")),
])),
..Default::default()
}
}
fn client(cfg: &Config, alias: &str) -> AnyClient {
super::super::create_client_with_auth(alias, None, &cfg.providers_map(), None).unwrap()
}
fn session(provider: &str, model: &str) -> Session {
Session::new(provider, model, 128_000)
}
#[test]
fn resolve_maps_the_switch_decision_to_a_route() {
let cfg = cfg();
assert_eq!(
resolve_model_route(&cfg, "gpt-sol", "glm-5.2"),
ModelRoute::Provider {
alias: "glm".into(),
model: "glm-5.2".into()
},
);
assert_eq!(
resolve_model_route(&cfg, "gpt-sol", "gpt-5.5-mini"),
ModelRoute::Active("gpt-5.5-mini".into()),
);
assert_eq!(
resolve_model_route(&cfg, "gpt-sol", "claude-opus-4"),
ModelRoute::Unroutable {
model: "claude-opus-4".into(),
family: "anthropic".into()
},
);
}
#[test]
fn pinned_route_names_its_provider_verbatim() {
assert_eq!(
ModelRoute::pinned("gpt-sol", "gpt-5.5"),
ModelRoute::Provider {
alias: "gpt-sol".into(),
model: "gpt-5.5".into()
},
);
assert_eq!(ModelRoute::pinned("a", "m").model(), "m");
}
#[test]
fn applying_a_cross_provider_route_rebuilds_the_client() {
let cfg = cfg();
let mut client = client(&cfg, "gpt-sol");
let mut session = session("gpt-sol", "gpt-5.5");
let switched = apply_model_route(
&cfg,
&mut client,
&mut session,
resolve_model_route(&cfg, "gpt-sol", "glm-5.2"),
)
.expect("a configured glm provider must be routable");
assert_eq!(switched.as_deref(), Some("glm"));
assert!(matches!(client, AnyClient::Glm(_)), "client must move");
assert_eq!(session.model, "glm-5.2");
assert_eq!(session.provider, "glm", "session must follow the client");
}
#[test]
fn applying_a_same_client_route_leaves_the_provider_alone() {
let cfg = cfg();
let mut client = client(&cfg, "gpt-sol");
let mut session = session("gpt-sol", "gpt-5.5");
let switched = apply_model_route(
&cfg,
&mut client,
&mut session,
resolve_model_route(&cfg, "gpt-sol", "gpt-5.5-mini"),
)
.unwrap();
assert_eq!(switched, None);
assert!(matches!(client, AnyClient::OpenAI(_)));
assert_eq!(session.model, "gpt-5.5-mini");
assert_eq!(session.provider, "gpt-sol");
}
#[test]
fn a_refused_route_leaves_the_session_untouched() {
let cfg = cfg();
let mut client = client(&cfg, "gpt-sol");
let mut session = session("gpt-sol", "gpt-5.5");
let err = apply_model_route(
&cfg,
&mut client,
&mut session,
resolve_model_route(&cfg, "gpt-sol", "claude-opus-4"),
)
.unwrap_err();
assert_eq!(
err,
RouteRefusal::NoProviderForFamily {
model: "claude-opus-4".into(),
family: "anthropic".into()
}
);
assert!(matches!(client, AnyClient::OpenAI(_)), "client untouched");
assert_eq!(session.model, "gpt-5.5", "session untouched");
assert_eq!(session.provider, "gpt-sol");
}
#[test]
fn a_route_whose_provider_cannot_be_built_is_refused() {
let cfg = cfg();
let mut client = client(&cfg, "gpt-sol");
let mut session = session("gpt-sol", "gpt-5.5");
let err = apply_model_route(
&cfg,
&mut client,
&mut session,
ModelRoute::pinned("no-such-provider", "whatever"),
)
.unwrap_err();
assert!(matches!(err, RouteRefusal::ProviderBuildFailed { .. }));
assert!(matches!(client, AnyClient::OpenAI(_)));
assert_eq!(session.model, "gpt-5.5");
assert_eq!(session.provider, "gpt-sol");
}
#[test]
fn a_pinned_route_restores_the_exact_provider_inference_would_miss() {
let cfg = Config {
providers: Some(HashMap::from([
("azure-gpt".to_string(), entry("openai", "gpt-5.5")),
("gpt-sol".to_string(), entry("openai", "gpt-5.5")),
("glm".to_string(), entry("glm", "glm-5.2")),
])),
..Default::default()
};
assert_eq!(
resolve_model_route(&cfg, "glm", "gpt-5.5"),
ModelRoute::Provider {
alias: "azure-gpt".into(),
model: "gpt-5.5".into()
},
);
let mut client = client(&cfg, "glm");
let mut session = session("glm", "glm-5.2");
let switched = apply_model_route(
&cfg,
&mut client,
&mut session,
ModelRoute::pinned("gpt-sol", "gpt-5.5"),
)
.unwrap();
assert_eq!(switched.as_deref(), Some("gpt-sol"));
assert_eq!(session.provider, "gpt-sol");
assert_eq!(session.model, "gpt-5.5");
}
#[test]
fn a_route_to_the_provider_already_live_does_not_rebuild() {
let cfg = cfg();
let mut client = client(&cfg, "gpt-sol");
let mut session = session("gpt-sol", "gpt-5.5");
let switched = apply_model_route(
&cfg,
&mut client,
&mut session,
ModelRoute::pinned("gpt-sol", "gpt-4o"),
)
.unwrap();
assert_eq!(switched, None, "no client swap was needed");
assert_eq!(session.model, "gpt-4o", "the rename still applies");
assert_eq!(session.provider, "gpt-sol");
}
#[test]
fn a_route_to_an_unbuildable_provider_already_live_is_not_refused() {
let cfg = cfg();
let mut client = client(&cfg, "gpt-sol");
let mut session = session("ephemeral-cli-provider", "some-model");
let switched = apply_model_route(
&cfg,
&mut client,
&mut session,
ModelRoute::pinned("ephemeral-cli-provider", "restored-model"),
)
.expect("staying put must never need a client build");
assert_eq!(switched, None);
assert_eq!(session.model, "restored-model");
}
#[test]
fn applying_a_route_moves_the_context_window() {
let cfg = cfg();
let mut client = client(&cfg, "gpt-sol");
let mut session = session("gpt-sol", "gpt-5.5");
session.context_window = 1;
apply_model_route(
&cfg,
&mut client,
&mut session,
resolve_model_route(&cfg, "gpt-sol", "glm-5.2"),
)
.unwrap();
assert_eq!(
session.context_window,
cfg.resolve_context_window("glm-5.2"),
"the window must follow the model, not stay stale",
);
}
}