Skip to main content

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(&params.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}