From 8f9facb561995a671f1e9bd20e24ed303e1b48dd Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 27 Dec 2025 02:22:59 +0200 Subject: [PATCH] feat: added providers management settings with ability to add or remove providers (#76) * feat: implement adding opencode providers in openchamber settings * feat: implement provider authentication management with removal functionality --- packages/desktop/src-tauri/src/main.rs | 62 +- .../desktop/src-tauri/src/opencode_auth.rs | 116 +++ packages/desktop/src/main.tsx | 9 + .../assets/provider-logos/kimi-for-coding.svg | 1 - .../sections/providers/ProvidersPage.tsx | 966 +++++++++++++++++- .../sections/providers/ProvidersSidebar.tsx | 83 +- packages/ui/src/components/ui/select.tsx | 2 - packages/ui/src/lib/desktop.ts | 1 + packages/ui/src/stores/useConfigStore.ts | 9 + packages/vscode/src/bridge.ts | 31 + packages/vscode/src/opencodeAuth.ts | 72 ++ packages/vscode/webview/main.tsx | 13 + packages/web/server/index.js | 33 + packages/web/server/lib/opencode-auth.js | 81 ++ 14 files changed, 1469 insertions(+), 10 deletions(-) create mode 100644 packages/desktop/src-tauri/src/opencode_auth.rs delete mode 100644 packages/ui/src/assets/provider-logos/kimi-for-coding.svg create mode 100644 packages/vscode/src/opencodeAuth.ts create mode 100644 packages/web/server/lib/opencode-auth.js diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index ae96b8a6..5f0d26b1 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -4,6 +4,7 @@ mod commands; mod logging; mod assistant_notifications; mod session_activity; +mod opencode_auth; mod opencode_config; mod opencode_manager; mod window_state; @@ -1493,6 +1494,58 @@ async fn handle_config_routes( )); } + // Handle provider auth removal: DELETE /api/provider/:providerId/auth + if let Some(rest) = path.strip_prefix("/api/provider/") { + if let Some(provider_id) = rest.strip_suffix("/auth") { + if method == Method::DELETE { + let trimmed = provider_id.trim(); + if trimmed.is_empty() { + return Ok(config_error_response( + StatusCode::BAD_REQUEST, + "Provider ID is required", + )); + } + + match opencode_auth::remove_provider_auth(trimmed).await { + Ok(removed) => { + if let Err(resp) = refresh_opencode_after_config_change( + &state, + &format!("provider {} disconnected", trimmed), + ) + .await + { + return Ok(resp); + } + + return Ok(json_response( + StatusCode::OK, + ConfigActionResponse { + success: true, + requires_reload: true, + message: if removed { + "Provider disconnected successfully".to_string() + } else { + "Provider was not connected".to_string() + }, + reload_delay_ms: CLIENT_RELOAD_DELAY_MS, + }, + )); + } + Err(err) => { + error!( + "[desktop:config] Failed to disconnect provider {}: {}", + trimmed, err + ); + return Ok(config_error_response( + StatusCode::INTERNAL_SERVER_ERROR, + err.to_string(), + )); + } + } + } + } + } + Ok(StatusCode::NOT_FOUND.into_response()) } @@ -1585,9 +1638,16 @@ async fn proxy_to_opencode( let origin_path = original.0.path().to_string(); let method = req.method().clone(); + // Check if this is a provider auth deletion request (DELETE /api/provider/:id/auth) + let is_provider_auth_delete = method == Method::DELETE + && origin_path.starts_with("/api/provider/") + && origin_path.ends_with("/auth") + && origin_path != "/api/provider/auth"; // Exclude GET /api/provider/auth + let is_desktop_config_route = origin_path.starts_with("/api/config/agents/") || origin_path.starts_with("/api/config/commands/") - || origin_path == "/api/config/reload"; + || origin_path == "/api/config/reload" + || is_provider_auth_delete; if is_desktop_config_route { return handle_config_routes(state, &origin_path, method, req).await; diff --git a/packages/desktop/src-tauri/src/opencode_auth.rs b/packages/desktop/src-tauri/src/opencode_auth.rs new file mode 100644 index 00000000..e21fb5aa --- /dev/null +++ b/packages/desktop/src-tauri/src/opencode_auth.rs @@ -0,0 +1,116 @@ +use anyhow::{anyhow, Result}; +use log::info; +use serde_json::Value; +use std::path::PathBuf; +use tokio::fs; + +/// Get OpenCode data directory path (~/.local/share/opencode) +fn get_data_dir() -> PathBuf { + dirs::home_dir() + .expect("Cannot determine home directory") + .join(".local") + .join("share") + .join("opencode") +} + +/// Get auth file path +fn get_auth_file() -> PathBuf { + get_data_dir().join("auth.json") +} + +/// Ensure data directory exists +async fn ensure_data_dir() -> Result<()> { + let data_dir = get_data_dir(); + fs::create_dir_all(&data_dir).await?; + Ok(()) +} + +/// Read auth.json file +pub async fn read_auth() -> Result { + let auth_file = get_auth_file(); + + if !auth_file.exists() { + return Ok(Value::Object(serde_json::Map::new())); + } + + let content = fs::read_to_string(&auth_file).await?; + let trimmed = content.trim(); + + if trimmed.is_empty() { + return Ok(Value::Object(serde_json::Map::new())); + } + + serde_json::from_str(trimmed).map_err(|e| anyhow!("Failed to parse auth file: {}", e)) +} + +/// Write auth.json file with backup +pub async fn write_auth(auth: &Value) -> Result<()> { + ensure_data_dir().await?; + + let auth_file = get_auth_file(); + + // Create backup before writing + if auth_file.exists() { + let file_name = auth_file + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow!("Invalid auth file name"))?; + + let backup_path = auth_file.with_file_name(format!("{file_name}.openchamber.backup")); + fs::copy(&auth_file, &backup_path).await?; + info!("Created auth backup: {}", backup_path.display()); + } + + let json_string = serde_json::to_string_pretty(auth)?; + fs::write(&auth_file, json_string).await?; + info!("Successfully wrote auth file"); + + Ok(()) +} + +/// Remove provider auth entry from auth.json +pub async fn remove_provider_auth(provider_id: &str) -> Result { + if provider_id.is_empty() { + return Err(anyhow!("Provider ID is required")); + } + + let mut auth = read_auth().await?; + + let auth_obj = auth + .as_object_mut() + .ok_or_else(|| anyhow!("Auth file is not a valid JSON object"))?; + + if !auth_obj.contains_key(provider_id) { + info!( + "Provider {} not found in auth file, nothing to remove", + provider_id + ); + return Ok(false); + } + + auth_obj.remove(provider_id); + write_auth(&auth).await?; + info!("Removed provider auth: {}", provider_id); + + Ok(true) +} + +/// Get provider auth entry +pub async fn get_provider_auth(provider_id: &str) -> Result> { + let auth = read_auth().await?; + + Ok(auth + .as_object() + .and_then(|obj| obj.get(provider_id)) + .cloned()) +} + +/// List all provider IDs with auth +pub async fn list_provider_auths() -> Result> { + let auth = read_auth().await?; + + Ok(auth + .as_object() + .map(|obj| obj.keys().cloned().collect()) + .unwrap_or_default()) +} diff --git a/packages/desktop/src/main.tsx b/packages/desktop/src/main.tsx index 0b8156c5..e7eb14be 100644 --- a/packages/desktop/src/main.tsx +++ b/packages/desktop/src/main.tsx @@ -176,6 +176,15 @@ window.opencodeDesktop = { async getHomeDirectory() { return { success: true, path: homeDirectory || null }; }, + async openExternal(url: string) { + try { + await open(url); + return { success: true }; + } catch (error) { + console.error('[desktop] Error opening external link:', error); + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + }, markRendererReady() { }, diff --git a/packages/ui/src/assets/provider-logos/kimi-for-coding.svg b/packages/ui/src/assets/provider-logos/kimi-for-coding.svg deleted file mode 100644 index fb56ac10..00000000 --- a/packages/ui/src/assets/provider-logos/kimi-for-coding.svg +++ /dev/null @@ -1 +0,0 @@ -MoonshotAI \ No newline at end of file diff --git a/packages/ui/src/components/sections/providers/ProvidersPage.tsx b/packages/ui/src/components/sections/providers/ProvidersPage.tsx index 8141f833..6b4a9d13 100644 --- a/packages/ui/src/components/sections/providers/ProvidersPage.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersPage.tsx @@ -1,11 +1,973 @@ import React from 'react'; -import { SectionPlaceholder } from '../SectionPlaceholder'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { toast } from 'sonner'; +import { RiStackLine, RiToolsLine, RiBrainAi3Line, RiFileImageLine, RiArrowDownSLine, RiCheckLine, RiSearchLine } from '@remixicon/react'; +import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; +import { cn } from '@/lib/utils'; +import type { ModelMetadata } from '@/types'; + +const COMPACT_NUMBER_FORMATTER = new Intl.NumberFormat('en-US', { + notation: 'compact', + compactDisplay: 'short', + maximumFractionDigits: 1, + minimumFractionDigits: 0, +}); + +const formatTokens = (value?: number | null) => { + if (typeof value !== 'number' || Number.isNaN(value)) { + return null; + } + if (value === 0) { + return '0'; + } + const formatted = COMPACT_NUMBER_FORMATTER.format(value); + return formatted.endsWith('.0') ? formatted.slice(0, -2) : formatted; +}; + +const ADD_PROVIDER_ID = '__add_provider__'; + +interface AuthMethod { + type?: string; + name?: string; + label?: string; + description?: string; + help?: string; + method?: number; + [key: string]: unknown; +} + +interface ProviderOption { + id: string; + name?: string; +} + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null; + +const normalizeAuthType = (method: AuthMethod) => { + const raw = typeof method.type === 'string' ? method.type : ''; + const label = `${method.name ?? ''} ${method.label ?? ''}`.toLowerCase(); + const merged = `${raw} ${label}`.toLowerCase(); + if (merged.includes('oauth')) return 'oauth'; + if (merged.includes('api')) return 'api'; + return raw.toLowerCase(); +}; + +const parseAuthPayload = (payload: unknown): Record => { + if (!isRecord(payload)) { + return {}; + } + const result: Record = {}; + for (const [providerId, value] of Object.entries(payload)) { + if (Array.isArray(value)) { + result[providerId] = value.filter((entry) => isRecord(entry)) as AuthMethod[]; + } + } + return result; +}; + +const normalizeProviderEntry = (entry: unknown): ProviderOption | null => { + if (typeof entry === 'string') { + return { id: entry }; + } + if (!isRecord(entry)) { + return null; + } + const idCandidate = + (typeof entry.id === 'string' && entry.id) || + (typeof entry.providerID === 'string' && entry.providerID) || + (typeof entry.slug === 'string' && entry.slug) || + (typeof entry.name === 'string' && entry.name); + if (!idCandidate) { + return null; + } + const nameCandidate = typeof entry.name === 'string' ? entry.name : undefined; + return { id: idCandidate, name: nameCandidate }; +}; + +const parseProvidersPayload = (payload: unknown): ProviderOption[] => { + let entries: unknown[] = []; + + if (Array.isArray(payload)) { + entries = payload; + } else if (isRecord(payload)) { + if (Array.isArray(payload.all)) { + entries = payload.all; + } else if (Array.isArray(payload.providers)) { + entries = payload.providers; + } + } + + const mapped = entries + .map((entry) => normalizeProviderEntry(entry)) + .filter((entry): entry is ProviderOption => Boolean(entry)); + + const seen = new Set(); + return mapped.filter((entry) => { + if (seen.has(entry.id)) { + return false; + } + seen.add(entry.id); + return true; + }); +}; export const ProvidersPage: React.FC = () => { + const providers = useConfigStore((state) => state.providers); + const selectedProviderId = useConfigStore((state) => state.selectedProviderId); + const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); + const loadProviders = useConfigStore((state) => state.loadProviders); + const getModelMetadata = useConfigStore((state) => state.getModelMetadata); + + const [authMethodsByProvider, setAuthMethodsByProvider] = React.useState>({}); + const [authLoading, setAuthLoading] = React.useState(false); + const [apiKeyInputs, setApiKeyInputs] = React.useState>({}); + const [authBusyKey, setAuthBusyKey] = React.useState(null); + const [modelQuery, setModelQuery] = React.useState(''); + const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null); + const [oauthCodes, setOauthCodes] = React.useState>({}); + const [oauthDetails, setOauthDetails] = React.useState>({}); + const [availableProviders, setAvailableProviders] = React.useState([]); + const [availableLoading, setAvailableLoading] = React.useState(false); + const [availableError, setAvailableError] = React.useState(null); + const [candidateProviderId, setCandidateProviderId] = React.useState(''); + const [providerSearchQuery, setProviderSearchQuery] = React.useState(''); + const [providerDropdownOpen, setProviderDropdownOpen] = React.useState(false); + + React.useEffect(() => { + if (!selectedProviderId && providers.length > 0) { + setSelectedProvider(providers[0].id); + } + }, [providers, selectedProviderId, setSelectedProvider]); + + React.useEffect(() => { + let isMounted = true; + + const loadAuthMethods = async () => { + setAuthLoading(true); + try { + const response = await fetch('/api/provider/auth', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Auth methods request failed (${response.status})`); + } + + const payload = await response.json().catch(() => ({})); + if (!isMounted) return; + setAuthMethodsByProvider(parseAuthPayload(payload)); + } catch (error) { + if (!isMounted) return; + console.error('Failed to load provider auth methods:', error); + toast.error('Failed to load provider authentication methods'); + } finally { + if (isMounted) { + setAuthLoading(false); + } + } + }; + + loadAuthMethods(); + + return () => { + isMounted = false; + }; + }, []); + + React.useEffect(() => { + let isMounted = true; + + const loadAvailableProviders = async () => { + setAvailableLoading(true); + setAvailableError(null); + try { + const response = await fetch('/api/provider', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + throw new Error(`Provider list request failed (${response.status})`); + } + + const payload = await response.json().catch(() => ({})); + if (!isMounted) return; + setAvailableProviders(parseProvidersPayload(payload)); + } catch (error) { + if (!isMounted) return; + console.error('Failed to load available providers:', error); + setAvailableError('Unable to load provider list'); + } finally { + if (isMounted) { + setAvailableLoading(false); + } + } + }; + + loadAvailableProviders(); + + return () => { + isMounted = false; + }; + }, []); + + const connectedProviderIds = React.useMemo( + () => new Set(providers.map((provider) => provider.id)), + [providers] + ); + + const unconnectedProviders = React.useMemo( + () => availableProviders.filter((provider) => !connectedProviderIds.has(provider.id)), + [availableProviders, connectedProviderIds] + ); + + React.useEffect(() => { + if (selectedProviderId !== ADD_PROVIDER_ID) { + return; + } + + if (!candidateProviderId && unconnectedProviders.length > 0) { + setCandidateProviderId(unconnectedProviders[0].id); + return; + } + + if (candidateProviderId && !unconnectedProviders.some((provider) => provider.id === candidateProviderId)) { + setCandidateProviderId(unconnectedProviders[0]?.id ?? ''); + } + }, [selectedProviderId, candidateProviderId, unconnectedProviders]); + + const selectedProvider = providers.find((provider) => provider.id === selectedProviderId); + + const handleSaveApiKey = async (providerId: string) => { + const apiKey = apiKeyInputs[providerId]?.trim() ?? ''; + if (!apiKey) { + toast.error('API key is required'); + return; + } + + const busyKey = `api:${providerId}`; + setAuthBusyKey(busyKey); + + try { + const response = await fetch(`/api/auth/${encodeURIComponent(providerId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'api', key: apiKey }), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = payload?.error || 'Failed to save API key'; + throw new Error(message); + } + + toast.success('API key saved'); + setApiKeyInputs((prev) => ({ ...prev, [providerId]: '' })); + await reloadOpenCodeConfiguration(); + await loadProviders(); + setSelectedProvider(providerId); + } catch (error) { + console.error('Failed to save API key:', error); + toast.error('Failed to save API key'); + } finally { + setAuthBusyKey(null); + } + }; + + const handleOAuthStart = async (providerId: string, methodIndex: number) => { + const busyKey = `oauth:${providerId}:${methodIndex}`; + setAuthBusyKey(busyKey); + + try { + const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/authorize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: methodIndex }), + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = payload?.error || 'Failed to start OAuth flow'; + throw new Error(message); + } + + const payloadRecord = isRecord(payload) ? payload : {}; + const dataRecord = isRecord(payloadRecord.data) ? payloadRecord.data : payloadRecord; + const urlCandidate = + (typeof dataRecord.url === 'string' && dataRecord.url) || + (typeof dataRecord.verification_uri_complete === 'string' && dataRecord.verification_uri_complete) || + (typeof dataRecord.verification_uri === 'string' && dataRecord.verification_uri) || + undefined; + const instructions = + (typeof dataRecord.instructions === 'string' && dataRecord.instructions) || + (typeof dataRecord.message === 'string' && dataRecord.message) || + undefined; + const userCode = + (typeof dataRecord.user_code === 'string' && dataRecord.user_code) || + (typeof dataRecord.code === 'string' && dataRecord.code) || + (typeof dataRecord.userCode === 'string' && dataRecord.userCode) || + undefined; + + if (!urlCandidate && !instructions && !userCode) { + throw new Error('No OAuth details returned'); + } + + const detailsKey = `${providerId}:${methodIndex}`; + setOauthDetails((prev) => ({ + ...prev, + [detailsKey]: { + url: urlCandidate, + instructions, + userCode, + }, + })); + + if (urlCandidate) { + window.open(urlCandidate, '_blank', 'noopener,noreferrer'); + } + setPendingOAuth({ providerId, methodIndex }); + toast.message('Complete the OAuth flow in your browser'); + } catch (error) { + console.error('Failed to start OAuth flow:', error); + toast.error('Failed to start OAuth flow'); + } finally { + setAuthBusyKey(null); + } + }; + + const handleOAuthComplete = async (providerId: string, methodIndex: number) => { + const codeKey = `${providerId}:${methodIndex}`; + const code = oauthCodes[codeKey]?.trim(); + + const busyKey = `oauth-complete:${providerId}:${methodIndex}`; + setAuthBusyKey(busyKey); + + try { + const requestBody: { method: number; code?: string } = { method: methodIndex }; + if (code) { + requestBody.code = code; + } + + const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/oauth/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + const responsePayload = await response.json().catch(() => null); + if (!response.ok) { + const message = responsePayload?.error || 'Failed to complete OAuth flow'; + throw new Error(message); + } + + toast.success('OAuth connection completed'); + setOauthCodes((prev) => ({ ...prev, [codeKey]: '' })); + setPendingOAuth(null); + await reloadOpenCodeConfiguration(); + await loadProviders(); + setSelectedProvider(providerId); + } catch (error) { + console.error('Failed to complete OAuth flow:', error); + toast.error('Failed to complete OAuth flow'); + } finally { + setAuthBusyKey(null); + } + }; + + const handleCopyOAuthLink = async (url: string) => { + try { + await navigator.clipboard.writeText(url); + toast.success('OAuth link copied'); + } catch (error) { + console.error('Failed to copy OAuth link:', error); + toast.error('Failed to copy OAuth link'); + } + }; + + const handleCopyOAuthCode = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + toast.success('Device code copied'); + } catch (error) { + console.error('Failed to copy device code:', error); + toast.error('Failed to copy device code'); + } + }; + + const handleDisconnectProvider = async (providerId: string) => { + const busyKey = `disconnect:${providerId}`; + setAuthBusyKey(busyKey); + + try { + const response = await fetch(`/api/provider/${encodeURIComponent(providerId)}/auth`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + }); + + const payload = await response.json().catch(() => null); + if (!response.ok) { + const message = payload?.error || 'Failed to disconnect provider'; + throw new Error(message); + } + + toast.success('Provider disconnected'); + await reloadOpenCodeConfiguration(); + await loadProviders(); + } catch (error) { + console.error('Failed to disconnect provider:', error); + toast.error('Failed to disconnect provider'); + } finally { + setAuthBusyKey(null); + } + }; + + const isAddMode = selectedProviderId === ADD_PROVIDER_ID; + + if (!isAddMode && providers.length === 0) { + return ( +
+
+ +

No providers detected

+

Check your OpenCode configuration

+
+
+ ); + } + + if (isAddMode) { + return ( + +
+

Connect provider

+

+ Choose a provider to connect and set up its authentication. +

+
+ +
+
+

Provider

+

+ Select a provider that is not connected yet. +

+
+ + {availableLoading ? ( +

Loading providers…

+ ) : availableError ? ( +

{availableError}

+ ) : unconnectedProviders.length === 0 ? ( +

All available providers are already connected.

+ ) : ( + { + setProviderDropdownOpen(open); + if (!open) setProviderSearchQuery(''); + }}> + + + + e.preventDefault()} + > +
e.stopPropagation()} + > + + setProviderSearchQuery(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + placeholder="Search providers..." + className="flex-1 bg-transparent typography-meta outline-none placeholder:text-muted-foreground" + autoFocus + /> +
+ + {(() => { + const filtered = unconnectedProviders.filter(p => { + const query = providerSearchQuery.toLowerCase(); + return (p.name || p.id).toLowerCase().includes(query) || p.id.toLowerCase().includes(query); + }); + if (filtered.length === 0) { + return

No providers found

; + } + return filtered.map((provider) => ( + { + setCandidateProviderId(provider.id); + setProviderDropdownOpen(false); + setProviderSearchQuery(''); + }} + className="flex items-center justify-between" + > + {provider.name || provider.id} + {candidateProviderId === provider.id && ( + + )} + + )); + })()} +
+
+
+ )} +
+ + {candidateProviderId && ( +
+

Authentication

+ + {authLoading ? ( +

Loading authentication methods…

+ ) : ( +
+
+ +
+ + setApiKeyInputs((prev) => ({ + ...prev, + [candidateProviderId]: event.target.value, + })) + } + placeholder="sk-..." + /> + +
+

+ Keys are sent directly to OpenCode and never stored by OpenChamber. +

+
+ + {(() => { + const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? []; + const candidateOAuthMethods = candidateAuthMethods.filter( + (method) => normalizeAuthType(method) === 'oauth' + ); + + if (candidateOAuthMethods.length === 0) { + return null; + } + + return ( +
+ {candidateOAuthMethods.map((method, index) => { + const methodLabel = method.label || method.name || `OAuth method ${index + 1}`; + const codeKey = `${candidateProviderId}:${index}`; + const isPending = + pendingOAuth?.providerId === candidateProviderId && pendingOAuth?.methodIndex === index; + + return ( +
+
+
+
{methodLabel}
+ {(method.description || method.help) && ( +
+ {String(method.description || method.help)} +
+ )} +
+ +
+ + {oauthDetails[codeKey]?.instructions && ( +

+ {oauthDetails[codeKey]?.instructions} +

+ )} + + {oauthDetails[codeKey]?.userCode && ( +
+ + +
+ )} + + {oauthDetails[codeKey]?.url && ( +
+ +
+ + +
+
+ )} + + {isPending && ( +
+ + setOauthCodes((prev) => ({ + ...prev, + [codeKey]: event.target.value, + })) + } + placeholder="Authorization code (if required)" + /> + +
+ )} +
+ ); + })} +
+ ); + })()} +
+ )} +
+ )} +
+ ); + } + + if (!selectedProvider) { + return ( +
+
+ +

Select a provider from the sidebar

+

Review details and configure auth

+
+
+ ); + } + + const providerModels = Array.isArray(selectedProvider.models) ? selectedProvider.models : []; + + const providerAuthMethods = authMethodsByProvider[selectedProvider.id] ?? []; + const oauthAuthMethods = providerAuthMethods.filter((method) => normalizeAuthType(method) === 'oauth'); + + const filteredModels = providerModels.filter((model) => { + const name = typeof model?.name === 'string' ? model.name : ''; + const id = typeof model?.id === 'string' ? model.id : ''; + const query = modelQuery.trim().toLowerCase(); + if (!query) return true; + return name.toLowerCase().includes(query) || id.toLowerCase().includes(query); + }); + return ( - +
+
+ +

+ {selectedProvider.name || selectedProvider.id} +

+
+

+ Provider ID: {selectedProvider.id} +

+
+ +
+

Authentication

+ + {authLoading ? ( +

Loading authentication methods…

+ ) : ( +
+
+ +
+ + setApiKeyInputs((prev) => ({ + ...prev, + [selectedProvider.id]: event.target.value, + })) + } + placeholder="sk-..." + /> + +
+

+ Keys are sent directly to OpenCode and never stored by OpenChamber. +

+
+ +
+ +
+ + {oauthAuthMethods.length > 0 && ( +
+ {oauthAuthMethods.map((method, index) => { + const methodLabel = method.label || method.name || `OAuth method ${index + 1}`; + const codeKey = `${selectedProvider.id}:${index}`; + const isPending = + pendingOAuth?.providerId === selectedProvider.id && pendingOAuth?.methodIndex === index; + + return ( +
+
+
+
{methodLabel}
+ {(method.description || method.help) && ( +
+ {String(method.description || method.help)} +
+ )} +
+ +
+ + {oauthDetails[codeKey]?.instructions && ( +

+ {oauthDetails[codeKey]?.instructions} +

+ )} + + {oauthDetails[codeKey]?.userCode && ( +
+ + +
+ )} + + {oauthDetails[codeKey]?.url && ( +
+ +
+ + +
+
+ )} + + + {isPending && ( +
+ + setOauthCodes((prev) => ({ + ...prev, + [codeKey]: event.target.value, + })) + } + placeholder="Paste authorization code" + /> + +
+ )} +
+ ); + })} +
+ )} + + +
+ )} +
+ +
+
+

Models

+

+ Browse and filter models exposed by this provider. +

+
+ + setModelQuery(event.target.value)} + placeholder="Filter models..." + /> + +
+ {filteredModels.length === 0 ? ( +

No models match this filter.

+ ) : ( + filteredModels.map((model) => { + const modelId = typeof model?.id === 'string' ? model.id : ''; + const modelName = typeof model?.name === 'string' ? model.name : modelId; + const metadata = modelId ? getModelMetadata(selectedProvider.id, modelId) as ModelMetadata | undefined : undefined; + + const contextTokens = formatTokens(metadata?.limit?.context); + const outputTokens = formatTokens(metadata?.limit?.output); + + const capabilityIcons: Array<{ key: string; icon: typeof RiToolsLine; label: string }> = []; + if (metadata?.tool_call) capabilityIcons.push({ key: 'tools', icon: RiToolsLine, label: 'Tool calling' }); + if (metadata?.reasoning) capabilityIcons.push({ key: 'reasoning', icon: RiBrainAi3Line, label: 'Reasoning' }); + if (metadata?.attachment) capabilityIcons.push({ key: 'image', icon: RiFileImageLine, label: 'Image input' }); + + return ( +
+ + {modelName} + + {(contextTokens || outputTokens) && ( + + {contextTokens ? `${contextTokens} ctx` : ''} + {contextTokens && outputTokens ? ' · ' : ''} + {outputTokens ? `${outputTokens} out` : ''} + + )} + {capabilityIcons.length > 0 && ( +
+ {capabilityIcons.map(({ key, icon: Icon, label }) => ( + + + + ))} +
+ )} +
+ ); + }) + )} +
+
); }; diff --git a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx index c523667f..dd6129a1 100644 --- a/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx +++ b/packages/ui/src/components/sections/providers/ProvidersSidebar.tsx @@ -1,11 +1,86 @@ import React from 'react'; -import { SectionPlaceholder } from '../SectionPlaceholder'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { ProviderLogo } from '@/components/ui/ProviderLogo'; +import { Button } from '@/components/ui/button'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useDeviceInfo } from '@/lib/device'; +import { RiAddLine, RiStackLine } from '@remixicon/react'; +import { cn } from '@/lib/utils'; + +const ADD_PROVIDER_ID = '__add_provider__'; export const ProvidersSidebar: React.FC = () => { + const providers = useConfigStore((state) => state.providers); + const selectedProviderId = useConfigStore((state) => state.selectedProviderId); + const setSelectedProvider = useConfigStore((state) => state.setSelectedProvider); + const { isMobile } = useDeviceInfo(); + return ( - - - +
+
+
+

Providers

+
+ {providers.length} + +
+
+
+ + + {providers.length === 0 ? ( +
+ +

No providers found

+

Check your OpenCode configuration

+
+ ) : ( + providers.map((provider) => { + const modelCount = Array.isArray(provider.models) ? provider.models.length : 0; + const isSelected = provider.id === selectedProviderId; + + return ( +
+
+
+ +
+
+
+ ); + }) + )} +
+
); }; diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index 680a6b3d..b9755cb5 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -70,7 +70,6 @@ function SelectContent({ position={position} {...props} > - {children} - ) diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 93dc1a78..88e499a4 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -67,6 +67,7 @@ export type DesktopApi = { checkForUpdates?: () => Promise; downloadUpdate?: (onProgress?: (progress: UpdateProgress) => void) => Promise; restartToUpdate?: () => Promise; + openExternal?: (url: string) => Promise<{ success: boolean; error?: string }>; }; export const isDesktopRuntime = (): boolean => diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 25c45e80..8b2ddd48 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -280,6 +280,7 @@ interface ConfigStore { currentProviderId: string; currentModelId: string; currentAgentName: string | undefined; + selectedProviderId: string; agentModelSelections: { [agentName: string]: { providerId: string; modelId: string } }; defaultProviders: { [key: string]: string }; isConnected: boolean; @@ -294,6 +295,7 @@ interface ConfigStore { setProvider: (providerId: string) => void; setModel: (modelId: string) => void; setAgent: (agentName: string | undefined) => void; + setSelectedProvider: (providerId: string) => void; setSettingsDefaultModel: (model: string | undefined) => void; setSettingsDefaultAgent: (agent: string | undefined) => void; saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => void; @@ -325,6 +327,7 @@ export const useConfigStore = create()( currentProviderId: "", currentModelId: "", currentAgentName: undefined, + selectedProviderId: "", agentModelSelections: {}, defaultProviders: {}, isConnected: false, @@ -392,6 +395,7 @@ export const useConfigStore = create()( set({ currentProviderId: providerId, currentModelId: newModelId, + selectedProviderId: providerId, }); } }, @@ -400,6 +404,10 @@ export const useConfigStore = create()( set({ currentModelId: modelId }); }, + setSelectedProvider: (providerId: string) => { + set({ selectedProviderId: providerId }); + }, + saveAgentModelSelection: (agentName: string, providerId: string, modelId: string) => { set((state) => ({ agentModelSelections: { @@ -620,6 +628,7 @@ export const useConfigStore = create()( set({ currentProviderId: agent.model!.providerID, currentModelId: agent.model!.modelID, + selectedProviderId: agent.model!.providerID, }); } } diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index 8ae483de..d85a41ae 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import type { OpenCodeManager } from './opencode'; import { createAgent, createCommand, deleteAgent, deleteCommand, getAgentSources, getCommandSources, updateAgent, updateCommand } from './opencodeConfig'; +import { removeProviderAuth } from './opencodeAuth'; export interface BridgeRequest { id: string; @@ -807,6 +808,36 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo } } + case 'api:provider/auth:delete': { + const { providerId } = (payload || {}) as { providerId?: string }; + if (!providerId) { + return { id, type, success: false, error: 'Provider ID is required' }; + } + try { + const removed = removeProviderAuth(providerId); + if (removed) { + await ctx?.manager?.restart(); + } + return { + id, + type, + success: true, + data: { + success: true, + removed, + requiresReload: removed, + message: removed + ? `Provider ${providerId} disconnected successfully. Reloading interface…` + : `Provider ${providerId} was not configured.`, + reloadDelayMs: removed ? CLIENT_RELOAD_DELAY_MS : undefined, + }, + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { id, type, success: false, error: errorMessage }; + } + } + default: return { id, type, success: false, error: `Unknown message type: ${type}` }; } diff --git a/packages/vscode/src/opencodeAuth.ts b/packages/vscode/src/opencodeAuth.ts new file mode 100644 index 00000000..b9a21221 --- /dev/null +++ b/packages/vscode/src/opencodeAuth.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); +const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json'); + +type AuthEntry = Record; +type AuthFile = Record; + +const readAuthFile = (): AuthFile => { + if (!fs.existsSync(AUTH_FILE)) { + return {}; + } + try { + const content = fs.readFileSync(AUTH_FILE, 'utf8'); + const trimmed = content.trim(); + if (!trimmed) { + return {}; + } + return JSON.parse(trimmed) as AuthFile; + } catch (error) { + console.error('Failed to read auth file:', error); + throw new Error('Failed to read OpenCode auth configuration'); + } +}; + +const writeAuthFile = (auth: AuthFile): void => { + try { + if (!fs.existsSync(OPENCODE_DATA_DIR)) { + fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true }); + } + + if (fs.existsSync(AUTH_FILE)) { + const backupFile = `${AUTH_FILE}.openchamber.backup`; + fs.copyFileSync(AUTH_FILE, backupFile); + } + + fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); + } catch (error) { + console.error('Failed to write auth file:', error); + throw new Error('Failed to write OpenCode auth configuration'); + } +}; + +export const removeProviderAuth = (providerId: string): boolean => { + if (!providerId || typeof providerId !== 'string') { + throw new Error('Provider ID is required'); + } + + const auth = readAuthFile(); + + if (!auth[providerId]) { + return false; + } + + delete auth[providerId]; + writeAuthFile(auth); + return true; +}; + +export const getProviderAuth = (providerId: string): AuthEntry | null => { + const auth = readAuthFile(); + return auth[providerId] || null; +}; + +export const listProviderAuths = (): string[] => { + const auth = readAuthFile(); + return Object.keys(auth); +}; + +export { AUTH_FILE, OPENCODE_DATA_DIR }; diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 6d7136f1..c2fa0a62 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -428,6 +428,19 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { return new Response(JSON.stringify(result), { status: 200, headers: { 'Content-Type': 'application/json' } }); } + // Handle provider auth deletion: DELETE /api/provider/:providerId/auth + const providerAuthMatch = pathname.match(/^\/api\/provider\/([^/]+)\/auth$/); + if (providerAuthMatch && (init?.method || 'GET').toUpperCase() === 'DELETE') { + const providerId = decodeURIComponent(providerAuthMatch[1]); + try { + const data = await sendBridgeMessage('api:provider/auth:delete', { providerId }); + return new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return new Response(JSON.stringify({ error: message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); + } + } + return null; }; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index a37571d0..95bc70b1 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -2307,6 +2307,39 @@ async function main(options = {}) { } }); + let authLibrary = null; + const getAuthLibrary = async () => { + if (!authLibrary) { + authLibrary = await import('./lib/opencode-auth.js'); + } + return authLibrary; + }; + + app.delete('/api/provider/:providerId/auth', async (req, res) => { + try { + const { providerId } = req.params; + if (!providerId) { + return res.status(400).json({ error: 'Provider ID is required' }); + } + + const { removeProviderAuth } = await getAuthLibrary(); + const removed = removeProviderAuth(providerId); + + await refreshOpenCodeAfterConfigChange(`provider ${providerId} disconnected`); + + res.json({ + success: true, + removed, + requiresReload: true, + message: removed ? 'Provider disconnected successfully' : 'Provider was not connected', + reloadDelayMs: CLIENT_RELOAD_DELAY_MS, + }); + } catch (error) { + console.error('Failed to disconnect provider:', error); + res.status(500).json({ error: error.message || 'Failed to disconnect provider' }); + } + }); + let gitLibraries = null; const getGitLibraries = async () => { if (!gitLibraries) { diff --git a/packages/web/server/lib/opencode-auth.js b/packages/web/server/lib/opencode-auth.js new file mode 100644 index 00000000..2728b5f9 --- /dev/null +++ b/packages/web/server/lib/opencode-auth.js @@ -0,0 +1,81 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; + +const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); +const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json'); + +function readAuthFile() { + if (!fs.existsSync(AUTH_FILE)) { + return {}; + } + try { + const content = fs.readFileSync(AUTH_FILE, 'utf8'); + const trimmed = content.trim(); + if (!trimmed) { + return {}; + } + return JSON.parse(trimmed); + } catch (error) { + console.error('Failed to read auth file:', error); + throw new Error('Failed to read OpenCode auth configuration'); + } +} + +function writeAuthFile(auth) { + try { + if (!fs.existsSync(OPENCODE_DATA_DIR)) { + fs.mkdirSync(OPENCODE_DATA_DIR, { recursive: true }); + } + + if (fs.existsSync(AUTH_FILE)) { + const backupFile = `${AUTH_FILE}.openchamber.backup`; + fs.copyFileSync(AUTH_FILE, backupFile); + console.log(`Created auth backup: ${backupFile}`); + } + + fs.writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), 'utf8'); + console.log('Successfully wrote auth file'); + } catch (error) { + console.error('Failed to write auth file:', error); + throw new Error('Failed to write OpenCode auth configuration'); + } +} + +function removeProviderAuth(providerId) { + if (!providerId || typeof providerId !== 'string') { + throw new Error('Provider ID is required'); + } + + const auth = readAuthFile(); + + if (!auth[providerId]) { + console.log(`Provider ${providerId} not found in auth file, nothing to remove`); + return false; + } + + delete auth[providerId]; + writeAuthFile(auth); + console.log(`Removed provider auth: ${providerId}`); + return true; +} + +function getProviderAuth(providerId) { + const auth = readAuthFile(); + return auth[providerId] || null; +} + +function listProviderAuths() { + const auth = readAuthFile(); + return Object.keys(auth); +} + +export { + readAuthFile, + writeAuthFile, + removeProviderAuth, + getProviderAuth, + listProviderAuths, + AUTH_FILE, + OPENCODE_DATA_DIR +};