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` ⇒ [`pick_default_model`]:
61//!    (a) the built-in default ([`DEFAULT_MODEL_ID`] = `claude-sonnet-5`) if it
62//!    is already authenticated (has a folded Bearer, or the provider holds an
63//!    `x-api-key`); otherwise (b) the **first authenticated model** in the
64//!    catalog — mirroring the TS `findInitialModel` step-4 fallback
65//!    `availableModels[0]` over the auth-filtered snapshot. This lets a
66//!    `models.json`-only gateway config "just work": the built-in Anthropic
67//!    models carry no auth, so the gateway model (the only authenticated one)
68//!    is picked. The all-builtin/no-custom-code default (`ANTHROPIC_API_KEY`
69//!    path) still selects `claude-sonnet-5`. Last resort falls back to
70//!    [`DEFAULT_MODEL_ID`] (or the catalog head) — unreachable in practice
71//!    because the auth gate refuses an unauthed catalog earlier.
72//!
73//! [`AnthropicProvider`]: rpi_ai::providers::anthropic::AnthropicProvider
74
75use std::collections::BTreeMap;
76use std::sync::Arc;
77
78use rpi_ai::providers::anthropic::models::anthropic_models;
79use rpi_ai::providers::anthropic::AnthropicProvider;
80use rpi_ai::{Model, Provider, ThinkingLevel};
81
82use crate::args::parse_thinking_level;
83use crate::config::{self, Credential, DEFAULT_PROVIDER_ID};
84
85/// The v1-default model id when `--model` is absent. Mirrors the TS
86/// `defaultModelPerProvider["anthropic"]` (the first current-generation
87/// reasoning model in the catalog).
88pub const DEFAULT_MODEL_ID: &str = "claude-sonnet-5";
89
90/// The default thinking level when neither `--thinking` nor a `:level` suffix
91/// is present. Mirrors the TS `DEFAULT_THINKING_LEVEL` (`"medium"`, clamped to
92/// model capabilities by the harness's provider build_params).
93pub const DEFAULT_THINKING_LEVEL: ThinkingLevel = ThinkingLevel::Medium;
94
95/// The resolved run configuration: the provider handle, the chosen model, and
96/// the effective thinking level (after `--thinking` / `:level` / model-clamp).
97#[derive(Clone)]
98pub struct ResolvedModel {
99    /// The Anthropic provider (carries the API key, or `None` when Bearer
100    /// headers carry the auth). Cheap to clone (`Arc` internally via the
101    /// `Provider` trait object).
102    pub provider: Arc<dyn Provider>,
103    /// The chosen model from the catalog.
104    pub model: Model,
105    /// Effective thinking level (the requested level, before model-clamp — the
106    /// harness/provider clamps to the model's supported set).
107    pub thinking_level: ThinkingLevel,
108}
109
110impl std::fmt::Debug for ResolvedModel {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("ResolvedModel")
113            .field("provider", &self.provider.id())
114            .field("model", &self.model.id)
115            .field("thinking_level", &self.thinking_level)
116            .finish()
117    }
118}
119
120/// The env var consulted for the API key. Mirrors TS `ANTHROPIC_API_KEY`.
121pub const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY";
122
123/// The env var consulted for a bearer token (routed as
124/// `Authorization: Bearer`). Mirrors TS `ANTHROPIC_AUTH_TOKEN` — used by
125/// third-party Anthropic-compatible gateways (one-api/new-api/claude-code-router
126/// and private reverse proxies) that authenticate via `Authorization` rather
127/// than `x-api-key`.
128pub const ANTHROPIC_AUTH_TOKEN_ENV: &str = "ANTHROPIC_AUTH_TOKEN";
129
130/// The env var that overrides the Anthropic endpoint base URL. Mirrors TS
131/// `ANTHROPIC_BASE_URL` — point this at a gateway/proxy that speaks the
132/// `/v1/messages` protocol.
133pub const ANTHROPIC_BASE_URL_ENV: &str = "ANTHROPIC_BASE_URL";
134
135/// Hint text surfaced when no credential source is available. Lists every
136/// accepted source so the user can pick the one that fits their setup.
137pub const NO_API_KEY_HINT: &str =
138    "ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN env, --api-key, or `rpi auth login` (writes ~/.rpi/auth.json)";
139
140/// A resolution error. The TS resolver returns `{ error, warning }`; v1 folds
141/// both into a single enum since the CLI treats them the same (print + non-zero
142/// exit) except `NoApiKey`, which prints guidance then exits.
143#[derive(Debug, thiserror::Error)]
144pub enum ResolveError {
145    #[error("Unknown provider \"{0}\". v1 supports: anthropic")]
146    UnknownProvider(String),
147    #[error("No model matches \"{pattern}\". Available: {available}")]
148    NoMatch { pattern: String, available: String },
149    #[error("Invalid thinking level \"{0}\" in model pattern. Valid: {1}")]
150    InvalidThinkingLevel(String, String),
151    #[error("No API key. Set one of: {hint}")]
152    NoApiKey { hint: &'static str },
153    #[error("Could not read config: {0}")]
154    Config(#[from] config::ConfigError),
155}
156
157/// Resolve the provider + model + thinking level from the CLI flags + env +
158/// `~/.rpi/` config.
159///
160/// `cli_provider` is the `--provider` value (optional). `cli_model` is the
161/// `--model` value (optional; may be `provider/id[:thinking]` or `id[:thinking]`).
162/// `cli_thinking` is the `--thinking` value (optional). `cli_api_key` is the
163/// `--api-key` value (optional; highest-priority `x-api-key` source).
164/// `cli_base_url` is the `--base-url` value (optional; overrides
165/// `ANTHROPIC_BASE_URL` + each model's `base_url`).
166pub fn resolve(
167    cli_provider: Option<&str>,
168    cli_model: Option<&str>,
169    cli_thinking: Option<ThinkingLevel>,
170    cli_api_key: Option<&str>,
171    cli_base_url: Option<&str>,
172) -> Result<ResolvedModel, ResolveError> {
173    // ---- Provider selection (v1: Anthropic protocol only) ----
174    if let Some(req) = cli_provider {
175        if !req.eq_ignore_ascii_case("anthropic") {
176            return Err(ResolveError::UnknownProvider(req.to_string()));
177        }
178    }
179
180    // ---- Auth resolution: provider_key (x-api-key) OR auth_headers (Bearer) ----
181    let mut provider_key: Option<String> = None;
182    let mut auth_headers: BTreeMap<String, String> = BTreeMap::new();
183    // Whether the resolved Bearer came from a `~/.rpi/models.json` gateway
184    // (endpoint-specific — fold onto gateway models only) vs `ANTHROPIC_AUTH_TOKEN`
185    // env (a global credential — fold onto every model). See the fold below.
186    let mut bearer_from_models_json = false;
187
188    // Load the models.json config ONCE — it is consulted both as an auth source
189    // (a provider with `authHeader: true` + `apiKey` supplies a Bearer token,
190    // mirroring upstream `provider-composer.ts` `withConfiguredAuth`) and as the
191    // model catalog merge source (below). Loading here (before the auth gate)
192    // means a static `~/.rpi/models.json` gateway credential can satisfy auth
193    // without any env var or `rpi auth login` — the models.json file alone is a
194    // complete third-party-endpoint setup.
195    let models_cfg = config::load_models_config()?;
196
197    // 1. --api-key (highest-priority x-api-key source).
198    if let Some(k) = cli_api_key.filter(|s| !s.is_empty()) {
199        provider_key = Some(k.to_string());
200    }
201    // 2. ~/.rpi/auth.json anthropic.api_key.key (persistent login).
202    if provider_key.is_none() {
203        if let Ok(store) = config::read_auth() {
204            if let Some(Credential::ApiKey { key: Some(k), .. }) = store.get(DEFAULT_PROVIDER_ID) {
205                if !k.is_empty() {
206                    provider_key = Some(k.clone());
207                }
208            }
209        }
210    }
211    // 3. ~/.rpi/models.json provider with authHeader:true + apiKey → Bearer.
212    //    The first anthropic-compatible provider that declares a static gateway
213    //    key supplies the Bearer token (v1 routes through one provider, so the
214    //    first match is authoritative). Mirrors upstream's `authHeader` handling
215    //    where the resolved apiKey is wrapped as `Authorization: Bearer`. Mark
216    //    this Bearer as endpoint-specific so the fold below targets only the
217    //    gateway's models (NOT the built-in Anthropic catalog).
218    if provider_key.is_none() && auth_headers.is_empty() {
219        if let Some(tok) = models_json_bearer_token(&models_cfg) {
220            auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
221            bearer_from_models_json = true;
222        }
223    }
224    // 4. ANTHROPIC_AUTH_TOKEN → Authorization: Bearer (third-party gateways).
225    if provider_key.is_none() && auth_headers.is_empty() {
226        if let Ok(tok) = std::env::var(ANTHROPIC_AUTH_TOKEN_ENV) {
227            if !tok.is_empty() {
228                auth_headers.insert("authorization".to_string(), format!("Bearer {tok}"));
229            }
230        }
231    }
232    // 5. ANTHROPIC_API_KEY → x-api-key (fallback).
233    if provider_key.is_none() && auth_headers.is_empty() {
234        if let Ok(k) = std::env::var(ANTHROPIC_API_KEY_ENV) {
235            if !k.is_empty() {
236                provider_key = Some(k);
237            }
238        }
239    }
240    // 6. Nothing → clear error listing every accepted source.
241    if provider_key.is_none() && auth_headers.is_empty() {
242        return Err(ResolveError::NoApiKey { hint: NO_API_KEY_HINT });
243    }
244
245    // ---- Endpoint override (--base-url → ANTHROPIC_BASE_URL) ----
246    let base_url_override = cli_base_url
247        .map(|s| s.to_string())
248        .or_else(|| {
249            std::env::var(ANTHROPIC_BASE_URL_ENV)
250                .ok()
251                .filter(|s| !s.is_empty())
252        });
253
254    // ---- Catalog: built-in + ~/.rpi/models.json (merged, reusing the
255    // already-loaded config) ----
256    let mut catalog = anthropic_models();
257    merge_user_catalog(&mut catalog, &models_cfg);
258
259    // Apply the endpoint override to every model (the request URL is built from
260    // `model.base_url` per-request in rpi-ai).
261    if let Some(base) = &base_url_override {
262        for m in catalog.iter_mut() {
263            m.base_url = base.clone();
264        }
265    }
266
267    // Fold the Bearer header (if any) into the catalog — but only onto models
268    // the Bearer is actually meant for. Upstream `withConfiguredAuth` synthesizes
269    // the Bearer per-provider: a models.json gateway's Bearer rides only on that
270    // gateway's models, NOT the built-in Anthropic claude-* catalog (whose
271    // `base_url` is `api.anthropic.com`). Folding it onto every model — the old
272    // behavior — meant the *default* model (`claude-sonnet-5`, whose base_url is
273    // Anthropic) carried a gateway Bearer to the wrong endpoint → 401 "Invalid
274    // bearer token".
275    //
276    // Two Bearer sources, two fold scopes:
277    //  - `~/.rpi/models.json` gateway (`bearer_from_models_json`): endpoint-
278    //    specific. Fold onto gateway models only — a model counts as a "gateway
279    //    model" when either (a) a `--base-url`/`ANTHROPIC_BASE_URL` override
280    //    rewrote every model's `base_url`, or (b) the model's own `base_url` was
281    //    set to a non-Anthropic URL by `provider_to_models` (i.e. it came from
282    //    `models.json`). Built-in `claude-*` keeps `api.anthropic.com` → stays
283    //    Bearer-less. This is what lets `pick_default_model` pick the gateway
284    //    model (the only authed one) in a gateway-only setup.
285    //  - `ANTHROPIC_AUTH_TOKEN` env: a global credential the user intends for the
286    //    configured endpoint (either the built-in Anthropic endpoint or a
287    //    `--base-url` override). Fold onto EVERY model so the default
288    //    `claude-sonnet-5` carries it — matching the pre-gateway behavior and
289    //    the TS behavior where an env Bearer is a provider-level credential.
290    if !auth_headers.is_empty() && !bearer_from_models_json {
291        // ANTHROPIC_AUTH_TOKEN: global — stamp onto every model.
292        for m in catalog.iter_mut() {
293            let headers = m.headers.get_or_insert_with(BTreeMap::new);
294            for (k, v) in &auth_headers {
295                headers.insert(k.clone(), v.clone());
296            }
297        }
298    } else if !auth_headers.is_empty() {
299        // models.json gateway Bearer: endpoint-specific — gateway models only.
300        let override_active = base_url_override.is_some();
301        for m in catalog.iter_mut() {
302            let is_gateway =
303                override_active || m.base_url != config::ANTHROPIC_DEFAULT_BASE_URL;
304            if is_gateway {
305                let headers = m.headers.get_or_insert_with(BTreeMap::new);
306                for (k, v) in &auth_headers {
307                    headers.insert(k.clone(), v.clone());
308                }
309            }
310        }
311    }
312
313    let available = catalog
314        .iter()
315        .map(|m| m.id.clone())
316        .collect::<Vec<_>>()
317        .join(", ");
318
319    // ---- Model selection ----
320    // With `--model`: parse the pattern (`provider/id[:thinking]`), match it
321    // exactly against the catalog (TS fuzzy/partial match is a deliberate v1
322    // omission — see module docs §5). Without `--model`: pick the default per
323    // upstream `findInitialModel` semantics (built-in default if it is authed,
324    // else the first authed model) via `pick_default_model` — see its doc.
325    let (model, thinking_level) = match cli_model {
326        Some(raw) => {
327            let (pattern, pattern_thinking) = split_model_pattern(raw);
328            // `--thinking` wins over a `:level` suffix; else default.
329            let thinking_level = cli_thinking
330                .or(pattern_thinking)
331                .unwrap_or(DEFAULT_THINKING_LEVEL);
332            let model = match find_model(&pattern, &catalog) {
333                Some(m) => m,
334                None => {
335                    return Err(ResolveError::NoMatch {
336                        pattern: pattern.clone(),
337                        available,
338                    });
339                }
340            };
341            (model, thinking_level)
342        }
343        None => {
344            // No `:level` suffix to honor here; `--thinking` still wins, else
345            // the default. Matches the TS `findInitialModel` default-thinking
346            // behavior (DEFAULT_THINKING_LEVEL unless a scoped model overrides).
347            let thinking_level = cli_thinking.unwrap_or(DEFAULT_THINKING_LEVEL);
348            let model = pick_default_model(&catalog, provider_key.as_deref());
349            (model, thinking_level)
350        }
351    };
352
353    // ---- Provider build ----
354    // Bearer path: `provider_key = None` — the model headers carry the auth
355    // (`has_header_auth` skips x-api-key). x-api-key path: pass the key.
356    let provider: Arc<dyn Provider> = Arc::new(AnthropicProvider::with_models(
357        provider_key,
358        reqwest::Client::new(),
359        catalog,
360    ));
361
362    Ok(ResolvedModel { provider, model, thinking_level })
363}
364
365/// Merge `~/.rpi/models.json` providers into the built-in catalog. Models from
366/// the user file replace any built-in entry with the same id (custom
367/// definitions win); brand-new ids are appended. Non-`anthropic-messages`
368/// providers are skipped (ignored in v1, documented). Takes the already-loaded
369/// config so the file is read once per `resolve`.
370fn merge_user_catalog(catalog: &mut Vec<Model>, cfg: &config::ModelsConfig) {
371    for (provider_id, provider_cfg) in &cfg.providers {
372        let Some(models) = config::provider_to_models(provider_id, provider_cfg) else {
373            // Non-anthropic protocol — ignored in v1 (documented).
374            continue;
375        };
376        for m in models {
377            if let Some(existing) = catalog.iter_mut().find(|c| c.id.eq_ignore_ascii_case(&m.id)) {
378                *existing = m;
379            } else {
380                catalog.push(m);
381            }
382        }
383    }
384}
385
386/// Extract a static gateway Bearer token from the first anthropic-compatible
387/// models.json provider that declares `authHeader: true` + a non-empty
388/// `apiKey`. Mirrors upstream's `withConfiguredAuth` (`authHeader` wraps the
389/// resolved key as `Authorization: Bearer`). Returns `None` when no such
390/// provider exists (the env/stored-cred/cli-flag sources still apply).
391fn models_json_bearer_token(cfg: &config::ModelsConfig) -> Option<String> {
392    for (_provider_id, provider_cfg) in &cfg.providers {
393        if !config::provider_is_anthropic_compatible(provider_cfg) {
394            continue;
395        }
396        if provider_cfg.auth_header.unwrap_or(false) {
397            if let Some(key) = provider_cfg.api_key.as_deref().filter(|s| !s.is_empty()) {
398                return Some(key.to_string());
399            }
400        }
401    }
402    None
403}
404
405/// Split a `--model` value into `(id_pattern, optional_thinking_level)`.
406///
407/// Handles `provider/id[:thinking]` (strips a leading `anthropic/` or any other
408/// `foo/` prefix so a `models.json` provider id addresses its model) and
409/// `id[:thinking]`. A trailing `:level` is parsed as a thinking level only if
410/// it is a valid level string; otherwise the whole tail is kept in the id
411/// pattern (some model ids legitimately contain colons — none do in the v1
412/// Anthropic catalog, but the parser stays conservative).
413///
414/// Mirrors the TS `parseModelPattern` last-colon split + recurse-on-prefix.
415fn split_model_pattern(value: &str) -> (String, Option<ThinkingLevel>) {
416    // Strip a leading `provider/` prefix. `anthropic/` is the common case; any
417    // other `foo/` prefix is also stripped so a `models.json` provider id (e.g.
418    // `gateway/custom-claude`) resolves to the `custom-claude` catalog entry.
419    let trimmed = value
420        .strip_prefix("anthropic/")
421        .or_else(|| value.strip_prefix("Anthropic/"))
422        .or_else(|| {
423            if let Some(idx) = value.find('/') {
424                Some(&value[idx + 1..])
425            } else {
426                None
427            }
428        })
429        .unwrap_or(value);
430
431    // Last-colon split: if the suffix is a valid thinking level, peel it.
432    if let Some(idx) = trimmed.rfind(':') {
433        let (head, tail) = trimmed.split_at(idx);
434        let suffix = &tail[1..]; // drop the ':'
435        if let Some(level) = parse_thinking_level(suffix) {
436            return (head.to_string(), Some(level));
437        }
438    }
439    (trimmed.to_string(), None)
440}
441
442/// Case-insensitive exact id match against the catalog. The TS resolver also
443/// does partial/fuzzy match; v1 keeps it exact (see module docs).
444fn find_model(pattern: &str, catalog: &[Model]) -> Option<Model> {
445    catalog
446        .iter()
447        .find(|m| m.id.eq_ignore_ascii_case(pattern))
448        .cloned()
449}
450
451/// Whether a catalog model is "configured-auth" — i.e. the request built for it
452/// would pass `assertRequestAuth` and not return "No API key". Mirrors the TS
453/// `hasConfiguredAuth(providerId)` filter that `getAvailableSnapshot()` applies
454/// (`available = all.filter(m => configuredProviders.has(m.provider))`).
455///
456/// In v1's single-provider world, "configured auth" is decided statically after
457/// the Bearer fold: a model counts as authed when EITHER
458/// (a) it carries an auth-owned header (`authorization`/`x-api-key`/`cf-aig-…`)
459///     — the Bearer fold has stamped a gateway/env Bearer onto it — OR
460/// (b) the provider holds a resolved `provider_key` (the x-api-key path:
461///     `--api-key`/auth.json/`ANTHROPIC_API_KEY`), which `assemble_headers`
462///     attaches out-of-band to every model regardless of `headers`.
463///
464/// This is called *after* the Bearer fold, so `has_header_auth(&m.headers)`
465/// truthfully reflects whether a Bearer was folded onto *this* model (gateway
466/// models only — see the fold's `is_gateway` gate; built-in claude-* without an
467/// override stay Bearer-less).
468fn model_is_authed(m: &Model, provider_key: Option<&str>) -> bool {
469    model_has_header_auth(m) || provider_key.is_some()
470}
471
472/// Same three-name check as rpi-ai's `has_header_auth`, but called from the
473/// CLI layer (rpi-ai's `has_header_auth` is private to the provider module, so
474/// we mirror it here over the model's `headers` map).
475fn model_has_header_auth(m: &Model) -> bool {
476    let Some(h) = &m.headers else { return false };
477    const NAMES: &[&str] = &["authorization", "x-api-key", "cf-aig-authorization"];
478    h.keys()
479        .any(|k| NAMES.contains(&k.to_ascii_lowercase().as_str()))
480}
481
482/// Choose the default model when `--model` is absent. Mirrors upstream
483/// `findInitialModel` [`packages/coding-agent/src/core/model-resolver.ts`]:
484/// the built-in default (`claude-sonnet-5`) wins *if it has configured auth*;
485/// otherwise fall back to the first authed model in the catalog (the TS
486/// `availableModels[0]` when no `defaultModelPerProvider` entry matches — e.g.
487/// a `~/.rpi/models.json` gateway is the only configured endpoint). This fixes
488/// the gateway-only case where the old hard-coded `claude-sonnet-5` default
489/// carried a gateway Bearer to `api.anthropic.com` and 401'd.
490///
491/// `provider_key` is the resolved x-api-key (`Some` on the `--api-key`/
492/// auth.json/`ANTHROPIC_API_KEY` path; `None` on the Bearer path). It is passed
493/// in (not read from a field) because the auth decision is local to `resolve`.
494fn pick_default_model(catalog: &[Model], provider_key: Option<&str>) -> Model {
495    // 1. Built-in default, when it is authed — preserves the standard
496    //    `ANTHROPIC_API_KEY`/`auth.json` behavior (claude-sonnet-5).
497    if let Some(m) = catalog
498        .iter()
499        .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
500        .filter(|m| model_is_authed(m, provider_key))
501    {
502        return m.clone();
503    }
504    // 2. First authed model (TS `availableModels[0]`). In a gateway-only setup
505    //    this is the gateway model (Bearer folded onto it, base_url = gateway).
506    if let Some(m) = catalog
507        .iter()
508        .find(|m| model_is_authed(m, provider_key))
509    {
510        return m.clone();
511    }
512    // 3. Last resort: the built-in default, authed or not. The auth gate above
513    //    already errored when no source resolved, so reaching here means *some*
514    //    auth exists but none folded/attached to a model we can see — keep the
515    //    historical default to avoid a NoMatch surprise.
516    catalog
517        .iter()
518        .find(|m| m.id.eq_ignore_ascii_case(DEFAULT_MODEL_ID))
519        .or_else(|| catalog.first())
520        .expect("catalog is never empty (built-in anthropic_models)")
521        .clone()
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use crate::args::{parse_thinking_level, VALID_THINKING_LEVELS};
528    use crate::config::test_support::env_lock;
529
530    /// Scope a test to a throwaway config dir + clear the `ANTHROPIC_*` env
531    /// vars, restoring both on drop. Holds the shared env lock for its whole
532    /// lifetime so parallel env-mutating tests across config/provider/auth all
533    /// serialize on one mutex.
534    struct TestEnv {
535        _guard: std::sync::MutexGuard<'static, ()>,
536        prev_key: Option<std::ffi::OsString>,
537        prev_tok: Option<std::ffi::OsString>,
538        prev_base: Option<std::ffi::OsString>,
539        prev_dir: Option<std::ffi::OsString>,
540        _tmp: tempfile::TempDir,
541    }
542    impl TestEnv {
543        fn new() -> Self {
544            let guard = env_lock().lock().unwrap();
545            let prev_key = std::env::var_os(ANTHROPIC_API_KEY_ENV);
546            let prev_tok = std::env::var_os(ANTHROPIC_AUTH_TOKEN_ENV);
547            let prev_base = std::env::var_os(ANTHROPIC_BASE_URL_ENV);
548            let prev_dir = std::env::var_os(config::CONFIG_DIR_ENV);
549            std::env::remove_var(ANTHROPIC_API_KEY_ENV);
550            std::env::remove_var(ANTHROPIC_AUTH_TOKEN_ENV);
551            std::env::remove_var(ANTHROPIC_BASE_URL_ENV);
552            let tmp = tempfile::TempDir::new().unwrap();
553            std::env::set_var(config::CONFIG_DIR_ENV, tmp.path());
554            Self {
555                _guard: guard,
556                prev_key,
557                prev_tok,
558                prev_base,
559                prev_dir,
560                _tmp: tmp,
561            }
562        }
563    }
564    impl Drop for TestEnv {
565        fn drop(&mut self) {
566            restore(ANTHROPIC_API_KEY_ENV, self.prev_key.take());
567            restore(ANTHROPIC_AUTH_TOKEN_ENV, self.prev_tok.take());
568            restore(ANTHROPIC_BASE_URL_ENV, self.prev_base.take());
569            restore(config::CONFIG_DIR_ENV, self.prev_dir.take());
570        }
571    }
572    fn restore(name: &str, prev: Option<std::ffi::OsString>) {
573        match prev {
574            Some(v) => std::env::set_var(name, v),
575            None => std::env::remove_var(name),
576        }
577    }
578
579    // These tests hit the network-free resolution path only (provider/model
580    // selection). They set a throwaway credential so `resolve` clears the
581    // `NoApiKey` gate, then assert the model + thinking choice — never making
582    // a real request.
583
584    fn resolve_with_key(
585        provider: Option<&str>,
586        model: Option<&str>,
587        thinking: Option<ThinkingLevel>,
588    ) -> Result<ResolvedModel, ResolveError> {
589        let _env = TestEnv::new();
590        std::env::set_var(ANTHROPIC_API_KEY_ENV, "test-key");
591        resolve(provider, model, thinking, None, None)
592    }
593
594    #[test]
595    fn default_model_is_sonnet_5() {
596        let r = resolve_with_key(None, None, None).unwrap();
597        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
598        assert_eq!(r.thinking_level, DEFAULT_THINKING_LEVEL);
599        assert_eq!(r.provider.id(), "anthropic");
600    }
601
602    #[test]
603    fn explicit_id_match() {
604        let r = resolve_with_key(None, Some("claude-haiku-4-5"), None).unwrap();
605        assert_eq!(r.model.id, "claude-haiku-4-5");
606    }
607
608    #[test]
609    fn case_insensitive_id() {
610        let r = resolve_with_key(None, Some("CLAUDE-OPUS-5"), None).unwrap();
611        assert_eq!(r.model.id, "claude-opus-5");
612    }
613
614    #[test]
615    fn provider_prefix_stripped() {
616        let r = resolve_with_key(None, Some("anthropic/claude-sonnet-5"), None).unwrap();
617        assert_eq!(r.model.id, "claude-sonnet-5");
618    }
619
620    #[test]
621    fn custom_provider_prefix_stripped() {
622        // `gateway/custom-claude` resolves to the catalog id `custom-claude`
623        // after the `foo/` prefix is stripped.
624        let _env = TestEnv::new();
625        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
626        std::fs::write(
627            config::models_path().unwrap(),
628            r#"{ "providers": { "gateway": { "baseUrl": "https://gw", "models": [{"id":"custom-claude"}] } } }"#,
629        )
630        .unwrap();
631        let r = resolve(None, Some("gateway/custom-claude"), None, None, None).unwrap();
632        assert_eq!(r.model.id, "custom-claude");
633    }
634
635    #[test]
636    fn thinking_suffix_in_model() {
637        let r = resolve_with_key(None, Some("claude-sonnet-5:high"), None).unwrap();
638        assert_eq!(r.model.id, "claude-sonnet-5");
639        assert_eq!(r.thinking_level, ThinkingLevel::High);
640    }
641
642    #[test]
643    fn thinking_flag_overrides_suffix() {
644        // `--thinking low` wins over a `:high` suffix.
645        let r =
646            resolve_with_key(None, Some("claude-sonnet-5:high"), Some(ThinkingLevel::Low)).unwrap();
647        assert_eq!(r.thinking_level, ThinkingLevel::Low);
648    }
649
650    #[test]
651    fn explicit_provider_anthropic_ok() {
652        let r = resolve_with_key(Some("anthropic"), Some("claude-sonnet-5"), None).unwrap();
653        assert_eq!(r.model.id, "claude-sonnet-5");
654    }
655
656    #[test]
657    fn unknown_provider_rejected() {
658        let err = resolve_with_key(Some("openai"), None, None).unwrap_err();
659        assert!(matches!(err, ResolveError::UnknownProvider(_)));
660    }
661
662    #[test]
663    fn no_match_lists_available() {
664        let err = resolve_with_key(None, Some("claude-does-not-exist"), None).unwrap_err();
665        match err {
666            ResolveError::NoMatch { pattern, available } => {
667                assert_eq!(pattern, "claude-does-not-exist");
668                assert!(available.contains("claude-sonnet-5"));
669            }
670            other => panic!("expected NoMatch, got {other:?}"),
671        }
672    }
673
674    #[test]
675    fn colon_not_a_thinking_level_kept_in_id() {
676        // A trailing `:foo` that isn't a thinking level stays part of the id
677        // pattern → no match (no model id contains `:foo`).
678        let err = resolve_with_key(None, Some("claude-sonnet-5:foo"), None).unwrap_err();
679        assert!(matches!(err, ResolveError::NoMatch { .. }));
680    }
681
682    #[test]
683    fn parse_thinking_level_roundtrip() {
684        assert_eq!(parse_thinking_level("xhigh"), Some(ThinkingLevel::Xhigh));
685        assert_eq!(parse_thinking_level("bogus"), None);
686        // Sanity: the valid set matches what help advertises.
687        for lvl in VALID_THINKING_LEVELS {
688            assert!(parse_thinking_level(lvl).is_some(), "{lvl} should parse");
689        }
690    }
691
692    #[test]
693    fn no_api_key_errors_with_hint() {
694        let _env = TestEnv::new();
695        let err = resolve(None, None, None, None, None).unwrap_err();
696        match err {
697            ResolveError::NoApiKey { hint } => {
698                assert!(hint.contains("ANTHROPIC_API_KEY"));
699                assert!(hint.contains("auth login"));
700            }
701            other => panic!("expected NoApiKey, got {other:?}"),
702        }
703    }
704
705    #[test]
706    fn stored_credential_satisfies_auth() {
707        let _env = TestEnv::new();
708        config::upsert_credential(
709            DEFAULT_PROVIDER_ID,
710            Credential::ApiKey { key: Some("stored-key".into()), env: None },
711        )
712        .unwrap();
713        let r = resolve(None, None, None, None, None).unwrap();
714        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
715        // x-api-key path: no Bearer header folded onto the model (auth rides on
716        // the provider's default key, surfaced to the provider at build time).
717        assert!(
718            r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
719            "x-api-key path should not synthesize a Bearer header"
720        );
721    }
722
723    #[test]
724    fn auth_token_routes_via_bearer_header() {
725        let _env = TestEnv::new();
726        std::env::set_var(ANTHROPIC_AUTH_TOKEN_ENV, "tok-123");
727        let r = resolve(None, None, None, None, None).unwrap();
728        // No provider key carries auth — it lives on the model header.
729        let headers = r.model.headers.as_ref().expect("bearer header on model");
730        assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer tok-123"));
731        // ANTHROPIC_AUTH_TOKEN is a *global* credential (not endpoint-specific
732        // like a models.json gateway key): the default claude-sonnet-5 is picked
733        // (it carries the env Bearer) — NOT a gateway model.
734        assert_eq!(r.model.id, DEFAULT_MODEL_ID);
735    }
736
737    #[test]
738    fn api_key_flag_beats_env_and_stored() {
739        let _env = TestEnv::new();
740        std::env::set_var(ANTHROPIC_API_KEY_ENV, "env-key");
741        config::upsert_credential(
742            DEFAULT_PROVIDER_ID,
743            Credential::ApiKey { key: Some("stored-key".into()), env: None },
744        )
745        .unwrap();
746        // `--api-key flag-key` wins; resolve succeeds + takes the x-api-key path
747        // (no Bearer header on the model).
748        let r = resolve(None, None, None, Some("flag-key"), None).unwrap();
749        assert!(
750            r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
751            "--api-key should take the x-api-key path, not Bearer"
752        );
753    }
754
755    #[test]
756    fn base_url_override_applies_to_model() {
757        let _env = TestEnv::new();
758        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
759        let r = resolve(None, None, None, None, Some("https://gw.example.com")).unwrap();
760        assert_eq!(r.model.base_url, "https://gw.example.com");
761    }
762
763    #[test]
764    fn base_url_env_is_fallback_for_flag() {
765        let _env = TestEnv::new();
766        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
767        std::env::set_var(ANTHROPIC_BASE_URL_ENV, "https://env-gw.example.com");
768        let r = resolve(None, None, None, None, None).unwrap();
769        assert_eq!(r.model.base_url, "https://env-gw.example.com");
770    }
771
772    #[test]
773    fn models_json_adds_custom_model() {
774        let _env = TestEnv::new();
775        std::env::set_var(ANTHROPIC_API_KEY_ENV, "k");
776        std::fs::write(
777            config::models_path().unwrap(),
778            r#"{
779  "providers": {
780    "gateway": {
781      "baseUrl": "https://gw.example.com",
782      "authHeader": true,
783      "apiKey": "gw-secret",
784      "models": [
785        { "id": "custom-claude", "name": "Custom" }
786      ]
787    }
788  }
789}"#,
790        )
791        .unwrap();
792        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
793        assert_eq!(r.model.id, "custom-claude");
794        assert_eq!(r.model.base_url, "https://gw.example.com");
795        // The model is routed through the single AnthropicProvider (provider
796        // stamped "anthropic" by config::provider_to_models).
797        assert_eq!(r.model.provider, DEFAULT_PROVIDER_ID);
798        // Provider-level authHeader folded in.
799        let headers = r.model.headers.as_ref().expect("headers merged");
800        assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
801    }
802
803    /// A models.json gateway with `authHeader:true` + `apiKey` is itself an auth
804    /// source — it satisfies the `resolve` auth gate WITHOUT any env var, stored
805    /// cred, or `--api-key`. This is the "models.json file alone sets up a
806    /// third-party endpoint" path. The Bearer folds onto the gateway model only
807    /// (built-in claude-* stays Bearer-less), and — with no `--model` — the
808    /// default selector picks that gateway model (the only authed one).
809    #[test]
810    fn models_json_auth_header_satisfies_auth_without_env() {
811        let _env = TestEnv::new();
812        // No ANTHROPIC_* env, no auth.json — only the models.json gateway.
813        std::fs::write(
814            config::models_path().unwrap(),
815            r#"{
816  "providers": {
817    "gateway": {
818      "baseUrl": "https://gw.example.com",
819      "api": "anthropic-messages",
820      "authHeader": true,
821      "apiKey": "gw-secret",
822      "models": [
823        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
824      ]
825    }
826  }
827}"#,
828        )
829        .unwrap();
830        let r = resolve(None, Some("custom-claude"), None, None, None).unwrap();
831        assert_eq!(r.model.id, "custom-claude");
832        assert_eq!(r.model.base_url, "https://gw.example.com");
833        let headers = r.model.headers.as_ref().expect("bearer folded onto model");
834        assert_eq!(headers.get("authorization").map(|s| s.as_str()), Some("Bearer gw-secret"));
835    }
836
837    /// The `--api-key` flag wins over a models.json `authHeader:true` gateway
838    /// key (the flag is the highest-priority x-api-key source; the gateway
839    /// Bearer is only consulted when no key path is taken).
840    /// A `models.json`-only gateway config (no `--model`, no env, no auth.json)
841    /// should pick the gateway model by default — mirroring the TS
842    /// `findInitialModel` step-4 fallback `availableModels[0]` over the
843    /// auth-filtered snapshot. The built-in Anthropic models carry no auth in a
844    /// gateway-only setup, so the gateway model is the first (and only)
845    /// authenticated model. This is the `rpi -p hi` (no `--model`) case.
846    #[test]
847    fn default_prefers_gateway_when_only_gateway_configured() {
848        // TestEnv already holds the shared env_lock for its whole lifetime —
849        // don't take it again here (would self-deadlock and poison the mutex).
850        let _env = TestEnv::new();
851        std::fs::write(
852            config::models_path().unwrap(),
853            r#"{
854  "providers": {
855    "gateway": {
856      "baseUrl": "https://gw.example.com",
857      "api": "anthropic-messages",
858      "authHeader": true,
859      "apiKey": "gw-secret",
860      "models": [
861        { "id": "custom-claude", "contextWindow": 200000, "maxTokens": 8192 }
862      ]
863    }
864  }
865}"#,
866        )
867        .unwrap();
868        // No --model (None): the default selector must pick the gateway model,
869        // NOT the built-in claude-sonnet-5 (which would carry a foreign Bearer
870        // to api.anthropic.com → 401, the bug this fixes).
871        let r = resolve(None, None, None, None, None).unwrap();
872        assert_eq!(r.model.id, "custom-claude");
873        assert_eq!(r.model.base_url, "https://gw.example.com");
874        // Gateway model carries the folded Bearer.
875        let headers = r.model.headers.as_ref().expect("bearer on gateway model");
876        assert_eq!(
877            headers.get("authorization").map(|s| s.as_str()),
878            Some("Bearer gw-secret")
879        );
880    }
881
882    #[test]
883    fn api_key_flag_beats_models_json_bearer() {
884        let _env = TestEnv::new();
885        std::fs::write(
886            config::models_path().unwrap(),
887            r#"{
888  "providers": {
889    "gateway": {
890      "baseUrl": "https://gw.example.com",
891      "authHeader": true,
892      "apiKey": "gw-secret",
893      "models": [ { "id": "custom-claude" } ]
894    }
895  }
896}"#,
897        )
898        .unwrap();
899        let r = resolve(None, Some("custom-claude"), None, Some("flag-key"), None).unwrap();
900        // --api-key path: no Bearer folded on (the gateway bearer is skipped).
901        assert!(
902            r.model.headers.as_ref().and_then(|h| h.get("authorization")).is_none(),
903            "--api-key should win over the models.json gateway bearer"
904        );
905    }
906}