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