1use std::collections::BTreeMap;
66use std::sync::Arc;
67
68use rpi_ai::providers::anthropic::models::anthropic_models;
69use rpi_ai::providers::anthropic::AnthropicProvider;
70use rpi_ai::{Model, Provider, ThinkingLevel};
71
72use crate::args::parse_thinking_level;
73use crate::config::{self, Credential, DEFAULT_PROVIDER_ID};
74
75pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
79
80pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
84
85#[derive(Clone)]
88pub struct ResolvedModel {
89 pub provider: Arc<dyn Provider>,
93 pub model: Model,
95 pub thinking_level: ThinkingLevel,
98}
99
100impl std::fmt::Debug for ResolvedModel {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("ResolvedModel")
103 .field("provider", &self.provider.id())
104 .field("model", &self.model.id)
105 .field("thinking_level", &self.thinking_level)
106 .finish()
107 }
108}
109
110pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
112
113pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
119
120pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
124
125pub const NO_API_KEY_HINT: &str =
128 "ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login` (writes ~/.rpi/auth.json)";
129
130#[derive(Debug, thiserror::Error)]
134pub enum ResolveError {
135 #[error("Unknown provider \"{0}\". v1 supports: anthropic")]
136 UnknownProvider(String),
137 #[error("No model matches \"{pattern}\". Available: {available}")]
138 NoMatch { pattern: String, available: String },
139 #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
140 InvalidThinkingLevel(String, String),
141 #[error("No API key. Set one of: {hint}")]
142 NoApiKey { hint: &'static str },
143 #[error("Could not read config: {0}")]
144 Config(#[from] config::ConfigError),
145}
146
147pub fn resolve(
157 cli_provider: Option<&str>,
158 cli_model: Option<&str>,
159 cli_thinking: Option<ThinkingLevel>,
160 cli_api_key: Option<&str>,
161 cli_base_url: Option<&str>,
162) -> Result<ResolvedModel, ResolveError> {
163 if let Some(req) = cli_provider {
165 if !req.eq_ignore_ascii_case("anthropic") {
166 return Err(ResolveError::UnknownProvider(req.to_string()));
167 }
168 }
169
170 let mut provider_key: Option<String> = None;
172 let mut auth_headers: BTreeMap<String, String> = BTreeMap::new();
173
174 let models_cfg = config::load_models_config()?;
182
183 if let Some(k) = cli_api_key.filter(|s| !s.is_empty()) {
185 provider_key = Some(k.to_string());
186 }
187 if provider_key.is_none() {
189 if let Ok(store) = config::read_auth() {
190 if let Some(Credential::ApiKey { key: Some(k), .. }) = store.get(DEFAULT_PROVIDER_ID) {
191 if !k.is_empty() {
192 provider_key = Some(k.clone());
193 }
194 }
195 }
196 }
197 if provider_key.is_none() && auth_headers.is_empty() {
203 if let Some(tok) = models_json_bearer_token(&models_cfg) {
204 auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
205 }
206 }
207 if provider_key.is_none() && auth_headers.is_empty() {
209 if let Ok(tok) = std::env::var(ANTHROPIC_AUTH_TOKEN_ENV) {
210 if !tok.is_empty() {
211 auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
212 }
213 }
214 }
215 if provider_key.is_none() && auth_headers.is_empty() {
217 if let Ok(k) = std::env::var(ANTHROPIC_API_KEY_ENV) {
218 if !k.is_empty() {
219 provider_key = Some(k);
220 }
221 }
222 }
223 if provider_key.is_none() && auth_headers.is_empty() {
225 return Err(ResolveError::NoApiKey { hint: NO_API_KEY_HINT });
226 }
227
228 let base_url_override = cli_base_url
230 .map(|s| s.to_string())
231 .or_else(|| {
232 std::env::var(ANTHROPIC_BASE_URL_ENV)
233 .ok()
234 .filter(|s| !s.is_empty())
235 });
236
237 let mut catalog = anthropic_models();
240 merge_user_catalog(&mut catalog, &models_cfg);
241
242 if let Some(base) = &base_url_override {
245 for m in catalog.iter_mut() {
246 m.base_url = base.clone();
247 }
248 }
249
250 if !auth_headers.is_empty() {
257 for m in catalog.iter_mut() {
258 let headers = m.headers.get_or_insert_with(BTreeMap::new);
259 for (k, v) in &auth_headers {
260 headers.insert(k.clone(), v.clone());
261 }
262 }
263 }
264
265 let available = catalog
266 .iter()
267 .map(|m| m.id.clone())
268 .collect::<Vec<_>>()
269 .join(", ");
270
271 let (pattern, pattern_thinking) = split_model_pattern(cli_model.unwrap_or(DEFAULT_MODEL_ID));
273
274 let thinking_level = cli_thinking
276 .or(pattern_thinking)
277 .unwrap_or(DEFAULT_THINKING_LEVEL);
278
279 let model = match find_model(&pattern, &catalog) {
281 Some(m) => m,
282 None => {
283 return Err(ResolveError::NoMatch {
284 pattern: pattern.clone(),
285 available,
286 });
287 }
288 };
289
290 let provider: Arc<dyn Provider> = Arc::new(AnthropicProvider::with_models(
294 provider_key,
295 reqwest::Client::new(),
296 catalog,
297 ));
298
299 Ok(ResolvedModel { provider, model, thinking_level })
300}
301
302fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
308 for (provider_id, provider_cfg) in &cfg.providers {
309 let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
310 continue;
312 };
313 for m in models {
314 if let Some(existing) = catalog.iter_mut().find(|c| c.id.eq_ignore_ascii_case(&m.id)) {
315 *existing = m;
316 } else {
317 catalog.push(m);
318 }
319 }
320 }
321}
322
323fn models_json_bearer_token(cfg: &config::ModelsConfig) -> Option<String> {
329 for (_provider_id, provider_cfg) in &cfg.providers {
330 if !config::provider_is_anthropic_compatible(provider_cfg) {
331 continue;
332 }
333 if provider_cfg.auth_header.unwrap_or(false) {
334 if let Some(key) = provider_cfg.api_key.as_deref().filter(|s| !s.is_empty()) {
335 return Some(key.to_string());
336 }
337 }
338 }
339 None
340}
341
342fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
353 let trimmed = value
357 .strip_prefix("anthropic/")
358 .or_else(|| value.strip_prefix("Anthropic/"))
359 .or_else(|| {
360 if let Some(idx) = value.find('/') {
361 Some(&value[idx + 1..])
362 } else {
363 None
364 }
365 })
366 .unwrap_or(value);
367
368 if let Some(idx) = trimmed.rfind(':') {
370 let (head, tail) = trimmed.split_at(idx);
371 let suffix = &tail[1..]; if let Some(level) = parse_thinking_level(suffix) {
373 return (head.to_string(), Some(level));
374 }
375 }
376 (trimmed.to_string(), None)
377}
378
379fn find_model(pattern: &str, catalog: &[Model]) -> Option<Model> {
382 catalog
383 .iter()
384 .find(|m| m.id.eq_ignore_ascii_case(pattern))
385 .cloned()
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
392 use crate::config::test_support::env_lock;
393
394 struct TestEnv {
399 _guard: std::sync::MutexGuard<'static, ()>,
400 prev_key: Option<std::ffi::OsString>,
401 prev_tok: Option<std::ffi::OsString>,
402 prev_base: Option<std::ffi::OsString>,
403 prev_dir: Option<std::ffi::OsString>,
404 _tmp: tempfile::TempDir,
405 }
406 impl TestEnv {
407 fn new() -> Self {
408 let guard = env_lock().lock().unwrap();
409 let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
410 let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
411 let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
412 let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
413 std::env::remove_var(ANTHROPIC_API_KEY_ENV);
414 std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
415 std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
416 let tmp = tempfile::TempDir::new().unwrap();
417 std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
418 Self {
419 _guard: guard,
420 prev_key,
421 prev_tok,
422 prev_base,
423 prev_dir,
424 _tmp: tmp,
425 }
426 }
427 }
428 impl Drop for TestEnv {
429 fn drop(&mut self) {
430 restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
431 restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
432 restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
433 restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
434 }
435 }
436 fn restore(name: &str, prev: Option<std::ffi::OsString>) {
437 match prev {
438 Some(v) => std::env::set_var(name, v),
439 None => std::env::remove_var(name),
440 }
441 }
442
443 fn resolve_with_key(
449 provider: Option<&str>,
450 model: Option<&str>,
451 thinking: Option<ThinkingLevel>,
452 ) -> Result<ResolvedModel, ResolveError> {
453 let _env = TestEnv::new();
454 std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
455 resolve(provider, model, thinking, None, None)
456 }
457
458 #[test]
459 fn default_model_is_sonnet_5() {
460 let r = resolve_with_key(None, None, None).unwrap();
461 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
462 assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
463 assert_eq!(r.provider.id(), "anthropic");
464 }
465
466 #[test]
467 fn explicit_id_match() {
468 let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
469 assert_eq!(r.model.id, "claude-haiku-4-5");
470 }
471
472 #[test]
473 fn case_insensitive_id() {
474 let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
475 assert_eq!(r.model.id, "claude-opus-5");
476 }
477
478 #[test]
479 fn provider_prefix_stripped() {
480 let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
481 assert_eq!(r.model.id, "claude-sonnet-5");
482 }
483
484 #[test]
485 fn custom_provider_prefix_stripped() {
486 let _env = TestEnv::new();
489 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
490 std::fs::write(
491 config::models_path().unwrap(),
492 r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "models": [{"id":"custom-claude"}] } } }"#,
493 )
494 .unwrap();
495 let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
496 assert_eq!(r.model.id, "custom-claude");
497 }
498
499 #[test]
500 fn thinking_suffix_in_model() {
501 let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
502 assert_eq!(r.model.id, "claude-sonnet-5");
503 assert_eq!(r.thinking_level, ThinkingLevel::High);
504 }
505
506 #[test]
507 fn thinking_flag_overrides_suffix() {
508 let r =
510 resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
511 assert_eq!(r.thinking_level, ThinkingLevel::Low);
512 }
513
514 #[test]
515 fn explicit_provider_anthropic_ok() {
516 let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
517 assert_eq!(r.model.id, "claude-sonnet-5");
518 }
519
520 #[test]
521 fn unknown_provider_rejected() {
522 let err = resolve_with_key(Some("openai"), None, None).unwrap_err();
523 assert!(matches!(err, ResolveError::UnknownProvider(_)));
524 }
525
526 #[test]
527 fn no_match_lists_available() {
528 let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
529 match err {
530 ResolveError::NoMatch { pattern, available } => {
531 assert_eq!(pattern, "claude-does-not-exist");
532 assert!(available.contains("claude-sonnet-5"));
533 }
534 other => panic!("expected NoMatch, got {other:?}"),
535 }
536 }
537
538 #[test]
539 fn colon_not_a_thinking_level_kept_in_id() {
540 let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
543 assert!(matches!(err, ResolveError::NoMatch { .. }));
544 }
545
546 #[test]
547 fn parse_thinking_level_roundtrip() {
548 assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
549 assert_eq!(parse_thinking_level("bogus"), None);
550 for lvl in VALID_THINKING_LEVELS {
552 assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
553 }
554 }
555
556 #[test]
557 fn no_api_key_errors_with_hint() {
558 let _env = TestEnv::new();
559 let err = resolve(None, None, None, None, None).unwrap_err();
560 match err {
561 ResolveError::NoApiKey { hint } => {
562 assert!(hint.contains("ANTHROPIC_API_KEY"));
563 assert!(hint.contains("auth login"));
564 }
565 other => panic!("expected NoApiKey, got {other:?}"),
566 }
567 }
568
569 #[test]
570 fn stored_credential_satisfies_auth() {
571 let _env = TestEnv::new();
572 config::upsert_credential(
573 DEFAULT_PROVIDER_ID,
574 Credential::ApiKey { key: Some("stored-key".into()), env: None },
575 )
576 .unwrap();
577 let r = resolve(None, None, None, None, None).unwrap();
578 assert_eq!(r.model.id, DEFAULT_MODEL_ID);
579 assert!(
582 r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
583 "x-api-key path should not synthesize a Bearer header"
584 );
585 }
586
587 #[test]
588 fn auth_token_routes_via_bearer_header() {
589 let _env = TestEnv::new();
590 std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
591 let r = resolve(None, None, None, None, None).unwrap();
592 let headers = r.model.headers.as_ref().expect("bearer header on model");
594 assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer tok-123"));
595 }
596
597 #[test]
598 fn api_key_flag_beats_env_and_stored() {
599 let _env = TestEnv::new();
600 std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
601 config::upsert_credential(
602 DEFAULT_PROVIDER_ID,
603 Credential::ApiKey { key: Some("stored-key".into()), env: None },
604 )
605 .unwrap();
606 let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
609 assert!(
610 r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
611 "--api-key should take the x-api-key path, not Bearer"
612 );
613 }
614
615 #[test]
616 fn base_url_override_applies_to_model() {
617 let _env = TestEnv::new();
618 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
619 let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
620 assert_eq!(r.model.base_url, "https://gw.example.com");
621 }
622
623 #[test]
624 fn base_url_env_is_fallback_for_flag() {
625 let _env = TestEnv::new();
626 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
627 std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
628 let r = resolve(None, None, None, None, None).unwrap();
629 assert_eq!(r.model.base_url, "https://env-gw.example.com");
630 }
631
632 #[test]
633 fn models_json_adds_custom_model() {
634 let _env = TestEnv::new();
635 std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
636 std::fs::write(
637 config::models_path().unwrap(),
638 r#"{
639 "providers": {
640 "gateway": {
641 "baseUrl": "https://gw.example.com",
642 "authHeader": true,
643 "apiKey": "gw-secret",
644 "models": [
645 { "id": "custom-claude", "name": "Custom" }
646 ]
647 }
648 }
649}"#,
650 )
651 .unwrap();
652 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
653 assert_eq!(r.model.id, "custom-claude");
654 assert_eq!(r.model.base_url, "https://gw.example.com");
655 assert_eq!(r.model.provider, DEFAULT_PROVIDER_ID);
658 let headers = r.model.headers.as_ref().expect("headers merged");
660 assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
661 }
662
663 #[test]
669 fn models_json_auth_header_satisfies_auth_without_env() {
670 let _env = TestEnv::new();
671 std::fs::write(
673 config::models_path().unwrap(),
674 r#"{
675 "providers": {
676 "gateway": {
677 "baseUrl": "https://gw.example.com",
678 "api": "anthropic-messages",
679 "authHeader": true,
680 "apiKey": "gw-secret",
681 "models": [
682 { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
683 ]
684 }
685 }
686}"#,
687 )
688 .unwrap();
689 let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
690 assert_eq!(r.model.id, "custom-claude");
691 assert_eq!(r.model.base_url, "https://gw.example.com");
692 let headers = r.model.headers.as_ref().expect("bearer folded onto model");
693 assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
694 }
695
696 #[test]
700 fn api_key_flag_beats_models_json_bearer() {
701 let _env = TestEnv::new();
702 std::fs::write(
703 config::models_path().unwrap(),
704 r#"{
705 "providers": {
706 "gateway": {
707 "baseUrl": "https://gw.example.com",
708 "authHeader": true,
709 "apiKey": "gw-secret",
710 "models": [ { "id": "custom-claude" } ]
711 }
712 }
713}"#,
714 )
715 .unwrap();
716 let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
717 assert!(
719 r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
720 "--api-key should win over the models.json gateway bearer"
721 );
722 }
723}