Skip to main content

rpi_cli/
provider.rs

1//! Provider + model resolution. Mirrors the *Anthropic-protocol* slice of the
2//! TS `packages/coding-agent/src/core/model-resolver.ts` (`resolveCliModel` +
3//! the `provider/id[:thinking]` parsing in [`crate::args`]).
4//!
5//! v1 is Anthropic-protocol only (plan §5.16: "OAuth/Copilot skipped v1;
6//! API-key auth only" — now extended to include third-party Anthropic-compatible
7//! endpoints via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` and a
8//! `~/.rpi/models.json` catalog; OAuth is still deferred). The TS
9//! `ModelRuntime`/`ModelRegistry` multi-provider machinery is not ported; this
10//! module builds a single [`AnthropicProvider`] from a resolved credential and
11//! resolves a [`Model`] + [`ThinkingLevel`] against the catalog.
12//!
13//! # Auth resolution precedence (mirrors upstream `anthropic.ts:resolve`)
14//!
15//! 1. `--api-key` → provider default key (sent as `x-api-key`).
16//! 2. `~/.rpi/auth.json` `anthropic.api_key.key` — the persistent `rpi auth
17//!    login` credential (sent as `x-api-key`). This is the "logged-in" path.
18//! 3. `~/.rpi/models.json` provider with `authHeader: true` + `apiKey` →
19//!    `Authorization: Bearer <key>` (a static gateway credential — the models.json
20//!    file alone is a complete third-party-endpoint setup, no env var needed).
21//! 4. `ANTHROPIC_AUTH_TOKEN` env → `Authorization: Bearer <token>` (folded into
22//!    each model's `headers`; the provider's `has_header_auth` recognizes it and
23//!    skips `x-api-key`, so a token-only setup does not error on a missing key).
24//! 5. `ANTHROPIC_API_KEY` env → provider default key (`x-api-key`).
25//! 6. None of the above ⇒ [`ResolveError::NoApiKey`].
26//!
27//! When a Bearer source (item 3 or 4) wins, the provider is built with
28//! `api_key = None` — the header on each model carries the auth. When a key
29//! source wins (1, 2, or 5), the provider carries the key as `x-api-key`.
30//!
31//! # Endpoint + catalog
32//!
33//! - `--base-url` / `ANTHROPIC_BASE_URL` overrides `model.base_url` at resolve
34//!   time (the request URL is built from it per-request in rpi-ai).
35//! - `~/.rpi/models.json` (if present) merges/overrides the built-in catalog:
36//!   each `anthropic-messages` provider contributes its models, with
37//!   provider-level `base_url`/`headers`/`authHeader` folded in. The models.json
38//!   provider id (e.g. `gateway`) is **config-namespacing only** in v1: every
39//!   models.json model is stamped `provider = "anthropic"` so it routes through
40//!   the single `AnthropicProvider` (the per-model `base_url` + `headers` carry
41//!   the endpoint/auth differentiation). A `--model gateway/custom-claude` just
42//!   strips the `gateway/` prefix and matches the `custom-claude` id.
43//!
44//! # Model pattern precedence (mirrors `resolveCliModel`)
45//!
46//! 1. `--model` may carry `provider/id[:thinking]`. A leading `anthropic/`
47//!    (case-insensitive) is stripped; any other `foo/` prefix is also stripped
48//!    so a `models.json` provider id (e.g. `gateway/…`) addresses its model.
49//! 2. Otherwise treat `--model` as `id[:thinking]`: if a trailing `:level` is a
50//!    valid thinking level, strip it and apply it (overriding `--thinking`);
51//!    else the whole string is the id.
52//! 3. A `--provider` that isn't `anthropic` is a hard error (v1 has no other
53//!    provider). `--provider anthropic` is accepted and just confirms the
54//!    default.
55//! 4. The model id is matched **exactly, case-insensitively** against the
56//!    catalog. The TS resolver additionally does fuzzy/partial matching; v1
57//!    keeps it exact to avoid surprising model picks (partial match is a common
58//!    source of "got the wrong model" bugs — documented as a divergence in
59//!    `docs/m6-cli-open-questions.md`).
60//! 5. No `--model` ⇒ the default ([`DEFAULT_MODEL_ID`] = `claude-sonnet-5`),
61//!    mirroring the TS per-provider default.
62//!
63//! [`AnthropicProvider`]: rpi_ai::providers::anthropic::AnthropicProvider
64
65use 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
75/// The v1-default model id when `--model` is absent. Mirrors the TS
76/// `defaultModelPerProvider["anthropic"]` (the first current-generation
77/// reasoning model in the catalog).
78pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
79
80/// The default thinking level when neither `--thinking` nor a `:level` suffix
81/// is present. Mirrors the TS `DEFAULT_THINKING_LEVEL` (`"medium"`, clamped to
82/// model capabilities by the harness's provider build_params).
83pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
84
85/// The resolved run configuration: the provider handle, the chosen model, and
86/// the effective thinking level (after `--thinking` / `:level` / model-clamp).
87#[derive(Clone)]
88pub struct ResolvedModel {
89    /// The Anthropic provider (carries the API key, or `None` when Bearer
90    /// headers carry the auth). Cheap to clone (`Arc` internally via the
91    /// `Provider` trait object).
92    pub provider: Arc<dyn Provider>,
93    /// The chosen model from the catalog.
94    pub model: Model,
95    /// Effective thinking level (the requested level, before model-clamp — the
96    /// harness/provider clamps to the model's supported set).
97    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
110/// The env var consulted for the API key. Mirrors TS `ANTHROPIC_API_KEY`.
111pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
112
113/// The env var consulted for a bearer token (routed as
114/// `Authorization: Bearer`). Mirrors TS `ANTHROPIC_AUTH_TOKEN` — used by
115/// third-party Anthropic-compatible gateways (one-api/new-api/claude-code-router
116/// and private reverse proxies) that authenticate via `Authorization` rather
117/// than `x-api-key`.
118pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
119
120/// The env var that overrides the Anthropic endpoint base URL. Mirrors TS
121/// `ANTHROPIC_BASE_URL` — point this at a gateway/proxy that speaks the
122/// `/v1/messages` protocol.
123pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
124
125/// Hint text surfaced when no credential source is available. Lists every
126/// accepted source so the user can pick the one that fits their setup.
127pub 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/// A resolution error. The TS resolver returns `{ error, warning }`; v1 folds
131/// both into a single enum since the CLI treats them the same (print + non-zero
132/// exit) except `NoApiKey`, which prints guidance then exits.
133#[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
147/// Resolve the provider + model + thinking level from the CLI flags + env +
148/// `~/.rpi/` config.
149///
150/// `cli_provider` is the `--provider` value (optional). `cli_model` is the
151/// `--model` value (optional; may be `provider/id[:thinking]` or `id[:thinking]`).
152/// `cli_thinking` is the `--thinking` value (optional). `cli_api_key` is the
153/// `--api-key` value (optional; highest-priority `x-api-key` source).
154/// `cli_base_url` is the `--base-url` value (optional; overrides
155/// `ANTHROPIC_BASE_URL` + each model's `base_url`).
156pub 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    // ---- Provider selection (v1: Anthropic protocol only) ----
164    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    // ---- Auth resolution: provider_key (x-api-key) OR auth_headers (Bearer) ----
171    let mut provider_key: Option<String> = None;
172    let mut auth_headers: BTreeMap<String, String> = BTreeMap::new();
173
174    // Load the models.json config ONCE — it is consulted both as an auth source
175    // (a provider with `authHeader: true` + `apiKey` supplies a Bearer token,
176    // mirroring upstream `provider-composer.ts` `withConfiguredAuth`) and as the
177    // model catalog merge source (below). Loading here (before the auth gate)
178    // means a static `~/.rpi/models.json` gateway credential can satisfy auth
179    // without any env var or `rpi auth login` — the models.json file alone is a
180    // complete third-party-endpoint setup.
181    let models_cfg = config::load_models_config()?;
182
183    // 1. --api-key (highest-priority x-api-key source).
184    if let Some(k) = cli_api_key.filter(|s| !s.is_empty()) {
185        provider_key = Some(k.to_string());
186    }
187    // 2. ~/.rpi/auth.json anthropic.api_key.key (persistent login).
188    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    // 3. ~/.rpi/models.json provider with authHeader:true + apiKey → Bearer.
198    //    The first anthropic-compatible provider that declares a static gateway
199    //    key supplies the Bearer token (v1 routes through one provider, so the
200    //    first match is authoritative). Mirrors upstream's `authHeader` handling
201    //    where the resolved apiKey is wrapped as `Authorization: Bearer`.
202    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    // 4. ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (third-party gateways).
208    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    // 5. ANTHROPIC_API_KEY → x-api-key (fallback).
216    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    // 6. Nothing → clear error listing every accepted source.
224    if provider_key.is_none() && auth_headers.is_empty() {
225        return Err(ResolveError::NoApiKey { hint: NO_API_KEY_HINT });
226    }
227
228    // ---- Endpoint override (--base-url → ANTHROPIC_BASE_URL) ----
229    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    // ---- Catalog: built-in + ~/.rpi/models.json (merged, reusing the
238    // already-loaded config) ----
239    let mut catalog = anthropic_models();
240    merge_user_catalog(&mut catalog, &models_cfg);
241
242    // Apply the endpoint override to every model (the request URL is built from
243    // `model.base_url` per-request in rpi-ai).
244    if let Some(base) = &base_url_override {
245        for m in catalog.iter_mut() {
246            m.base_url = base.clone();
247        }
248    }
249
250    // Fold the Bearer header (if any) into every model so `has_header_auth`
251    // recognizes it and the provider skips `x-api-key`. Provider-level headers
252    // from models.json are preserved; an env-derived Bearer is additive
253    // (inserted via `entry` so a models.json bearer isn't clobbered when the
254    // env token is absent — but when both exist, the env token is the
255    // interactive-session override and wins).
256    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    // ---- Model + thinking pattern parse ----
272    let (pattern, pattern_thinking) = split_model_pattern(cli_model.unwrap_or(DEFAULT_MODEL_ID));
273
274    // Effective thinking: `--thinking` wins over a `:level` suffix; else default.
275    let thinking_level = cli_thinking
276        .or(pattern_thinking)
277        .unwrap_or(DEFAULT_THINKING_LEVEL);
278
279    // Match the pattern against the catalog.
280    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    // ---- Provider build ----
291    // Bearer path: `provider_key = None` — the model headers carry the auth
292    // (`has_header_auth` skips x-api-key). x-api-key path: pass the key.
293    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
302/// Merge `~/.rpi/models.json` providers into the built-in catalog. Models from
303/// the user file replace any built-in entry with the same id (custom
304/// definitions win); brand-new ids are appended. Non-`anthropic-messages`
305/// providers are skipped (ignored in v1, documented). Takes the already-loaded
306/// config so the file is read once per `resolve`.
307fn 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            // Non-anthropic protocol — ignored in v1 (documented).
311            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
323/// Extract a static gateway Bearer token from the first anthropic-compatible
324/// models.json provider that declares `authHeader: true` + a non-empty
325/// `apiKey`. Mirrors upstream's `withConfiguredAuth` (`authHeader` wraps the
326/// resolved key as `Authorization: Bearer`). Returns `None` when no such
327/// provider exists (the env/stored-cred/cli-flag sources still apply).
328fn 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
342/// Split a `--model` value into `(id_pattern, optional_thinking_level)`.
343///
344/// Handles `provider/id[:thinking]` (strips a leading `anthropic/` or any other
345/// `foo/` prefix so a `models.json` provider id addresses its model) and
346/// `id[:thinking]`. A trailing `:level` is parsed as a thinking level only if
347/// it is a valid level string; otherwise the whole tail is kept in the id
348/// pattern (some model ids legitimately contain colons — none do in the v1
349/// Anthropic catalog, but the parser stays conservative).
350///
351/// Mirrors the TS `parseModelPattern` last-colon split + recurse-on-prefix.
352fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
353    // Strip a leading `provider/` prefix. `anthropic/` is the common case; any
354    // other `foo/` prefix is also stripped so a `models.json` provider id (e.g.
355    // `gateway/custom-claude`) resolves to the `custom-claude` catalog entry.
356    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    // Last-colon split: if the suffix is a valid thinking level, peel it.
369    if let Some(idx) = trimmed.rfind(':') {
370        let (head, tail) = trimmed.split_at(idx);
371        let suffix = &tail[1..]; // drop the ':'
372        if let Some(level) = parse_thinking_level(suffix) {
373            return (head.to_string(), Some(level));
374        }
375    }
376    (trimmed.to_string(), None)
377}
378
379/// Case-insensitive exact id match against the catalog. The TS resolver also
380/// does partial/fuzzy match; v1 keeps it exact (see module docs).
381fn 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    /// Scope a test to a throwaway config dir + clear the `ANTHROPIC_*` env
395    /// vars, restoring both on drop. Holds the shared env lock for its whole
396    /// lifetime so parallel env-mutating tests across config/provider/auth all
397    /// serialize on one mutex.
398    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    // These tests hit the network-free resolution path only (provider/model
444    // selection). They set a throwaway credential so `resolve` clears the
445    // `NoApiKey` gate, then assert the model + thinking choice — never making
446    // a real request.
447
448    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        // `gateway/custom-claude` resolves to the catalog id `custom-claude`
487        // after the `foo/` prefix is stripped.
488        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        // `--thinking low` wins over a `:high` suffix.
509        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        // A trailing `:foo` that isn't a thinking level stays part of the id
541        // pattern → no match (no model id contains `:foo`).
542        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        // Sanity: the valid set matches what help advertises.
551        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        // x-api-key path: no Bearer header folded onto the model (auth rides on
580        // the provider's default key, surfaced to the provider at build time).
581        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        // No provider key carries auth — it lives on the model header.
593        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        // `--api-key flag-key` wins; resolve succeeds + takes the x-api-key path
607        // (no Bearer header on the model).
608        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        // The model is routed through the single AnthropicProvider (provider
656        // stamped "anthropic" by config::provider_to_models).
657        assert_eq!(r.model.provider, DEFAULT_PROVIDER_ID);
658        // Provider-level authHeader folded in.
659        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    /// A models.json gateway with `authHeader:true` + `apiKey` is itself an auth
664    /// source — it satisfies the `resolve` auth gate WITHOUT any env var, stored
665    /// cred, or `--api-key`. This is the "models.json file alone sets up a
666    /// third-party endpoint" path. The Bearer token folds onto every model and
667    /// `resolve` succeeds.
668    #[test]
669    fn models_json_auth_header_satisfies_auth_without_env() {
670        let _env = TestEnv::new();
671        // No ANTHROPIC_* env, no auth.json — only the models.json gateway.
672        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    /// The `--api-key` flag wins over a models.json `authHeader:true` gateway
697    /// key (the flag is the highest-priority x-api-key source; the gateway
698    /// Bearer is only consulted when no key path is taken).
699    #[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        // --api-key path: no Bearer folded on (the gateway bearer is skipped).
718        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}