Skip to main content

rpi_cli/
provider.rs

1//! Provider + model resolution. Mirrors the *Anthropic-only* slice of the TS
2//! `packages/coding-agent/src/core/model-resolver.ts` (`resolveCliModel` +
3//! the `provider/id[:thinking]` parsing in [`crate::args`]).
4//!
5//! v1 is Anthropic-only (plan §5.16: "OAuth/Copilot skipped v1; API-key auth
6//! only"). The TS `ModelRuntime`/`ModelRegistry` multi-provider machinery is
7//! not ported; this module builds a single [`AnthropicProvider`] from an API
8//! key and resolves a [`Model`] + [`ThinkingLevel`] against its fixed catalog.
9//!
10//! # Resolution precedence (mirrors `resolveCliModel`)
11//!
12//! 1. `--model` may carry `provider/id[:thinking]`. If the prefix before the
13//!    first `/` is the provider (`anthropic`), strip it and parse the rest as
14//!    `id[:thinking]`.
15//! 2. Otherwise treat `--model` as `id[:thinking]`: if a trailing `:level` is a
16//!    valid thinking level, strip it and apply it (overriding `--thinking`);
17//!    else the whole string is the id.
18//! 3. A `--provider` that isn't `anthropic` is a hard error (v1 has no other
19//!    provider). `--provider anthropic` is accepted and just confirms the
20//!    default.
21//! 4. The model id is matched **exactly, case-insensitively** against the
22//!    catalog. The TS resolver additionally does fuzzy/partial matching; v1
23//!    keeps it exact to avoid surprising model picks (partial match is a common
24//!    source of "got the wrong model" bugs — documented as a divergence in
25//!    `docs/m6-cli-open-questions.md`).
26//! 5. No `--model` ⇒ the default ([`DEFAULT_MODEL_ID`] = `claude-sonnet-5`),
27//!    mirroring the TS per-provider default.
28//!
29//! [`AnthropicProvider`]: rpi_ai::providers::anthropic::AnthropicProvider
30
31use 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
39/// The v1-default model id when `--model` is absent. Mirrors the TS
40/// `defaultModelPerProvider["anthropic"]` (the first current-generation
41/// reasoning model in the catalog).
42pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
43
44/// The default thinking level when neither `--thinking` nor a `:level` suffix
45/// is present. Mirrors the TS `DEFAULT_THINKING_LEVEL` (`"medium"`, clamped to
46/// model capabilities by the harness's provider build_params).
47pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
48
49/// The resolved run configuration: the provider handle, the chosen model, and
50/// the effective thinking level (after `--thinking` / `:level` / model-clamp).
51#[derive(Clone)]
52pub struct ResolvedModel {
53    /// The Anthropic provider (carries the API key). Cheap to clone (`Arc`
54    /// internally via the `Provider` trait object).
55    pub provider: Arc<dyn Provider>,
56    /// The chosen model from the catalog.
57    pub model: Model,
58    /// Effective thinking level (the requested level, before model-clamp — the
59    /// harness/provider clamps to the model's supported set).
60    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
73/// The env var consulted for the API key. Mirrors TS `ANTHROPIC_API_KEY`.
74pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
75
76/// A resolution error. The TS resolver returns `{ error, warning }`; v1 folds
77/// both into a single enum since the CLI treats them the same (print + non-zero
78/// exit) except `NoApiKey`, which prints guidance then exits.
79#[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
91/// Resolve the provider + model + thinking level from the CLI flags + env.
92///
93/// `cli_provider` is the `--provider` value (optional). `cli_model` is the
94/// `--model` value (optional; may be `provider/id[:thinking]` or `id[:thinking]`).
95/// `cli_thinking` is the `--thinking` value (optional). `cli_api_key` is the
96/// `--api-key` value (optional; overrides `ANTHROPIC_API_KEY`).
97pub 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    // ---- Provider selection (v1: Anthropic only) ----
104    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    // ---- API key ----
111    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    // The provider is built without a pinned key when none is supplied; the
115    // harness still constructs, but the first turn will fail at the provider.
116    // We surface a clear error up-front so the user knows to set the key.
117    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    // ---- Model + thinking pattern parse ----
124    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    // Effective thinking: `--thinking` wins over a `:level` suffix; else default.
134    let thinking_level = cli_thinking
135        .or(pattern_thinking)
136        .unwrap_or(DEFAULT_THINKING_LEVEL);
137
138    // Match the pattern against the catalog.
139    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
152/// Split a `--model` value into `(id_pattern, optional_thinking_level)`.
153///
154/// Handles `provider/id[:thinking]` (strips a leading `anthropic/`) and
155/// `id[:thinking]`. A trailing `:level` is parsed as a thinking level only if
156/// it is a valid level string; otherwise the whole tail is kept in the id
157/// pattern (some providers/model ids legitimately contain colons — none do in
158/// the v1 Anthropic catalog, but the parser stays conservative).
159///
160/// Mirrors the TS `parseModelPattern` last-colon split + recurse-on-prefix.
161fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
162    // Strip a leading `provider/` when the provider is anthropic.
163    let trimmed = value
164        .strip_prefix("anthropic/")
165        .or_else(|| value.strip_prefix("Anthropic/"))
166        .unwrap_or(value);
167
168    // Last-colon split: if the suffix is a valid thinking level, peel it.
169    if let Some(idx) = trimmed.rfind(':') {
170        let (head, tail) = trimmed.split_at(idx);
171        let suffix = &tail[1..]; // drop the ':'
172        if let Some(level) = parse_thinking_level(suffix) {
173            return (head.to_string(), Some(level));
174        }
175    }
176    (trimmed.to_string(), None)
177}
178
179/// Case-insensitive exact id match against the catalog. The TS resolver also
180/// does partial/fuzzy match; v1 keeps it exact (see module docs).
181fn 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    /// Tests in this module mutate the process-global `ANTHROPIC_API_KEY` env
195    /// var, so they race under the default parallel test runner. This mutex
196    /// serializes every env-touching test to keep the set/restore/clear windows
197    /// non-overlapping.
198    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    // These tests hit the network-free resolution path only (provider/model
208    // selection). They set a throwaway API key so `resolve` clears the
209    // `NoApiKey` gate, then assert the model + thinking choice — never making
210    // a real request.
211
212    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        // `--thinking low` wins over a `:high` suffix.
264        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        // A trailing `:foo` that isn't a thinking level stays part of the id
295        // pattern → no match (no model id contains `:foo`).
296        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        // Sanity: the valid set matches what help advertises.
305        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}