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
This commit is contained in:
Bohdan Triapitsyn
2025-12-27 02:22:59 +02:00
committed by GitHub
parent ff874cc33d
commit 8f9facb561
14 changed files with 1469 additions and 10 deletions
+61 -1
View File
@@ -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;
@@ -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<Value> {
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<bool> {
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<Option<Value>> {
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<Vec<String>> {
let auth = read_auth().await?;
Ok(auth
.as_object()
.map(|obj| obj.keys().cloned().collect())
.unwrap_or_default())
}
+9
View File
@@ -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() {
},
@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>MoonshotAI</title><path d="M1.052 16.916l9.539 2.552a21.007 21.007 0 00.06 2.033l5.956 1.593a11.997 11.997 0 01-5.586.865l-.18-.016-.044-.004-.084-.009-.094-.01a11.605 11.605 0 01-.157-.02l-.107-.014-.11-.016a11.962 11.962 0 01-.32-.051l-.042-.008-.075-.013-.107-.02-.07-.015-.093-.019-.075-.016-.095-.02-.097-.023-.094-.022-.068-.017-.088-.022-.09-.024-.095-.025-.082-.023-.109-.03-.062-.02-.084-.025-.093-.028-.105-.034-.058-.019-.08-.026-.09-.031-.066-.024a6.293 6.293 0 01-.044-.015l-.068-.025-.101-.037-.057-.022-.08-.03-.087-.035-.088-.035-.079-.032-.095-.04-.063-.028-.063-.027a5.655 5.655 0 01-.041-.018l-.066-.03-.103-.047-.052-.024-.096-.046-.062-.03-.084-.04-.086-.044-.093-.047-.052-.027-.103-.055-.057-.03-.058-.032a6.49 6.49 0 01-.046-.026l-.094-.053-.06-.034-.051-.03-.072-.041-.082-.05-.093-.056-.052-.032-.084-.053-.061-.039-.079-.05-.07-.047-.053-.035a7.785 7.785 0 01-.054-.036l-.044-.03-.044-.03a6.066 6.066 0 01-.04-.028l-.057-.04-.076-.054-.069-.05-.074-.054-.056-.042-.076-.057-.076-.059-.086-.067-.045-.035-.064-.052-.074-.06-.089-.073-.046-.039-.046-.039a7.516 7.516 0 01-.043-.037l-.045-.04-.061-.053-.07-.062-.068-.06-.062-.058-.067-.062-.053-.05-.088-.084a13.28 13.28 0 01-.099-.097l-.029-.028-.041-.042-.069-.07-.05-.051-.05-.053a6.457 6.457 0 01-.168-.179l-.08-.088-.062-.07-.071-.08-.042-.049-.053-.062-.058-.068-.046-.056a7.175 7.175 0 01-.027-.033l-.045-.055-.066-.082-.041-.052-.05-.064-.02-.025a11.99 11.99 0 01-1.44-2.402zm-1.02-5.794l11.353 3.037a20.468 20.468 0 00-.469 2.011l10.817 2.894a12.076 12.076 0 01-1.845 2.005L.657 15.923l-.016-.046-.035-.104a11.965 11.965 0 01-.05-.153l-.007-.023a11.896 11.896 0 01-.207-.741l-.03-.126-.018-.08-.021-.097-.018-.081-.018-.09-.017-.084-.018-.094c-.026-.141-.05-.283-.071-.426l-.017-.118-.011-.083-.013-.102a12.01 12.01 0 01-.019-.161l-.005-.047a12.12 12.12 0 01-.034-2.145zm1.593-5.15l11.948 3.196c-.368.605-.705 1.231-1.01 1.875l11.295 3.022c-.142.82-.368 1.612-.668 2.365l-11.55-3.09L.124 10.26l.015-.1.008-.049.01-.067.015-.087.018-.098c.026-.148.056-.295.088-.442l.028-.124.02-.085.024-.097c.022-.09.045-.18.07-.268l.028-.102.023-.083.03-.1.025-.082.03-.096.026-.082.031-.095a11.896 11.896 0 011.01-2.232zm4.442-4.4L17.352 4.59a20.77 20.77 0 00-1.688 1.721l7.823 2.093c.267.852.442 1.744.513 2.665L2.106 5.213l.045-.065.027-.04.04-.055.046-.065.055-.076.054-.072.064-.086.05-.065.057-.073.055-.07.06-.074.055-.069.065-.077.054-.066.066-.077.053-.06.072-.082.053-.06.067-.074.054-.058.073-.078.058-.06.063-.067.168-.17.1-.098.059-.056.076-.071a12.084 12.084 0 012.272-1.677zM12.017 0h.097l.082.001.069.001.054.002.068.002.046.001.076.003.047.002.06.003.054.002.087.005.105.007.144.011.088.007.044.004.077.008.082.008.047.005.102.012.05.006.108.014.081.01.042.006.065.01.207.032.07.012.065.011.14.026.092.018.11.022.046.01.075.016.041.01L14.7.3l.042.01.065.015.049.012.071.017.096.024.112.03.113.03.113.032.05.015.07.02.078.024.073.023.05.016.05.016.076.025.099.033.102.036.048.017.064.023.093.034.11.041.116.045.1.04.047.02.06.024.041.018.063.026.04.018.057.025.11.048.1.046.074.035.075.036.06.028.092.046.091.045.102.052.053.028.049.026.046.024.06.033.041.022.052.029.088.05.106.06.087.051.057.034.053.032.096.059.088.055.098.062.036.024.064.041.084.056.04.027.062.042.062.043.023.017c.054.037.108.075.161.114l.083.06.065.048.056.043.086.065.082.064.04.03.05.041.086.069.079.065.085.071c.712.6 1.353 1.283 1.909 2.031L7.222.994l.062-.027.065-.028.081-.034.086-.035c.113-.045.227-.09.341-.131l.096-.035.093-.033.084-.03.096-.031c.087-.03.176-.058.264-.085l.091-.027.086-.025.102-.03.085-.023.1-.026L9.04.37l.09-.023.091-.022.095-.022.09-.02.098-.021.091-.02.095-.018.092-.018.1-.018.091-.016.098-.017.092-.014.097-.015.092-.013.102-.013.091-.012.105-.012.09-.01.105-.01c.093-.01.186-.018.28-.024l.106-.008.09-.005.11-.006.093-.004.1-.004.097-.002.099-.002.197-.002z"></path></svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

@@ -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<string, unknown> =>
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<string, AuthMethod[]> => {
if (!isRecord(payload)) {
return {};
}
const result: Record<string, AuthMethod[]> = {};
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<string>();
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<Record<string, AuthMethod[]>>({});
const [authLoading, setAuthLoading] = React.useState(false);
const [apiKeyInputs, setApiKeyInputs] = React.useState<Record<string, string>>({});
const [authBusyKey, setAuthBusyKey] = React.useState<string | null>(null);
const [modelQuery, setModelQuery] = React.useState('');
const [pendingOAuth, setPendingOAuth] = React.useState<{ providerId: string; methodIndex: number } | null>(null);
const [oauthCodes, setOauthCodes] = React.useState<Record<string, string>>({});
const [oauthDetails, setOauthDetails] = React.useState<Record<string, { url?: string; instructions?: string; userCode?: string }>>({});
const [availableProviders, setAvailableProviders] = React.useState<ProviderOption[]>([]);
const [availableLoading, setAvailableLoading] = React.useState(false);
const [availableError, setAvailableError] = React.useState<string | null>(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 (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">No providers detected</p>
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
</div>
</div>
);
}
if (isAddMode) {
return (
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
<div className="space-y-1">
<h1 className="typography-ui-header font-semibold text-lg">Connect provider</h1>
<p className="typography-body text-muted-foreground">
Choose a provider to connect and set up its authentication.
</p>
</div>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Provider</h2>
<p className="typography-meta text-muted-foreground/80">
Select a provider that is not connected yet.
</p>
</div>
{availableLoading ? (
<p className="typography-meta text-muted-foreground">Loading providers</p>
) : availableError ? (
<p className="typography-meta text-muted-foreground">{availableError}</p>
) : unconnectedProviders.length === 0 ? (
<p className="typography-meta text-muted-foreground">All available providers are already connected.</p>
) : (
<DropdownMenu open={providerDropdownOpen} onOpenChange={(open) => {
setProviderDropdownOpen(open);
if (!open) setProviderSearchQuery('');
}}>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-3 py-2 typography-ui-label",
"hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
)}
>
<span className={candidateProviderId ? "text-foreground" : "text-muted-foreground"}>
{candidateProviderId
? (unconnectedProviders.find(p => p.id === candidateProviderId)?.name || candidateProviderId)
: "Select provider"}
</span>
<RiArrowDownSLine className="h-4 w-4 text-muted-foreground" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="min-w-[200px] p-0"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<div
className="flex items-center gap-2 border-b px-3 py-2"
onKeyDown={(e) => e.stopPropagation()}
>
<RiSearchLine className="h-4 w-4 text-muted-foreground" />
<input
type="text"
value={providerSearchQuery}
onChange={(e) => 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
/>
</div>
<ScrollableOverlay outerClassName="max-h-[240px]" className="p-1">
{(() => {
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 <p className="py-4 text-center typography-meta text-muted-foreground">No providers found</p>;
}
return filtered.map((provider) => (
<DropdownMenuItem
key={provider.id}
onSelect={() => {
setCandidateProviderId(provider.id);
setProviderDropdownOpen(false);
setProviderSearchQuery('');
}}
className="flex items-center justify-between"
>
<span>{provider.name || provider.id}</span>
{candidateProviderId === provider.id && (
<RiCheckLine className="h-4 w-4 text-primary" />
)}
</DropdownMenuItem>
));
})()}
</ScrollableOverlay>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{candidateProviderId && (
<div className="space-y-4">
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2>
{authLoading ? (
<p className="typography-meta text-muted-foreground">Loading authentication methods</p>
) : (
<div className="space-y-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">API key</label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
type="password"
value={apiKeyInputs[candidateProviderId] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[candidateProviderId]: event.target.value,
}))
}
placeholder="sk-..."
/>
<Button
size="sm"
onClick={() => handleSaveApiKey(candidateProviderId)}
disabled={authBusyKey === `api:${candidateProviderId}`}
className="h-8"
>
{authBusyKey === `api:${candidateProviderId}` ? 'Saving…' : 'Save key'}
</Button>
</div>
<p className="typography-meta text-muted-foreground">
Keys are sent directly to OpenCode and never stored by OpenChamber.
</p>
</div>
{(() => {
const candidateAuthMethods = authMethodsByProvider[candidateProviderId] ?? [];
const candidateOAuthMethods = candidateAuthMethods.filter(
(method) => normalizeAuthType(method) === 'oauth'
);
if (candidateOAuthMethods.length === 0) {
return null;
}
return (
<div className="space-y-3">
{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 (
<div key={`${candidateProviderId}-${methodLabel}`} className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label font-medium text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => handleOAuthStart(candidateProviderId, index)}
disabled={authBusyKey === `oauth:${candidateProviderId}:${index}`}
className="h-8"
>
Connect
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-muted-foreground">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input value={oauthDetails[codeKey]?.userCode} readOnly />
<Button
size="sm"
variant="outline"
onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
className="h-8"
>
Copy code
</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input value={oauthDetails[codeKey]?.url} readOnly />
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="h-8"
asChild
>
<a href={oauthDetails[codeKey]?.url} target="_blank" rel="noopener noreferrer">
Open link
</a>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
className="h-8"
>
Copy link
</Button>
</div>
</div>
)}
{isPending && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder="Authorization code (if required)"
/>
<Button
size="sm"
onClick={() => handleOAuthComplete(candidateProviderId, index)}
disabled={authBusyKey === `oauth-complete:${candidateProviderId}:${index}`}
className="h-8"
>
{authBusyKey === `oauth-complete:${candidateProviderId}:${index}`
? 'Saving…'
: 'Complete'}
</Button>
</div>
)}
</div>
);
})}
</div>
);
})()}
</div>
)}
</div>
)}
</ScrollableOverlay>
);
}
if (!selectedProvider) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-12 w-12 opacity-50" />
<p className="typography-body">Select a provider from the sidebar</p>
<p className="typography-meta mt-1 opacity-75">Review details and configure auth</p>
</div>
</div>
);
}
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 (
<ScrollableOverlay outerClassName="h-full" className="mx-auto max-w-3xl space-y-6 p-6">
<SectionPlaceholder sectionId="providers" variant="page" />
<div className="space-y-1">
<div className="flex items-center gap-2">
<ProviderLogo providerId={selectedProvider.id} className="h-5 w-5" />
<h1 className="typography-ui-header font-semibold text-lg">
{selectedProvider.name || selectedProvider.id}
</h1>
</div>
<p className="typography-body text-muted-foreground">
Provider ID: {selectedProvider.id}
</p>
</div>
<div className="space-y-4">
<h2 className="typography-ui-header font-semibold text-foreground">Authentication</h2>
{authLoading ? (
<p className="typography-meta text-muted-foreground">Loading authentication methods</p>
) : (
<div className="space-y-4">
<div className="space-y-2">
<label className="typography-ui-label font-medium text-foreground">API key</label>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
type="password"
value={apiKeyInputs[selectedProvider.id] ?? ''}
onChange={(event) =>
setApiKeyInputs((prev) => ({
...prev,
[selectedProvider.id]: event.target.value,
}))
}
placeholder="sk-..."
/>
<Button
size="sm"
onClick={() => handleSaveApiKey(selectedProvider.id)}
disabled={authBusyKey === `api:${selectedProvider.id}`}
className="h-8"
>
{authBusyKey === `api:${selectedProvider.id}` ? 'Saving…' : 'Save key'}
</Button>
</div>
<p className="typography-meta text-muted-foreground">
Keys are sent directly to OpenCode and never stored by OpenChamber.
</p>
</div>
<div className="pt-2 border-t border-border/40">
<Button
size="sm"
variant="outline"
onClick={() => handleDisconnectProvider(selectedProvider.id)}
disabled={authBusyKey === `disconnect:${selectedProvider.id}`}
className="h-8 text-destructive hover:text-destructive hover:bg-destructive/10"
>
{authBusyKey === `disconnect:${selectedProvider.id}` ? 'Disconnecting…' : 'Disconnect provider'}
</Button>
</div>
{oauthAuthMethods.length > 0 && (
<div className="space-y-3">
{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 (
<div key={`${selectedProvider.id}-${methodLabel}`} className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div>
<div className="typography-ui-label font-medium text-foreground">{methodLabel}</div>
{(method.description || method.help) && (
<div className="typography-meta text-muted-foreground">
{String(method.description || method.help)}
</div>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={() => handleOAuthStart(selectedProvider.id, index)}
disabled={authBusyKey === `oauth:${selectedProvider.id}:${index}`}
className="h-8"
>
Connect
</Button>
</div>
{oauthDetails[codeKey]?.instructions && (
<p className="typography-meta text-muted-foreground">
{oauthDetails[codeKey]?.instructions}
</p>
)}
{oauthDetails[codeKey]?.userCode && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input value={oauthDetails[codeKey]?.userCode} readOnly />
<Button
size="sm"
variant="outline"
onClick={() => handleCopyOAuthCode(oauthDetails[codeKey]?.userCode ?? '')}
className="h-8"
>
Copy code
</Button>
</div>
)}
{oauthDetails[codeKey]?.url && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input value={oauthDetails[codeKey]?.url} readOnly />
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="h-8"
asChild
>
<a href={oauthDetails[codeKey]?.url} target="_blank" rel="noopener noreferrer">
Open link
</a>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCopyOAuthLink(oauthDetails[codeKey]?.url ?? '')}
className="h-8"
>
Copy link
</Button>
</div>
</div>
)}
{isPending && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Input
value={oauthCodes[codeKey] ?? ''}
onChange={(event) =>
setOauthCodes((prev) => ({
...prev,
[codeKey]: event.target.value,
}))
}
placeholder="Paste authorization code"
/>
<Button
size="sm"
onClick={() => handleOAuthComplete(selectedProvider.id, index)}
disabled={authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`}
className="h-8"
>
{authBusyKey === `oauth-complete:${selectedProvider.id}:${index}`
? 'Saving…'
: 'Complete'}
</Button>
</div>
)}
</div>
);
})}
</div>
)}
</div>
)}
</div>
<div className="space-y-4">
<div className="space-y-1">
<h2 className="typography-ui-header font-semibold text-foreground">Models</h2>
<p className="typography-meta text-muted-foreground/80">
Browse and filter models exposed by this provider.
</p>
</div>
<Input
value={modelQuery}
onChange={(event) => setModelQuery(event.target.value)}
placeholder="Filter models..."
/>
<div className="border-t border-border/40">
{filteredModels.length === 0 ? (
<p className="typography-meta text-muted-foreground py-3 px-2">No models match this filter.</p>
) : (
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 (
<div
key={modelId}
className="flex items-center gap-2 px-2 py-1.5 border-b border-border/40"
>
<span className="typography-meta font-medium text-foreground truncate flex-1 min-w-0">
{modelName}
</span>
{(contextTokens || outputTokens) && (
<span className="typography-micro text-muted-foreground flex-shrink-0">
{contextTokens ? `${contextTokens} ctx` : ''}
{contextTokens && outputTokens ? ' · ' : ''}
{outputTokens ? `${outputTokens} out` : ''}
</span>
)}
{capabilityIcons.length > 0 && (
<div className="flex items-center gap-1 flex-shrink-0">
{capabilityIcons.map(({ key, icon: Icon, label }) => (
<span
key={key}
className="flex h-4 w-4 items-center justify-center text-muted-foreground"
title={label}
aria-label={label}
>
<Icon className="h-3 w-3" />
</span>
))}
</div>
)}
</div>
);
})
)}
</div>
</div>
</ScrollableOverlay>
);
};
@@ -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 (
<ScrollableOverlay outerClassName="h-full" className="px-3 py-2">
<SectionPlaceholder sectionId="providers" variant="sidebar" />
</ScrollableOverlay>
<div className="flex h-full flex-col bg-sidebar">
<div className={cn('border-b border-border/40 px-3 dark:border-white/10', isMobile ? 'mt-2 py-3' : 'py-3')}>
<div className="flex items-center justify-between gap-2">
<h2 className="typography-ui-label font-semibold text-foreground">Providers</h2>
<div className="flex items-center gap-1">
<span className="typography-meta text-muted-foreground">{providers.length}</span>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground"
onClick={() => setSelectedProvider(ADD_PROVIDER_ID)}
aria-label="Connect provider"
title="Connect provider"
>
<RiAddLine className="size-4" />
</Button>
</div>
</div>
</div>
<ScrollableOverlay outerClassName="flex-1 min-h-0" className="space-y-1 px-3 py-2 overflow-x-hidden">
{providers.length === 0 ? (
<div className="py-12 px-4 text-center text-muted-foreground">
<RiStackLine className="mx-auto mb-3 h-10 w-10 opacity-50" />
<p className="typography-ui-label font-medium">No providers found</p>
<p className="typography-meta mt-1 opacity-75">Check your OpenCode configuration</p>
</div>
) : (
providers.map((provider) => {
const modelCount = Array.isArray(provider.models) ? provider.models.length : 0;
const isSelected = provider.id === selectedProviderId;
return (
<div key={provider.id} className="group transition-all duration-200">
<div className="relative">
<div className="w-full flex items-center justify-between py-1.5 px-2 pr-1">
<button
type="button"
onClick={() => setSelectedProvider(provider.id)}
className="flex-1 text-left overflow-hidden"
tabIndex={0}
>
<div className="flex items-center gap-2">
<ProviderLogo providerId={provider.id} className="h-4 w-4 flex-shrink-0" />
<span className={cn(
"typography-ui-label font-medium truncate flex-1 min-w-0",
isSelected
? "text-primary"
: "text-foreground hover:text-primary/80"
)}>
{provider.name || provider.id}
</span>
<span className="typography-meta text-muted-foreground flex-shrink-0">
{modelCount}
</span>
</div>
</button>
</div>
</div>
</div>
);
})
)}
</ScrollableOverlay>
</div>
);
};
-2
View File
@@ -70,7 +70,6 @@ function SelectContent({
position={position}
{...props}
>
<SelectScrollUpButton />
<ScrollableOverlay
as={SelectPrimitive.Viewport}
outerClassName="max-h-[var(--radix-select-content-available-height)] w-full"
@@ -82,7 +81,6 @@ function SelectContent({
>
{children}
</ScrollableOverlay>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
+1
View File
@@ -67,6 +67,7 @@ export type DesktopApi = {
checkForUpdates?: () => Promise<UpdateInfo>;
downloadUpdate?: (onProgress?: (progress: UpdateProgress) => void) => Promise<void>;
restartToUpdate?: () => Promise<void>;
openExternal?: (url: string) => Promise<{ success: boolean; error?: string }>;
};
export const isDesktopRuntime = (): boolean =>
+9
View File
@@ -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<ConfigStore>()(
currentProviderId: "",
currentModelId: "",
currentAgentName: undefined,
selectedProviderId: "",
agentModelSelections: {},
defaultProviders: {},
isConnected: false,
@@ -392,6 +395,7 @@ export const useConfigStore = create<ConfigStore>()(
set({
currentProviderId: providerId,
currentModelId: newModelId,
selectedProviderId: providerId,
});
}
},
@@ -400,6 +404,10 @@ export const useConfigStore = create<ConfigStore>()(
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<ConfigStore>()(
set({
currentProviderId: agent.model!.providerID,
currentModelId: agent.model!.modelID,
selectedProviderId: agent.model!.providerID,
});
}
}
+31
View File
@@ -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}` };
}
+72
View File
@@ -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<string, unknown>;
type AuthFile = Record<string, AuthEntry>;
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 };
+13
View File
@@ -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;
};
+33
View File
@@ -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) {
+81
View File
@@ -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
};