agentmux_srv/server/providers_handlers.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! RPC: `providers.models` — authoritative model catalog for a provider,
5//! fetched from the Anthropic Models API with the account-global OAuth token.
6//!
7//! Best-effort by design: any missing token (e.g. macOS Keychain), expiry, or
8//! network failure returns an empty `models` list, and the frontend keeps its
9//! bundled static catalog. Only Claude has an authoritative Models API today;
10//! other providers return empty. See `backend/model_catalog.rs` and
11//! `docs/specs/SPEC_MODEL_CATALOG_REFRESH_2026_07_02.md`.
12
13use std::sync::Arc;
14
15use crate::backend::model_catalog::{fetch_model_catalog, resolve_access_token, CatalogModel};
16use crate::backend::providers::get_provider;
17use crate::backend::rpc::engine::WshRpcEngine;
18
19use super::AppState;
20
21#[derive(serde::Deserialize, ts_rs::TS)]
22#[ts(export, export_to = "../../../frontend/types/rpc/")]
23pub struct ProvidersModelsParams {
24 pub provider_id: String,
25}
26
27#[derive(serde::Serialize, serde::Deserialize, ts_rs::TS)]
28#[ts(export, export_to = "../../../frontend/types/rpc/")]
29pub struct ProvidersModelsResult {
30 /// `CatalogModel` serializes to `{ id, display_name }`.
31 pub models: Vec<CatalogModel>,
32}
33
34fn empty_result() -> Result<ProvidersModelsResult, String> {
35 Ok(ProvidersModelsResult { models: vec![] })
36}
37
38pub fn register_providers_handlers(engine: &Arc<WshRpcEngine>, _state: &AppState) {
39 // providers.models → authoritative model list for a provider (Claude only
40 // today). Reads the account-global OAuth token and hits GET /v1/models.
41 engine.register_typed(
42 "providers.models",
43 move |params: ProvidersModelsParams, _ctx| async move {
44
45 // Only Claude exposes an authoritative Models API; others fall
46 // back to the frontend's static catalog.
47 let provider = match get_provider(¶ms.provider_id) {
48 Some(p) => p,
49 None => return Err(format!("unknown provider: {}", params.provider_id)),
50 };
51 if provider.id != "claude" {
52 return empty_result();
53 }
54
55 // Account-GLOBAL shared creds dir (version/channel-independent).
56 // If data paths can't be resolved (CI / unusual env), stay
57 // best-effort per the module contract: empty list → FE keeps its
58 // static catalog, rather than erroring the RPC.
59 let paths = match agentmux_common::DataPaths::from_env() {
60 Some(p) => p,
61 None => return empty_result(),
62 };
63 let dir = paths.provider_auth_dir(provider.auth_dir_name);
64 // `allow_keychain_fallback: false` — this RPC is fired
65 // automatically, unprompted, on every app launch
66 // (`frontend/app-init.ts`'s init wave). Persisting or reading
67 // back a stored fallback token (model_catalog.rs's steps 2/3)
68 // can trigger an interactive macOS Keychain password prompt;
69 // a background model-label refresh must never surface one.
70 // The `CLAUDE_CODE_OAUTH_TOKEN` env var itself is still
71 // honored either way (a plain process-env read, no keychain
72 // involved) — only the keychain-touching persist/read-back
73 // is skipped, so macOS falls back to its static bundled
74 // catalog only when that env var isn't set for this process.
75 let token = match resolve_access_token(&dir, false).await {
76 Some(t) => t,
77 // No token from the `.credentials.json` file or the
78 // CLAUDE_CODE_OAUTH_TOKEN env var — the two sources this
79 // background call is allowed to use (see the comment
80 // above; the keychain-backed fallback is deliberately
81 // excluded) → empty → frontend keeps its static
82 // fallback.
83 None => return empty_result(),
84 };
85
86 let models = fetch_model_catalog(&token).await.unwrap_or_default();
87 Ok(ProvidersModelsResult { models })
88 },
89 );
90}