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}