1use std::sync::Arc;
32
33use rpi_ai::providers::anthropic::models::anthropic_models;
34use rpi_ai::providers::anthropic::AnthropicProvider;
35use rpi_ai::{Model, Provider, ThinkingLevel};
36
37use crate::args::parse_thinking_level;
38
39pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
43
44pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
48
49#[derive(Clone)]
52pub struct ResolvedModel {
53 pub provider: Arc<dyn Provider>,
56 pub model: Model,
58 pub thinking_level: ThinkingLevel,
61}
62
63impl std::fmt::Debug for ResolvedModel {
64 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65 f.debug_struct("ResolvedModel")
66 .field("provider", &self.provider.id())
67 .field("model", &self.model.id)
68 .field("thinking_level", &self.thinking_level)
69 .finish()
70 }
71}
72
73pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
75
76#[derive(Debug, thiserror::Error)]
80pub enum ResolveError {
81 #[error("Unknown provider \"{0}\". v1 supports: anthropic")]
82 UnknownProvider(String),
83 #[error("No model matches \"{pattern}\". Available: {available}")]
84 NoMatch { pattern: String, available: String },
85 #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
86 InvalidThinkingLevel(String, String),
87 #[error("No API key. Set {env} or pass --api-key.")]
88 NoApiKey { env: &'static str },
89}
90
91pub fn resolve(
98 cli_provider: Option<&str>,
99 cli_model: Option<&str>,
100 cli_thinking: Option<ThinkingLevel>,
101 cli_api_key: Option<&str>,
102) -> Result<ResolvedModel, ResolveError> {
103 if let Some(req) = cli_provider {
105 if !req.eq_ignore_ascii_case("anthropic") {
106 return Err(ResolveError::UnknownProvider(req.to_string()));
107 }
108 }
109
110 let api_key = cli_api_key
112 .map(|s| s.to_string())
113 .or_else(|| std::env::var(ANTHROPIC_API_KEY_ENV).ok().filter(|s| !s.is_empty()));
114 if api_key.is_none() {
118 return Err(ResolveError::NoApiKey { env: ANTHROPIC_API_KEY_ENV });
119 }
120 let provider: Arc<dyn Provider> =
121 Arc::new(AnthropicProvider::new(api_key, reqwest::Client::new()));
122
123 let catalog = anthropic_models();
125 let available = catalog
126 .iter()
127 .map(|m| m.id.clone())
128 .collect::<Vec<_>>()
129 .join(", ");
130
131 let (pattern, pattern_thinking) = split_model_pattern(cli_model.unwrap_or(DEFAULT_MODEL_ID));
132
133 let thinking_level = cli_thinking
135 .or(pattern_thinking)
136 .unwrap_or(DEFAULT_THINKING_LEVEL);
137
138 let model = match find_model(&pattern, &catalog) {
140 Some(m) => m,
141 None => {
142 return Err(ResolveError::NoMatch {
143 pattern: pattern.clone(),
144 available,
145 });
146 }
147 };
148
149 Ok(ResolvedModel { provider, model, thinking_level })
150}
151
152fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
162 let trimmed = value
164 .strip_prefix("anthropic/")
165 .or_else(|| value.strip_prefix("Anthropic/"))
166 .unwrap_or(value);
167
168 if let Some(idx) = trimmed.rfind(':') {
170 let (head, tail) = trimmed.split_at(idx);
171 let suffix = &tail[1..]; if let Some(level) = parse_thinking_level(suffix) {
173 return (head.to_string(), Some(level));
174 }
175 }
176 (trimmed.to_string(), None)
177}
178
179fn find_model(pattern: &str, catalog: &[Model]) -> Option<Model> {
182 catalog
183 .iter()
184 .find(|m| m.id.eq_ignore_ascii_case(pattern))
185 .cloned()
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
192 use std::sync::{Mutex, OnceLock};
193
194 fn env_lock() -> &'static Mutex<()> {
199 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
200 LOCK.get_or_init(|| Mutex::new(()))
201 }
202
203 fn env_key() -> Option<String> {
204 std::env::var(ANTHROPIC_API_KEY_ENV).ok().filter(|s| !s.is_empty())
205 }
206
207 fn resolve_with_key(
213 provider: Option<&str>,
214 model: Option<&str>,
215 thinking: Option<ThinkingLevel>,
216 ) -> Result<ResolvedModel, ResolveError> {
217 let _guard = env_lock().lock().unwrap();
218 let prev = env_key();
219 std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
220 let r = resolve(provider, model, thinking, None);
221 match prev {
222 Some(v) => std::env::set_var(ANTHROPIC_API_KEY_ENV, v),
223 None => std::env::remove_var(ANTHROPIC_API_KEY_ENV),
224 }
225 r
226 }
227
228 #[test]
229 fn default_model_is_sonnet_5() {
230 let r = resolve_with_key(None, None, None).unwrap();
231 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
232 assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
233 assert_eq!(r.provider.id(), "anthropic");
234 }
235
236 #[test]
237 fn explicit_id_match() {
238 let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
239 assert_eq!(r.model.id, "claude-haiku-4-5");
240 }
241
242 #[test]
243 fn case_insensitive_id() {
244 let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
245 assert_eq!(r.model.id, "claude-opus-5");
246 }
247
248 #[test]
249 fn provider_prefix_stripped() {
250 let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
251 assert_eq!(r.model.id, "claude-sonnet-5");
252 }
253
254 #[test]
255 fn thinking_suffix_in_model() {
256 let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
257 assert_eq!(r.model.id, "claude-sonnet-5");
258 assert_eq!(r.thinking_level, ThinkingLevel::High);
259 }
260
261 #[test]
262 fn thinking_flag_overrides_suffix() {
263 let r = resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
265 assert_eq!(r.thinking_level, ThinkingLevel::Low);
266 }
267
268 #[test]
269 fn explicit_provider_anthropic_ok() {
270 let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
271 assert_eq!(r.model.id, "claude-sonnet-5");
272 }
273
274 #[test]
275 fn unknown_provider_rejected() {
276 let err = resolve_with_key(Some("openai"), None, None).unwrap_err();
277 assert!(matches!(err, ResolveError::UnknownProvider(_)));
278 }
279
280 #[test]
281 fn no_match_lists_available() {
282 let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
283 match err {
284 ResolveError::NoMatch { pattern, available } => {
285 assert_eq!(pattern, "claude-does-not-exist");
286 assert!(available.contains("claude-sonnet-5"));
287 }
288 other => panic!("expected NoMatch, got {other:?}"),
289 }
290 }
291
292 #[test]
293 fn colon_not_a_thinking_level_kept_in_id() {
294 let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
297 assert!(matches!(err, ResolveError::NoMatch { .. }));
298 }
299
300 #[test]
301 fn parse_thinking_level_roundtrip() {
302 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
303 assert_eq!(parse_thinking_level("bogus"), None);
304 for lvl in VALID_THINKING_LEVELS {
306 assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
307 }
308 }
309
310 #[test]
311 fn no_api_key_errors_with_env_name() {
312 let _guard = env_lock().lock().unwrap();
313 let prev = env_key();
314 std::env::remove_var(ANTHROPIC_API_KEY_ENV);
315 let err = resolve(None, None, None, None).unwrap_err();
316 match err {
317 ResolveError::NoApiKey { env } => assert_eq!(env, ANTHROPIC_API_KEY_ENV),
318 other => panic!("expected NoApiKey, got {other:?}"),
319 }
320 match prev {
321 Some(v) => std::env::set_var(ANTHROPIC_API_KEY_ENV, v),
322 None => {}
323 }
324 }
325}