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}