From eedc001b914cef0548dfbd5c7ddf26f18e2d0902 Mon Sep 17 00:00:00 2001 From: DarkSkyXD Date: Fri, 13 Mar 2026 21:04:43 -0500 Subject: [PATCH 1/2] feat(providers): add Anthropic OAuth subscription support with CLI detection Add the ability for users to authenticate with their Anthropic Claude Pro/Max subscription via OAuth instead of requiring a separate API key. The feature detects existing Claude Code CLI installations and surfaces that status in the UI for a streamlined sign-in experience. Backend: - Add GET /api/providers/anthropic/oauth/cli-status endpoint that detects Claude Code CLI installation, version, and auth status by inspecting ~/.claude/ and running `claude auth status` - Add POST /api/providers/anthropic/oauth/start endpoint to initiate the PKCE authorization flow and return the browser authorize URL - Add POST /api/providers/anthropic/oauth/exchange endpoint to exchange the authorization code for access/refresh tokens - Add anthropic_oauth field to ProviderStatus for tracking OAuth state - Add set/clear_anthropic_oauth_credentials methods to LlmManager - Handle DELETE /api/providers/anthropic-oauth for credential removal Frontend: - Add AnthropicOAuthProviderCard below the Anthropic API key card with CLI detection status indicator and sign-in/remove actions - Add AnthropicOAuthDialog with model selector (user picks their model), PKCE flow steps, and authorization code input - Add ClaudeCliStatusResponse, AnthropicOAuthStartResponse, and AnthropicOAuthExchangeResponse types to the API client - Add claudeCliStatus(), startAnthropicOAuth(), and exchangeAnthropicOAuth() API functions Co-Authored-By: Claude Opus 4.6 (1M context) --- interface/src/api/client.ts | 46 ++++ interface/src/routes/Settings.tsx | 334 +++++++++++++++++++++++++- src/api/providers.rs | 378 ++++++++++++++++++++++++++++++ src/api/server.rs | 12 + src/llm/manager.rs | 10 + 5 files changed, 779 insertions(+), 1 deletion(-) diff --git a/interface/src/api/client.ts b/interface/src/api/client.ts index 8cab46c2d..72d37104b 100644 --- a/interface/src/api/client.ts +++ b/interface/src/api/client.ts @@ -911,6 +911,7 @@ export interface CronExecutionsParams { export interface ProviderStatus { anthropic: boolean; + anthropic_oauth: boolean; openai: boolean; openai_chatgpt: boolean; openrouter: boolean; @@ -966,6 +967,28 @@ export interface OpenAiOAuthBrowserStatusResponse { message: string | null; } +export interface ClaudeCliStatusResponse { + claude_folder_exists: boolean; + credentials_file_exists: boolean; + cli_installed: boolean; + cli_version: string | null; + authenticated: boolean; + email: string | null; + oauth_configured: boolean; +} + +export interface AnthropicOAuthStartResponse { + success: boolean; + message: string; + authorize_url: string | null; + state: string | null; +} + +export interface AnthropicOAuthExchangeResponse { + success: boolean; + message: string; +} + // -- Model Types -- export interface ModelInfo { @@ -1951,6 +1974,29 @@ export const api = { } return response.json() as Promise; }, + claudeCliStatus: () => fetchJson("/providers/anthropic/oauth/cli-status"), + startAnthropicOAuth: async (params: { model: string; mode?: string }) => { + const response = await fetch(`${API_BASE}/providers/anthropic/oauth/start`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, + exchangeAnthropicOAuth: async (params: { code: string; state: string }) => { + const response = await fetch(`${API_BASE}/providers/anthropic/oauth/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }); + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + return response.json() as Promise; + }, startOpenAiOAuthBrowser: async (params: {model: string}) => { const response = await fetch(`${API_BASE}/providers/openai/oauth/browser/start`, { method: "POST", diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 9ea2df47d..5dc981714 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { api, type GlobalSettingsResponse, type UpdateStatus, type SecretCategory, type SecretListItem, type StoreState } from "@/api/client"; +import { api, type GlobalSettingsResponse, type UpdateStatus, type SecretCategory, type SecretListItem, type StoreState, type ClaudeCliStatusResponse } from "@/api/client"; import { Badge, Button, Input, SettingSidebarButton, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle } from "@/ui"; import { useSearch, useNavigate } from "@tanstack/react-router"; import { PlatformCatalog, InstanceCard, AddInstanceCard } from "@/components/ChannelSettingCard"; @@ -280,6 +280,14 @@ export function Settings() { message: string; sample?: string | null; } | null>(null); + const [anthropicOAuthDialogOpen, setAnthropicOAuthDialogOpen] = useState(false); + const [anthropicOAuthModel, setAnthropicOAuthModel] = useState(""); + const [anthropicOAuthMessage, setAnthropicOAuthMessage] = useState<{ + text: string; + type: "success" | "error"; + } | null>(null); + const [anthropicOAuthState, setAnthropicOAuthState] = useState(null); + const [anthropicOAuthCodeInput, setAnthropicOAuthCodeInput] = useState(""); const [isPollingOpenAiBrowserOAuth, setIsPollingOpenAiBrowserOAuth] = useState(false); const [openAiBrowserOAuthMessage, setOpenAiBrowserOAuthMessage] = useState<{ text: string; @@ -342,6 +350,21 @@ export function Settings() { mutationFn: ({ provider, apiKey, model }: { provider: string; apiKey: string; model: string }) => api.testProviderModel(provider, apiKey, model), }); + const { data: cliStatus } = useQuery({ + queryKey: ["claude-cli-status"], + queryFn: api.claudeCliStatus, + staleTime: 60_000, + enabled: activeSection === "providers", + }); + + const startAnthropicOAuthMutation = useMutation({ + mutationFn: (params: { model: string; mode?: string }) => api.startAnthropicOAuth(params), + }); + + const exchangeAnthropicOAuthMutation = useMutation({ + mutationFn: (params: { code: string; state: string }) => api.exchangeAnthropicOAuth(params), + }); + const startOpenAiBrowserOAuthMutation = useMutation({ mutationFn: (params: { model: string }) => api.startOpenAiOAuthBrowser(params), }); @@ -550,6 +573,71 @@ export function Settings() { ); }; + const handleStartAnthropicOAuth = async () => { + if (!anthropicOAuthModel.trim()) { + setAnthropicOAuthMessage({ text: "Please select a model first", type: "error" }); + return; + } + setAnthropicOAuthMessage(null); + setAnthropicOAuthState(null); + setAnthropicOAuthCodeInput(""); + try { + const result = await startAnthropicOAuthMutation.mutateAsync({ + model: anthropicOAuthModel.trim(), + }); + if (!result.success || !result.authorize_url || !result.state) { + setAnthropicOAuthMessage({ + text: result.message || "Failed to start OAuth flow", + type: "error", + }); + return; + } + setAnthropicOAuthState(result.state); + window.open( + result.authorize_url, + "spacebot-anthropic-oauth", + "popup=true,width=780,height=960,noopener,noreferrer", + ); + } catch (error: any) { + setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); + } + }; + + const handleExchangeAnthropicOAuth = async () => { + if (!anthropicOAuthState || !anthropicOAuthCodeInput.trim()) return; + setAnthropicOAuthMessage(null); + try { + const result = await exchangeAnthropicOAuthMutation.mutateAsync({ + code: anthropicOAuthCodeInput.trim(), + state: anthropicOAuthState, + }); + if (result.success) { + setAnthropicOAuthMessage({ text: result.message, type: "success" }); + setAnthropicOAuthState(null); + setAnthropicOAuthCodeInput(""); + queryClient.invalidateQueries({ queryKey: ["providers"] }); + queryClient.invalidateQueries({ queryKey: ["claude-cli-status"] }); + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ["agents"] }); + queryClient.invalidateQueries({ queryKey: ["overview"] }); + }, 3000); + } else { + setAnthropicOAuthMessage({ text: result.message, type: "error" }); + } + } catch (error: any) { + setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); + } + }; + + useEffect(() => { + if (!anthropicOAuthDialogOpen) { + setAnthropicOAuthMessage(null); + setAnthropicOAuthState(null); + setAnthropicOAuthCodeInput(""); + setAnthropicOAuthModel(""); + } + }, [anthropicOAuthDialogOpen]); + const handleClose = () => { setEditingProvider(null); setKeyInput(""); @@ -643,6 +731,16 @@ export function Settings() { onRemove={() => removeMutation.mutate(provider.id)} removing={removeMutation.isPending} />, + provider.id === "anthropic" ? ( + setAnthropicOAuthDialogOpen(true)} + onRemove={() => removeMutation.mutate("anthropic-oauth")} + removing={removeMutation.isPending} + /> + ) : null, provider.id === "openai" ? ( + + void; + onRemove: () => void; + removing: boolean; +}) { + const cliDetected = cliStatus?.cli_installed && cliStatus?.authenticated; + + return ( +
+
+ +
+
+ Claude Code CLI (OAuth) + {configured && ( + + + )} +
+

+ Use your Claude Pro/Max subscription via OAuth instead of an API key. +

+ {cliDetected && !configured && ( +

+ Claude Code CLI detected{cliStatus?.email ? ` (${cliStatus.email})` : ""} — you can sign in with your existing account. +

+ )} + {cliStatus && !cliDetected && !configured && ( +

+ Claude Code CLI not detected. You can still sign in via browser. +

+ )} +

+ Model selected during sign-in is applied to routing. +

+
+
+ + {configured && ( + + )} +
+
+
+ ); +} + +// ── Anthropic OAuth Dialog ────────────────────────────────────────────── + +function AnthropicOAuthDialog({ + open, + onOpenChange, + isRequesting, + isExchanging, + message, + hasState, + codeInput, + onCodeInputChange, + onStartOAuth, + onExchangeCode, + cliStatus, + modelValue, + onModelChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + isRequesting: boolean; + isExchanging: boolean; + message: { text: string; type: "success" | "error" } | null; + hasState: boolean; + codeInput: string; + onCodeInputChange: (value: string) => void; + onStartOAuth: () => void; + onExchangeCode: () => void; + cliStatus: ClaudeCliStatusResponse | undefined; + modelValue: string; + onModelChange: (value: string) => void; +}) { + const cliDetected = cliStatus?.cli_installed && cliStatus?.authenticated; + + return ( + + + + + + Sign in with Anthropic OAuth + + {!message && ( + + {cliDetected + ? `Claude Code CLI detected${cliStatus?.email ? ` (${cliStatus.email})` : ""}. Click below to authorize this app — since you're already signed in, it should be quick.` + : "Sign in with your Anthropic account (Claude Pro, Max, or API console) to use OAuth instead of an API key." + } + + )} + + +
+ {message && !hasState && ( +
+ {message.text} +
+ )} + + {!hasState && !message && ( +
+

+ Choose the model to use, then authorize access in a browser window. +

+ + +
+ )} + + {hasState && ( +
+
+
+ 1 +

Approve access in the browser window that opened

+
+
+ +
+
+ 2 +

Paste the authorization code below

+
+
+ onCodeInputChange(e.target.value)} + placeholder="Paste code here..." + onKeyDown={(e) => { + if (e.key === "Enter") onExchangeCode(); + }} + autoFocus + /> + +
+
+ + {message && ( +
+ {message.text} +
+ )} +
+ )} +
+ + + {message?.type === "success" ? ( + + ) : hasState ? ( + + ) : null} + +
+
+ ); +} + +// ── Provider Card ─────────────────────────────────────────────────────── + interface ProviderCardProps { provider: string; name: string; diff --git a/src/api/providers.rs b/src/api/providers.rs index 39c9d6558..f0541d3a9 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -24,6 +24,17 @@ const OPENAI_DEVICE_OAUTH_MAX_POLL_INTERVAL_SECS: u64 = 30; static OPENAI_DEVICE_OAUTH_SESSIONS: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); +/// Active Anthropic OAuth PKCE sessions: state_key → (pkce_verifier, model, expires_at). +static ANTHROPIC_OAUTH_SESSIONS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +#[derive(Clone, Debug)] +struct AnthropicOAuthSession { + verifier: String, + model: String, + expires_at: i64, +} + #[derive(Clone, Debug)] struct DeviceOAuthSession { expires_at: i64, @@ -40,6 +51,7 @@ enum DeviceOAuthSessionStatus { #[derive(Serialize)] pub(super) struct ProviderStatus { anthropic: bool, + anthropic_oauth: bool, openai: bool, openai_chatgpt: bool, openrouter: bool, @@ -97,6 +109,55 @@ pub(super) struct ProviderModelTestResponse { sample: Option, } +// ── Anthropic OAuth types ──────────────────────────────────────────────── + +#[derive(Serialize)] +pub(super) struct ClaudeCliStatusResponse { + /// Whether the `~/.claude/` folder exists (CLI has been used). + pub claude_folder_exists: bool, + /// Whether `~/.claude/credentials.json` exists (CLI has stored credentials). + pub credentials_file_exists: bool, + /// Whether the `claude` binary was found on the system. + pub cli_installed: bool, + /// CLI version string if the binary was found and `--version` succeeded. + pub cli_version: Option, + /// Whether the CLI reports being authenticated. + pub authenticated: bool, + /// Email from `claude auth status`, if available. + pub email: Option, + /// Whether Anthropic OAuth is already configured in this spacebot instance. + pub oauth_configured: bool, +} + +#[derive(Deserialize)] +pub(super) struct AnthropicOAuthStartRequest { + model: String, + #[serde(default)] + mode: Option, +} + +#[derive(Serialize)] +pub(super) struct AnthropicOAuthStartResponse { + pub success: bool, + pub message: String, + pub authorize_url: Option, + pub state: Option, +} + +#[derive(Deserialize)] +pub(super) struct AnthropicOAuthExchangeRequest { + code: String, + state: String, +} + +#[derive(Serialize)] +pub(super) struct AnthropicOAuthExchangeResponse { + pub success: bool, + pub message: String, +} + +// ── OpenAI OAuth types ────────────────────────────────────────────────── + #[derive(Deserialize)] pub(super) struct OpenAiOAuthBrowserStartRequest { model: String, @@ -345,6 +406,7 @@ pub(super) async fn get_providers( ) -> Result, StatusCode> { let config_path = state.config_path.read().await.clone(); let instance_dir = (**state.instance_dir.load()).clone(); + let anthropic_oauth_configured = crate::auth::credentials_path(&instance_dir).exists(); let openai_oauth_configured = crate::openai_auth::credentials_path(&instance_dir).exists(); let ( @@ -442,6 +504,7 @@ pub(super) async fn get_providers( let providers = ProviderStatus { anthropic, + anthropic_oauth: anthropic_oauth_configured, openai, openai_chatgpt, openrouter, @@ -464,6 +527,7 @@ pub(super) async fn get_providers( zai_coding_plan, }; let has_any = providers.anthropic + || providers.anthropic_oauth || providers.openai || providers.openai_chatgpt || providers.openrouter @@ -895,6 +959,24 @@ pub(super) async fn delete_provider( axum::extract::Path(provider): axum::extract::Path, ) -> Result, StatusCode> { let provider = provider.trim().to_lowercase(); + // Anthropic OAuth credentials are stored as a separate JSON file. + if provider == "anthropic-oauth" { + let instance_dir = (**state.instance_dir.load()).clone(); + let cred_path = crate::auth::credentials_path(&instance_dir); + if cred_path.exists() { + tokio::fs::remove_file(&cred_path) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + if let Some(mgr) = state.llm_manager.read().await.as_ref() { + mgr.clear_anthropic_oauth_credentials().await; + } + return Ok(Json(ProviderUpdateResponse { + success: true, + message: "Anthropic OAuth credentials removed".into(), + })); + } + // OpenAI ChatGPT OAuth credentials are stored as a separate JSON file, // not in the TOML config, so handle removal separately. if provider == "openai-chatgpt" { @@ -953,6 +1035,302 @@ pub(super) async fn delete_provider( })) } +// ── Anthropic OAuth / CLI detection handlers ──────────────────────────── + +/// Detect whether the Claude Code CLI is installed and authenticated on +/// the local machine by inspecting `~/.claude/` and running the binary. +pub(super) async fn claude_cli_status( + State(state): State>, +) -> Result, StatusCode> { + let instance_dir = (**state.instance_dir.load()).clone(); + let oauth_configured = crate::auth::credentials_path(&instance_dir).exists(); + + let home = dirs::home_dir().unwrap_or_default(); + let claude_dir = home.join(".claude"); + let claude_folder_exists = claude_dir.is_dir(); + let credentials_file_exists = claude_dir.join("credentials.json").is_file(); + + // Try to find the `claude` binary. + let (cli_installed, cli_version, authenticated, email) = + tokio::task::spawn_blocking(move || detect_claude_cli()) + .await + .unwrap_or((false, None, false, None)); + + Ok(Json(ClaudeCliStatusResponse { + claude_folder_exists, + credentials_file_exists, + cli_installed, + cli_version, + authenticated, + email, + oauth_configured, + })) +} + +/// Blocking helper: find the `claude` binary, run `--version` and `auth status`. +fn detect_claude_cli() -> (bool, Option, bool, Option) { + let claude_path = find_claude_binary(); + let Some(claude_path) = claude_path else { + return (false, None, false, None); + }; + + // Strip env vars that make the CLI think it's inside a nested session. + let clean_env: Vec<(String, String)> = std::env::vars() + .filter(|(k, _)| k != "CLAUDECODE" && k != "CLAUDE_CODE_ENTRYPOINT") + .collect(); + + // Version check + let version = std::process::Command::new(&claude_path) + .arg("--version") + .env_clear() + .envs(clean_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .ok() + .and_then(|output| { + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } + }); + + if version.is_none() { + return (false, None, false, None); + } + + // Auth status check + let auth_result = std::process::Command::new(&claude_path) + .args(["auth", "status"]) + .env_clear() + .envs(clean_env.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output(); + + let (authenticated, email) = match auth_result { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + match serde_json::from_str::(stdout.trim()) { + Ok(json) => { + let logged_in = json + .get("loggedIn") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let email = json + .get("email") + .and_then(|v| v.as_str()) + .map(String::from); + (logged_in, email) + } + Err(_) => { + // Older CLI versions may not return JSON — assume authed if + // --version worked and the binary didn't error. + (true, None) + } + } + } + Ok(_) => (false, None), + Err(_) => { + // Command failed to run but binary exists — assume authed (older CLI). + (true, None) + } + }; + + (true, version, authenticated, email) +} + +/// Locate the `claude` binary on the system. +fn find_claude_binary() -> Option { + let which_cmd = if cfg!(windows) { "where" } else { "which" }; + if let Ok(output) = std::process::Command::new(which_cmd) + .arg("claude") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + { + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !path.is_empty() { + return Some(path); + } + } + } + + // Fallback paths + let home = dirs::home_dir()?; + let candidates: Vec = if cfg!(windows) { + vec![ + home.join(".local").join("bin").join("claude.exe"), + home.join("AppData") + .join("Local") + .join("Programs") + .join("claude") + .join("claude.exe"), + ] + } else { + vec![ + home.join(".local").join("bin").join("claude"), + std::path::PathBuf::from("/usr/local/bin/claude"), + ] + }; + + for candidate in candidates { + if candidate.is_file() { + return Some(candidate.to_string_lossy().to_string()); + } + } + + None +} + +/// Start the Anthropic OAuth PKCE flow. Returns an authorization URL the +/// frontend should open in a popup/tab so the user can authorize. +pub(super) async fn start_anthropic_oauth( + State(state): State>, + Json(request): Json, +) -> Result, StatusCode> { + let model = request.model.trim().to_string(); + if model.is_empty() { + return Ok(Json(AnthropicOAuthStartResponse { + success: false, + message: "Model cannot be empty".to_string(), + authorize_url: None, + state: None, + })); + } + + let mode = match request.mode.as_deref() { + Some("console") => crate::auth::AuthMode::Console, + _ => crate::auth::AuthMode::Max, + }; + + let (url, verifier) = crate::auth::authorize_url(mode); + let state_key = Uuid::new_v4().to_string(); + let expires_at = chrono::Utc::now().timestamp() + 10 * 60; // 10 minute TTL + + // Insert the new session and prune expired ones in a single lock acquisition. + let now = chrono::Utc::now().timestamp(); + let mut sessions = ANTHROPIC_OAUTH_SESSIONS.write().await; + sessions.insert( + state_key.clone(), + AnthropicOAuthSession { + verifier, + model, + expires_at, + }, + ); + sessions.retain(|_, s| s.expires_at > now); + drop(sessions); + + Ok(Json(AnthropicOAuthStartResponse { + success: true, + message: "Authorization URL generated".to_string(), + authorize_url: Some(url), + state: Some(state_key), + })) +} + +/// Exchange the authorization code from the Anthropic OAuth callback for +/// access and refresh tokens, save them, and update routing. +pub(super) async fn exchange_anthropic_oauth( + State(state): State>, + Json(request): Json, +) -> Result, StatusCode> { + let state_key = request.state.trim().to_string(); + let code = request.code.trim().to_string(); + + if state_key.is_empty() || code.is_empty() { + return Ok(Json(AnthropicOAuthExchangeResponse { + success: false, + message: "State and code are required".to_string(), + })); + } + + // Look up the session + let session = ANTHROPIC_OAUTH_SESSIONS.write().await.remove(&state_key); + let Some(session) = session else { + return Ok(Json(AnthropicOAuthExchangeResponse { + success: false, + message: "OAuth session not found or expired. Please try again.".to_string(), + })); + }; + + let now = chrono::Utc::now().timestamp(); + if now >= session.expires_at { + return Ok(Json(AnthropicOAuthExchangeResponse { + success: false, + message: "OAuth session expired. Please try again.".to_string(), + })); + } + + // Exchange code for tokens + let credentials = match crate::auth::exchange_code(&code, &session.verifier).await { + Ok(creds) => creds, + Err(error) => { + return Ok(Json(AnthropicOAuthExchangeResponse { + success: false, + message: format!("Token exchange failed: {error}"), + })); + } + }; + + // Save credentials to disk + let instance_dir = (**state.instance_dir.load()).clone(); + if let Err(error) = crate::auth::save_credentials(&instance_dir, &credentials) { + tracing::warn!(%error, "failed to save Anthropic OAuth credentials"); + return Ok(Json(AnthropicOAuthExchangeResponse { + success: false, + message: format!("Failed to save credentials: {error}"), + })); + } + + // Update the LLM manager + if let Some(llm_manager) = state.llm_manager.read().await.as_ref() { + llm_manager + .set_anthropic_oauth_credentials(credentials) + .await; + } + + // Update model routing in config.toml + let config_path = state.config_path.read().await.clone(); + let content = if config_path.exists() { + tokio::fs::read_to_string(&config_path) + .await + .unwrap_or_default() + } else { + String::new() + }; + + if let Ok(mut doc) = content.parse::() { + apply_model_routing(&mut doc, &session.model); + if let Err(error) = tokio::fs::write(&config_path, doc.to_string()).await { + tracing::warn!(%error, "failed to write config.toml after Anthropic OAuth"); + } + } + + refresh_defaults_config(&state).await; + + state + .provider_setup_tx + .try_send(crate::ProviderSetupEvent::ProvidersConfigured) + .ok(); + + Ok(Json(AnthropicOAuthExchangeResponse { + success: true, + message: format!( + "Anthropic OAuth configured. Model '{}' applied to routing.", + session.model + ), + })) +} + #[cfg(test)] mod tests { use super::build_test_llm_config; diff --git a/src/api/server.rs b/src/api/server.rs index 4d4451d38..4303b5623 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -227,6 +227,18 @@ pub async fn start_http_server( "/providers", get(providers::get_providers).put(providers::update_provider), ) + .route( + "/providers/anthropic/oauth/cli-status", + get(providers::claude_cli_status), + ) + .route( + "/providers/anthropic/oauth/start", + post(providers::start_anthropic_oauth), + ) + .route( + "/providers/anthropic/oauth/exchange", + post(providers::exchange_anthropic_oauth), + ) .route( "/providers/openai/oauth/browser/start", post(providers::start_openai_browser_oauth), diff --git a/src/llm/manager.rs b/src/llm/manager.rs index bd0044d49..252b2ed71 100644 --- a/src/llm/manager.rs +++ b/src/llm/manager.rs @@ -192,6 +192,16 @@ impl LlmManager { } } + /// Set Anthropic OAuth credentials in memory after successful auth. + pub async fn set_anthropic_oauth_credentials(&self, creds: AnthropicOAuthCredentials) { + *self.anthropic_oauth_credentials.write().await = Some(creds); + } + + /// Clear Anthropic OAuth credentials from memory. + pub async fn clear_anthropic_oauth_credentials(&self) { + *self.anthropic_oauth_credentials.write().await = None; + } + /// Set OpenAI OAuth credentials in memory after successful auth. pub async fn set_openai_oauth_credentials(&self, creds: OpenAiOAuthCredentials) { *self.openai_oauth_credentials.write().await = Some(creds); From ec7f86ed7dd4611cbce4283f357cbc6e79d09e93 Mon Sep 17 00:00:00 2001 From: DarkSkyXD Date: Sun, 22 Mar 2026 12:18:26 -0500 Subject: [PATCH 2/2] fix: use Tauri shell plugin for external URLs, fix unused variable - Replace window.open() with openExternal() helper that uses the Tauri shell plugin to open OAuth URLs in the system browser. window.open() in Tauri opens a webview instead of the default browser, breaking the OAuth flow in the desktop app. This also fixes the same latent bug in the existing OpenAI OAuth flow. - Prefix unused `state` parameter in start_anthropic_oauth with underscore to suppress compiler warning. Co-Authored-By: Claude Opus 4.6 (1M context) --- interface/src/routes/Settings.tsx | 23 +++++++++++++---------- src/api/providers.rs | 2 +- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/interface/src/routes/Settings.tsx b/interface/src/routes/Settings.tsx index 5dc981714..2b2d7793a 100644 --- a/interface/src/routes/Settings.tsx +++ b/interface/src/routes/Settings.tsx @@ -8,8 +8,19 @@ import { ModelSelect } from "@/components/ModelSelect"; import { ProviderIcon } from "@/lib/providerIcons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { IS_TAURI } from "@/api/client"; import { parse as parseToml } from "smol-toml"; + +/** Open a URL in the system browser. In Tauri, uses the shell plugin; otherwise falls back to window.open. */ +async function openExternal(url: string) { + if (IS_TAURI) { + const { open } = await import("@tauri-apps/plugin-shell"); + await open(url); + } else { + window.open(url, "_blank", "noopener,noreferrer"); + } +} import { useTheme, THEMES, type ThemeId } from "@/hooks/useTheme"; import { Markdown } from "@/components/Markdown"; @@ -566,11 +577,7 @@ export function Settings() { const handleOpenDeviceLogin = () => { if (!deviceCodeInfo || !deviceCodeCopied) return; - window.open( - deviceCodeInfo.verificationUrl, - "spacebot-openai-device", - "popup=true,width=780,height=960,noopener,noreferrer", - ); + openExternal(deviceCodeInfo.verificationUrl); }; const handleStartAnthropicOAuth = async () => { @@ -593,11 +600,7 @@ export function Settings() { return; } setAnthropicOAuthState(result.state); - window.open( - result.authorize_url, - "spacebot-anthropic-oauth", - "popup=true,width=780,height=960,noopener,noreferrer", - ); + openExternal(result.authorize_url); } catch (error: any) { setAnthropicOAuthMessage({ text: `Failed: ${error.message}`, type: "error" }); } diff --git a/src/api/providers.rs b/src/api/providers.rs index f0541d3a9..f989bf363 100644 --- a/src/api/providers.rs +++ b/src/api/providers.rs @@ -1193,7 +1193,7 @@ fn find_claude_binary() -> Option { /// Start the Anthropic OAuth PKCE flow. Returns an authorization URL the /// frontend should open in a popup/tab so the user can authorize. pub(super) async fn start_anthropic_oauth( - State(state): State>, + State(_state): State>, Json(request): Json, ) -> Result, StatusCode> { let model = request.model.trim().to_string();