use std::sync::Arc;
use rpi_ai::providers::anthropic::models::anthropic_models;
use rpi_ai::providers::anthropic::AnthropicProvider;
use rpi_ai::{Model, Provider, ThinkingLevel};
use crate::args::parse_thinking_level;
pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
#[derive(Clone)]
pub struct ResolvedModel {
pub provider: Arc<dyn Provider>,
pub model: Model,
pub thinking_level: ThinkingLevel,
}
impl std::fmt::Debug for ResolvedModel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ResolvedModel")
.field("provider", &self.provider.id())
.field("model", &self.model.id)
.field("thinking_level", &self.thinking_level)
.finish()
}
}
pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
#[derive(Debug, thiserror::Error)]
pub enum ResolveError {
#[error("Unknown provider \"{0}\". v1 supports: anthropic")]
UnknownProvider(String),
#[error("No model matches \"{pattern}\". Available: {available}")]
NoMatch { pattern: String, available: String },
#[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
InvalidThinkingLevel(String, String),
#[error("No API key. Set {env} or pass --api-key.")]
NoApiKey { env: &'static str },
}
pub fn resolve(
cli_provider: Option<&str>,
cli_model: Option<&str>,
cli_thinking: Option<ThinkingLevel>,
cli_api_key: Option<&str>,
) -> Result<ResolvedModel, ResolveError> {
if let Some(req) = cli_provider {
if !req.eq_ignore_ascii_case("anthropic") {
return Err(ResolveError::UnknownProvider(req.to_string()));
}
}
let api_key = cli_api_key
.map(|s| s.to_string())
.or_else(|| std::env::var(ANTHROPIC_API_KEY_ENV).ok().filter(|s| !s.is_empty()));
if api_key.is_none() {
return Err(ResolveError::NoApiKey { env: ANTHROPIC_API_KEY_ENV });
}
let provider: Arc<dyn Provider> =
Arc::new(AnthropicProvider::new(api_key, reqwest::Client::new()));
let catalog = anthropic_models();
let available = catalog
.iter()
.map(|m| m.id.clone())
.collect::<Vec<_>>()
.join(", ");
let (pattern, pattern_thinking) = split_model_pattern(cli_model.unwrap_or(DEFAULT_MODEL_ID));
let thinking_level = cli_thinking
.or(pattern_thinking)
.unwrap_or(DEFAULT_THINKING_LEVEL);
let model = match find_model(&pattern, &catalog) {
Some(m) => m,
None => {
return Err(ResolveError::NoMatch {
pattern: pattern.clone(),
available,
});
}
};
Ok(ResolvedModel { provider, model, thinking_level })
}
fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
let trimmed = value
.strip_prefix("anthropic/")
.or_else(|| value.strip_prefix("Anthropic/"))
.unwrap_or(value);
if let Some(idx) = trimmed.rfind(':') {
let (head, tail) = trimmed.split_at(idx);
let suffix = &tail[1..]; if let Some(level) = parse_thinking_level(suffix) {
return (head.to_string(), Some(level));
}
}
(trimmed.to_string(), None)
}
fn find_model(pattern: &str, catalog: &[Model]) -> Option<Model> {
catalog
.iter()
.find(|m| m.id.eq_ignore_ascii_case(pattern))
.cloned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
use std::sync::{Mutex, OnceLock};
fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn env_key() -> Option<String> {
std::env::var(ANTHROPIC_API_KEY_ENV).ok().filter(|s| !s.is_empty())
}
fn resolve_with_key(
provider: Option<&str>,
model: Option<&str>,
thinking: Option<ThinkingLevel>,
) -> Result<ResolvedModel, ResolveError> {
let _guard = env_lock().lock().unwrap();
let prev = env_key();
std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
let r = resolve(provider, model, thinking, None);
match prev {
Some(v) => std::env::set_var(ANTHROPIC_API_KEY_ENV, v),
None => std::env::remove_var(ANTHROPIC_API_KEY_ENV),
}
r
}
#[test]
fn default_model_is_sonnet_5() {
let r = resolve_with_key(None, None, None).unwrap();
assert_eq!(r.model.id, DEFAULT_MODEL_ID);
assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
assert_eq!(r.provider.id(), "anthropic");
}
#[test]
fn explicit_id_match() {
let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
assert_eq!(r.model.id, "claude-haiku-4-5");
}
#[test]
fn case_insensitive_id() {
let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
assert_eq!(r.model.id, "claude-opus-5");
}
#[test]
fn provider_prefix_stripped() {
let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
assert_eq!(r.model.id, "claude-sonnet-5");
}
#[test]
fn thinking_suffix_in_model() {
let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
assert_eq!(r.model.id, "claude-sonnet-5");
assert_eq!(r.thinking_level, ThinkingLevel::High);
}
#[test]
fn thinking_flag_overrides_suffix() {
let r = resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
assert_eq!(r.thinking_level, ThinkingLevel::Low);
}
#[test]
fn explicit_provider_anthropic_ok() {
let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
assert_eq!(r.model.id, "claude-sonnet-5");
}
#[test]
fn unknown_provider_rejected() {
let err = resolve_with_key(Some("openai"), None, None).unwrap_err();
assert!(matches!(err, ResolveError::UnknownProvider(_)));
}
#[test]
fn no_match_lists_available() {
let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
match err {
ResolveError::NoMatch { pattern, available } => {
assert_eq!(pattern, "claude-does-not-exist");
assert!(available.contains("claude-sonnet-5"));
}
other => panic!("expected NoMatch, got {other:?}"),
}
}
#[test]
fn colon_not_a_thinking_level_kept_in_id() {
let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
assert!(matches!(err, ResolveError::NoMatch { .. }));
}
#[test]
fn parse_thinking_level_roundtrip() {
assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
assert_eq!(parse_thinking_level("bogus"), None);
for lvl in VALID_THINKING_LEVELS {
assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
}
}
#[test]
fn no_api_key_errors_with_env_name() {
let _guard = env_lock().lock().unwrap();
let prev = env_key();
std::env::remove_var(ANTHROPIC_API_KEY_ENV);
let err = resolve(None, None, None, None).unwrap_err();
match err {
ResolveError::NoApiKey { env } => assert_eq!(env, ANTHROPIC_API_KEY_ENV),
other => panic!("expected NoApiKey, got {other:?}"),
}
match prev {
Some(v) => std::env::set_var(ANTHROPIC_API_KEY_ENV, v),
None => {}
}
}
}