Skip to main content

agentmux_srv/backend/
model_catalog.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Model catalog — authoritative Claude model list from the Anthropic Models
5//! API (`GET /v1/models`), fetched with the agent's OAuth Bearer token.
6//!
7//! Why this exists: the agent-pane model dropdown was a hand-curated list whose
8//! version labels ("Sonnet 4.6", …) drift every time Anthropic ships a model.
9//! The CLI has no list-models surface, and prompting a model for the list is
10//! hallucination-prone — so we read the authoritative catalog from
11//! `GET /v1/models`. Verified 2026-07-02: a Claude **Max subscription** OAuth
12//! token returns HTTP 200 (no API key, no `oauth-2025-04-20` beta header) with
13//! the current models incl. `claude-sonnet-5` | "Claude Sonnet 5".
14//!
15//! This is a **metadata read only** — never call `/v1/messages` with this token
16//! (that would cross the subscription-vs-API billing line). Access is treated as
17//! best-effort: any failure (missing token on macOS Keychain, 401/expiry,
18//! network) returns `None`, and the caller falls back to the bundled curated
19//! catalog. See `docs/specs/SPEC_MODEL_CATALOG_REFRESH_2026_07_02.md`.
20//!
21//! STATUS: fetch + parse layer (this file). Remaining wiring per the spec —
22//! not yet implemented here:
23//!   - cache the result per `(provider, cli_version)` under the data dir;
24//!   - expose via a `providers.models` RPC (+ add `models` to Rust
25//!     `ProviderConfig`, `backend/providers.rs`);
26//!   - trigger a refresh on CLI install/upgrade (`install_handlers.rs` /
27//!     `cli_handlers.rs`), plus an optional lazy ">30d & token valid" refetch
28//!     modeled on `backend/cron/mod.rs` / `memory_heartbeat.rs`;
29//!   - frontend: overlay `providers/index.ts` `models` with the cached catalog
30//!     and converge the strip / `/model` dropdowns to read it.
31
32use std::path::Path;
33
34use crate::identity::key_validator::client;
35use crate::identity::secret_store;
36
37/// Fixed, sentinel account id for the user-supplied long-lived
38/// `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`), persisted via
39/// `identity::secret_store::put`/`get`. Those wrap it in `account_key()`
40/// (`"acct:{account_id}"`), so the actual keychain entry is
41/// `"acct:system:claude-code-oauth-token"` — this constant only needs to be
42/// distinct as an *account id* from the per-identity account ids used
43/// elsewhere (see `identity::resolver`, `identity::oauth_client`), which are
44/// account UUIDs and would never collide with this sentinel string. This is a
45/// single, system-wide fallback token, not tied to any AgentMux identity.
46const CLAUDE_OAUTH_TOKEN_ACCOUNT: &str = "system:claude-code-oauth-token";
47
48/// One entry in the model dropdown. `id` is the concrete `--model` value the
49/// CLI accepts (e.g. `claude-sonnet-5`); `display_name` is the label to show
50/// (e.g. "Claude Sonnet 5"), taken verbatim from the API.
51#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize, ts_rs::TS)]
52#[ts(export, export_to = "../../../frontend/types/rpc/")]
53pub struct CatalogModel {
54    pub id: String,
55    pub display_name: String,
56}
57
58/// Read the Claude OAuth access token from an isolated auth dir's
59/// `.credentials.json` (`claudeAiOauth.accessToken`). Returns `None` when the
60/// file/token is absent — notably on **macOS**, where the CLI stores creds in
61/// the Keychain rather than this file (see the `COMMAND_CHECK_CLI_AUTH`
62/// handler in `cli_handlers.rs`); [`resolve_access_token`] then tries its
63/// other sources before falling back to the bundled catalog. Windows/Linux
64/// keep the token here.
65pub fn read_oauth_access_token(config_dir: &Path) -> Option<String> {
66    let creds_path = config_dir.join(".credentials.json");
67    let content = std::fs::read_to_string(&creds_path).ok()?;
68    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
69    let token = json
70        .get("claudeAiOauth")
71        .and_then(|o| o.get("accessToken"))
72        .and_then(|v| v.as_str())?;
73    if token.is_empty() {
74        None
75    } else {
76        Some(token.to_string())
77    }
78}
79
80/// Resolve the Claude OAuth access token to use for the model-catalog fetch,
81/// trying multiple sources in order before giving up (→ caller falls back to
82/// the bundled curated catalog, unchanged from before this existed). Order:
83///
84///   1. [`read_oauth_access_token`] — the `.credentials.json` file written by
85///      the Claude Code CLI. Covers Linux/Windows with zero behavior change.
86///   2. The `CLAUDE_CODE_OAUTH_TOKEN` env var — the long-lived token a user
87///      gets by running `claude setup-token` themselves in a real terminal
88///      (Anthropic's documented recipe for headless/CI use of Claude Code)
89///      and exporting before launching AgentMux. This is how macOS — where
90///      Claude Code keeps its token in the Keychain, not a plain file — gets
91///      a token at all. When found, it is persisted into AgentMux's own
92///      OS-keychain-backed secret store (`identity::secret_store`) under
93///      [`CLAUDE_OAUTH_TOKEN_ACCOUNT`] so a later run still has it even
94///      without the env var set (e.g. the app relaunched from the Dock
95///      rather than from the terminal that had it exported).
96///   3. AgentMux's own secret store — a token persisted by a previous run of
97///      step 2.
98///
99/// Callers should use this instead of calling [`read_oauth_access_token`]
100/// directly. Never logs the token.
101///
102/// `allow_keychain_fallback` gates the two operations in steps 2/3 that
103/// actually touch the OS keychain (via `identity::secret_store`, which
104/// wraps blocking `keyring` syscalls): *persisting* a freshly-read env-var
105/// token, and reading back a *previously-persisted* one. Either can trigger
106/// an interactive macOS "App wants to use your confidential information"
107/// password prompt whenever a stored entry exists under a code signature
108/// the OS doesn't already trust (e.g. a rebuilt local dev binary). That's
109/// fine for a flow the user explicitly triggered, but this function's only
110/// caller today (`providers.models`) is a silent, fire-and-forget background
111/// refresh fired on every app launch (`frontend/app-init.ts`) — a keychain
112/// prompt appearing unprompted at startup, for a purely cosmetic
113/// model-label refresh, is not acceptable.
114///
115/// Reading the `CLAUDE_CODE_OAUTH_TOKEN` env var itself is a plain
116/// process-env read with no keychain interaction, so it's always
117/// attempted regardless of this flag (Codex P2, PR #2654) — only the
118/// persist-on-find and read-the-stored-copy operations are skipped when
119/// `false`. Pass `false` from any automatic/background caller; pass `true`
120/// only from a path the user explicitly initiated.
121pub async fn resolve_access_token(config_dir: &Path, allow_keychain_fallback: bool) -> Option<String> {
122    if let Some(token) = read_oauth_access_token(config_dir) {
123        return Some(token);
124    }
125    tokio::task::spawn_blocking(move || {
126        resolve_fallback_access_token(
127            || std::env::var("CLAUDE_CODE_OAUTH_TOKEN").ok(),
128            |token| {
129                if !allow_keychain_fallback {
130                    return;
131                }
132                if let Err(e) = secret_store::put(CLAUDE_OAUTH_TOKEN_ACCOUNT, token) {
133                    tracing::warn!("model catalog: failed to persist CLAUDE_CODE_OAUTH_TOKEN: {e}");
134                }
135            },
136            || {
137                if !allow_keychain_fallback {
138                    return None;
139                }
140                secret_store::get(CLAUDE_OAUTH_TOKEN_ACCOUNT)
141                    .ok()
142                    .map(|z| z.to_string())
143            },
144        )
145    })
146    .await
147    .unwrap_or(None)
148}
149
150/// Pure resolution-order logic backing steps 2/3 of [`resolve_access_token`],
151/// factored out so it is unit-testable without touching a real OS keychain
152/// (`identity::secret_store` wraps the `keyring` crate directly with no
153/// mockable seam, so tests inject plain closures here instead).
154fn resolve_fallback_access_token(
155    env_var: impl FnOnce() -> Option<String>,
156    persist: impl FnOnce(&str),
157    stored: impl FnOnce() -> Option<String>,
158) -> Option<String> {
159    if let Some(token) = env_var().filter(|t| !t.is_empty()) {
160        persist(&token);
161        return Some(token);
162    }
163    stored().filter(|t| !t.is_empty())
164}
165
166/// Map a `GET /v1/models` response body (`{ "data": [ { id, display_name }, … ] }`)
167/// to our catalog. `display_name` falls back to `id` when absent. Extracted from
168/// the network call so it is unit-testable without an outbound request.
169fn parse_models(body: &serde_json::Value) -> Vec<CatalogModel> {
170    body.get("data")
171        .and_then(|v| v.as_array())
172        .map(|arr| {
173            arr.iter()
174                .filter_map(|m| {
175                    let id = m.get("id")?.as_str()?.to_string();
176                    let display_name = m
177                        .get("display_name")
178                        .and_then(|v| v.as_str())
179                        .unwrap_or(id.as_str())
180                        .to_string();
181                    Some(CatalogModel { id, display_name })
182                })
183                .collect()
184        })
185        .unwrap_or_default()
186}
187
188/// Fetch the authoritative model catalog. Returns `None` (→ caller uses the
189/// bundled fallback) on network error, non-2xx (incl. 401 token expiry), or an
190/// empty/garbled body. Never logs the token.
191pub async fn fetch_model_catalog(access_token: &str) -> Option<Vec<CatalogModel>> {
192    let resp = match client()
193        .get("https://api.anthropic.com/v1/models")
194        .header("Authorization", format!("Bearer {access_token}"))
195        .header("anthropic-version", "2023-06-01")
196        .send()
197        .await
198    {
199        Ok(r) => r,
200        Err(e) => {
201            tracing::warn!("model catalog: network error: {e}");
202            return None;
203        }
204    };
205    if !resp.status().is_success() {
206        // 401 here means the OAuth token expired — keep last-good cache and
207        // refetch on the next auth/install event (spec §3 caveats).
208        tracing::warn!("model catalog: HTTP {} from /v1/models", resp.status());
209        return None;
210    }
211    let body: serde_json::Value = match resp.json().await {
212        Ok(b) => b,
213        Err(e) => {
214            tracing::warn!("model catalog: bad JSON: {e}");
215            return None;
216        }
217    };
218    let models = parse_models(&body);
219    if models.is_empty() {
220        None
221    } else {
222        Some(models)
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[tokio::test]
231    async fn resolve_access_token_never_touches_keychain_when_fallback_disallowed() {
232        // The fix for the app-launch keychain-prompt regression: when
233        // `allow_keychain_fallback` is false, no `.credentials.json` exists
234        // (the macOS case), and the env var isn't set either, this must
235        // return None without ever entering the keychain-touching persist/
236        // read-back branch — not just "return the same result," but
237        // structurally skip it, since even a *read* of a stale keychain
238        // entry can trigger an OS prompt. This test can't observe "no
239        // syscall happened" directly, but a real keychain entry under
240        // CLAUDE_OAUTH_TOKEN_ACCOUNT existing on the machine this test runs
241        // on would make the old (pre-fix) code path return Some(..) — so
242        // None here is a meaningful assertion, not a vacuous one, whenever
243        // such an entry happens to be present.
244        let _lock = crate::test_support::ISOLATED_AUTH_ENV_LOCK
245            .lock()
246            .unwrap_or_else(|e| e.into_inner());
247        let had_env = std::env::var("CLAUDE_CODE_OAUTH_TOKEN").ok();
248        std::env::remove_var("CLAUDE_CODE_OAUTH_TOKEN");
249
250        let tmp = tempfile::tempdir().unwrap();
251        let got = resolve_access_token(tmp.path(), false).await;
252
253        if let Some(v) = had_env {
254            std::env::set_var("CLAUDE_CODE_OAUTH_TOKEN", v);
255        }
256        assert_eq!(got, None);
257    }
258
259    #[tokio::test]
260    async fn resolve_access_token_still_honors_env_var_when_fallback_disallowed() {
261        // Codex P2, PR #2654: reading CLAUDE_CODE_OAUTH_TOKEN is a plain
262        // process-env read with no keychain interaction, so it must still
263        // resolve even with `allow_keychain_fallback: false` — only
264        // persisting it (or reading a previously-persisted copy) is
265        // keychain-touching and gated. Before this fix, disallowing
266        // keychain fallback also silently dropped this env var, making the
267        // only real caller (providers.models) always return an empty
268        // catalog for exactly the users this env var exists for.
269        let _lock = crate::test_support::ISOLATED_AUTH_ENV_LOCK
270            .lock()
271            .unwrap_or_else(|e| e.into_inner());
272        let had_env = std::env::var("CLAUDE_CODE_OAUTH_TOKEN").ok();
273        std::env::set_var("CLAUDE_CODE_OAUTH_TOKEN", "sk-ant-oat-test-token");
274
275        let tmp = tempfile::tempdir().unwrap();
276        let got = resolve_access_token(tmp.path(), false).await;
277
278        match had_env {
279            Some(v) => std::env::set_var("CLAUDE_CODE_OAUTH_TOKEN", v),
280            None => std::env::remove_var("CLAUDE_CODE_OAUTH_TOKEN"),
281        }
282        assert_eq!(got.as_deref(), Some("sk-ant-oat-test-token"));
283    }
284
285    #[test]
286    fn parses_models_and_falls_back_display_name_to_id() {
287        let body = serde_json::json!({
288            "data": [
289                { "id": "claude-sonnet-5", "display_name": "Claude Sonnet 5" },
290                { "id": "claude-opus-4-8" } // no display_name → falls back to id
291            ]
292        });
293        let got = parse_models(&body);
294        assert_eq!(got.len(), 2);
295        assert_eq!(got[0], CatalogModel { id: "claude-sonnet-5".into(), display_name: "Claude Sonnet 5".into() });
296        assert_eq!(got[1].display_name, "claude-opus-4-8");
297    }
298
299    #[test]
300    fn empty_when_data_missing_or_wrong_shape() {
301        assert!(parse_models(&serde_json::json!({})).is_empty());
302        assert!(parse_models(&serde_json::json!({ "data": "nope" })).is_empty());
303    }
304
305    #[test]
306    fn skips_entries_without_id() {
307        let body = serde_json::json!({ "data": [ { "display_name": "no id here" }, { "id": "claude-haiku-4-5" } ] });
308        let got = parse_models(&body);
309        assert_eq!(got.len(), 1);
310        assert_eq!(got[0].id, "claude-haiku-4-5");
311    }
312
313    #[test]
314    fn fallback_prefers_env_var_and_persists_it() {
315        let mut persisted = None;
316        let got = resolve_fallback_access_token(
317            || Some("sk-ant-oat-from-env".to_string()),
318            |token| persisted = Some(token.to_string()),
319            || Some("sk-ant-oat-from-store".to_string()),
320        );
321        assert_eq!(got.as_deref(), Some("sk-ant-oat-from-env"));
322        assert_eq!(persisted.as_deref(), Some("sk-ant-oat-from-env"));
323    }
324
325    #[test]
326    fn fallback_uses_secret_store_when_env_var_absent() {
327        let mut persist_calls = 0;
328        let got = resolve_fallback_access_token(
329            || None,
330            |_| persist_calls += 1,
331            || Some("sk-ant-oat-from-store".to_string()),
332        );
333        assert_eq!(got.as_deref(), Some("sk-ant-oat-from-store"));
334        assert_eq!(persist_calls, 0);
335    }
336
337    #[test]
338    fn fallback_treats_empty_env_var_as_absent() {
339        let got = resolve_fallback_access_token(
340            || Some(String::new()),
341            |_| panic!("must not persist an empty token"),
342            || Some("sk-ant-oat-from-store".to_string()),
343        );
344        assert_eq!(got.as_deref(), Some("sk-ant-oat-from-store"));
345    }
346
347    #[test]
348    fn fallback_none_when_no_source_has_a_token() {
349        let got = resolve_fallback_access_token(|| None, |_| panic!("must not persist"), || None);
350        assert!(got.is_none());
351    }
352}