From 425e81f4a5f142b20b73e683f62807f12a2f5add Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 20 Jan 2026 16:48:48 +0900 Subject: [PATCH] feat: implement custom endpoint management - Added functionality to manage custom endpoints, including creating, updating, deleting, and retrieving endpoints. - Introduced new types for custom endpoints and updated existing types to accommodate custom endpoint configurations. - Enhanced the model switcher to support custom endpoints, allowing users to select and configure them. - Implemented API key management for custom endpoints, ensuring secure access to their services. - Updated IPC handlers to facilitate communication between the renderer and main processes for custom endpoint operations. --- src/main/agent/runtime.ts | 62 +++- src/main/ipc/models.ts | 221 ++++++++++- src/main/storage.ts | 110 +++++- src/main/types.ts | 27 +- src/preload/index.d.ts | 23 +- src/preload/index.ts | 37 +- .../components/chat/CustomEndpointDialog.tsx | 350 ++++++++++++++++++ .../src/components/chat/ModelSwitcher.tsx | 205 +++++++++- src/renderer/src/lib/store.ts | 43 ++- src/renderer/src/types.ts | 27 +- 10 files changed, 1072 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/components/chat/CustomEndpointDialog.tsx diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 9d997dd66..06e3dc5da 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -1,7 +1,12 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createDeepAgent } from "deepagents" import { getDefaultModel } from "../ipc/models" -import { getApiKey, getThreadCheckpointPath } from "../storage" +import { + getApiKey, + getThreadCheckpointPath, + getCustomEndpoint, + getCustomEndpointApiKey +} from "../storage" import { ChatAnthropic } from "@langchain/anthropic" import { ChatOpenAI } from "@langchain/openai" import { ChatGoogleGenerativeAI } from "@langchain/google-genai" @@ -58,6 +63,24 @@ export async function closeCheckpointer(threadId: string): Promise { } } +// Parse custom endpoint model ID format: "custom:endpoint-id:model-name" +function parseCustomModelId(modelId: string): { endpointId: string; modelName: string } | null { + if (!modelId.startsWith("custom:")) { + return null + } + + const parts = modelId.split(":") + if (parts.length < 3) { + return null + } + + // Format: custom:endpoint-id:model-name (model name may contain colons) + const endpointId = parts[1] + const modelName = parts.slice(2).join(":") + + return { endpointId, modelName } +} + // Get the appropriate model instance based on configuration function getModelInstance( modelId?: string @@ -65,6 +88,43 @@ function getModelInstance( const model = modelId || getDefaultModel() console.log("[Runtime] Using model:", model) + // Check for custom endpoint model format: "custom:endpoint-id:model-name" + const customModel = parseCustomModelId(model) + if (customModel) { + const { endpointId, modelName } = customModel + + const endpoint = getCustomEndpoint(endpointId) + if (!endpoint) { + throw new Error(`Custom endpoint "${endpointId}" not found`) + } + + const apiKey = getCustomEndpointApiKey(endpointId) + if (!apiKey) { + throw new Error(`API key not configured for custom endpoint "${endpointId}"`) + } + + // Normalize base URL (remove trailing slash) + const baseURL = endpoint.baseUrl.replace(/\/+$/, "") + + console.log("[Runtime] Using custom endpoint:", endpoint.name, "model:", modelName) + + // For custom OpenAI-compatible endpoints: + // - openAIApiKey: LangChain's parameter for the API key + // - configuration.baseURL: Sets the custom endpoint URL + // - configuration.apiKey: Ensures the underlying OpenAI SDK also has the key + // We also set OPENAI_API_KEY in env as a fallback for SDK lazy initialization + process.env.OPENAI_API_KEY = apiKey + + return new ChatOpenAI({ + modelName: modelName, + openAIApiKey: apiKey, + configuration: { + baseURL, + apiKey + } + }) + } + // Determine provider from model ID if (model.startsWith("claude")) { const apiKey = getApiKey("anthropic") diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index e56866b3d..028f2ef98 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -8,10 +8,26 @@ import type { SetApiKeyParams, WorkspaceSetParams, WorkspaceLoadParams, - WorkspaceFileParams + WorkspaceFileParams, + CustomEndpoint, + CreateEndpointParams, + UpdateEndpointParams } from "../types" import { startWatching, stopWatching } from "../services/workspace-watcher" -import { getOpenworkDir, getApiKey, setApiKey, deleteApiKey, hasApiKey } from "../storage" +import { + getOpenworkDir, + getApiKey, + setApiKey, + deleteApiKey, + hasApiKey, + getCustomEndpoints, + getCustomEndpoint, + saveCustomEndpoint, + deleteCustomEndpoint, + getCustomEndpointApiKey, + setCustomEndpointApiKey, + hasCustomEndpointApiKey +} from "../storage" // Store for non-sensitive settings only (no encryption needed) const store = new Store({ @@ -205,13 +221,35 @@ const AVAILABLE_MODELS: ModelConfig[] = [ ] export function registerModelHandlers(ipcMain: IpcMain): void { - // List available models + // List available models (including custom endpoint models) ipcMain.handle("models:list", async () => { - // Check which models have API keys configured - return AVAILABLE_MODELS.map((model) => ({ + // Built-in models with API key availability + const builtInModels = AVAILABLE_MODELS.map((model) => ({ ...model, available: hasApiKey(model.provider) })) + + // Custom endpoint models + const customEndpoints = getCustomEndpoints() + const customModels: ModelConfig[] = [] + + for (const endpoint of customEndpoints) { + if (endpoint.models && endpoint.models.length > 0) { + for (const modelName of endpoint.models) { + customModels.push({ + id: `custom:${endpoint.id}:${modelName}`, + name: modelName, + provider: "custom", + model: modelName, + description: `via ${endpoint.name}`, + available: hasCustomEndpointApiKey(endpoint.id), + endpointId: endpoint.id + }) + } + } + } + + return [...builtInModels, ...customModels] }) // Get default model @@ -239,14 +277,183 @@ export function registerModelHandlers(ipcMain: IpcMain): void { deleteApiKey(provider) }) - // List providers with their API key status + // List providers with their API key status (including custom endpoints) ipcMain.handle("models:listProviders", async () => { - return PROVIDERS.map((provider) => ({ + // Built-in providers + const builtInProviders = PROVIDERS.map((provider) => ({ ...provider, hasApiKey: hasApiKey(provider.id) })) + + // Custom endpoints as providers + const customEndpoints = getCustomEndpoints() + const customProviders: Provider[] = customEndpoints.map((endpoint) => ({ + id: "custom" as const, + name: endpoint.name, + hasApiKey: hasCustomEndpointApiKey(endpoint.id), + endpointId: endpoint.id + })) + + return [...builtInProviders, ...customProviders] + }) + + // ============================================================================= + // Custom Endpoint Handlers + // ============================================================================= + + // List all custom endpoints + ipcMain.handle("endpoints:list", async () => { + return getCustomEndpoints() }) + // Get a single custom endpoint + ipcMain.handle("endpoints:get", async (_event, id: string) => { + return getCustomEndpoint(id) ?? null + }) + + // Create a new custom endpoint + ipcMain.handle("endpoints:create", async (_event, params: CreateEndpointParams) => { + const { id, name, baseUrl, apiKey } = params + + // Check if endpoint with this ID already exists + const existing = getCustomEndpoint(id) + if (existing) { + throw new Error(`Endpoint with ID "${id}" already exists`) + } + + // Create endpoint + const endpoint: CustomEndpoint = { + id, + name, + baseUrl, + models: [] + } + + saveCustomEndpoint(endpoint) + setCustomEndpointApiKey(id, apiKey) + + return endpoint + }) + + // Update an existing custom endpoint + ipcMain.handle("endpoints:update", async (_event, params: UpdateEndpointParams) => { + const { id, updates } = params + + const existing = getCustomEndpoint(id) + if (!existing) { + throw new Error(`Endpoint with ID "${id}" not found`) + } + + // Update endpoint properties + const updated: CustomEndpoint = { + ...existing, + ...updates + } + + saveCustomEndpoint(updated) + + // Update API key if provided + if (updates.apiKey) { + setCustomEndpointApiKey(id, updates.apiKey) + } + + return updated + }) + + // Delete a custom endpoint + ipcMain.handle("endpoints:delete", async (_event, id: string) => { + deleteCustomEndpoint(id) + }) + + // Discover models from a custom endpoint's /models API + ipcMain.handle("endpoints:discoverModels", async (_event, id: string) => { + const endpoint = getCustomEndpoint(id) + if (!endpoint) { + throw new Error(`Endpoint with ID "${id}" not found`) + } + + const apiKey = getCustomEndpointApiKey(id) + if (!apiKey) { + throw new Error(`No API key configured for endpoint "${id}"`) + } + + // Normalize base URL (remove trailing slash) + const baseUrl = endpoint.baseUrl.replace(/\/+$/, "") + const modelsUrl = `${baseUrl}/models` + + try { + const response = await fetch(modelsUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json" + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`) + } + + const data = (await response.json()) as { data?: Array<{ id: string }> } + + // OpenAI-compatible /models endpoint returns { data: [{ id: "model-name", ... }, ...] } + const models = data.data?.map((m) => m.id) ?? [] + + // Update endpoint with discovered models + const updated: CustomEndpoint = { + ...endpoint, + models + } + saveCustomEndpoint(updated) + + return models + } catch (error) { + throw new Error( + `Failed to discover models: ${error instanceof Error ? error.message : "Unknown error"}` + ) + } + }) + + // Test connection to a custom endpoint (validates API key and base URL) + ipcMain.handle( + "endpoints:testConnection", + async (_event, { baseUrl, apiKey }: { baseUrl: string; apiKey: string }) => { + // Normalize base URL (remove trailing slash) + const normalizedUrl = baseUrl.replace(/\/+$/, "") + const modelsUrl = `${normalizedUrl}/models` + + try { + const response = await fetch(modelsUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json" + } + }) + + if (!response.ok) { + return { + success: false, + error: `HTTP ${response.status}: ${response.statusText}` + } + } + + const data = (await response.json()) as { data?: Array<{ id: string }> } + const models = data.data?.map((m) => m.id) ?? [] + + return { + success: true, + models + } + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Connection failed" + } + } + } + ) + // Sync version info ipcMain.on("app:version", (event) => { event.returnValue = app.getVersion() diff --git a/src/main/storage.ts b/src/main/storage.ts index d09686cf9..b312bd284 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -1,19 +1,27 @@ import { homedir } from "os" import { join } from "path" import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs" -import type { ProviderId } from "./types" +import type { ProviderId, CustomEndpoint } from "./types" const OPENWORK_DIR = join(homedir(), ".openwork") const ENV_FILE = join(OPENWORK_DIR, ".env") +const ENDPOINTS_FILE = join(OPENWORK_DIR, "endpoints.json") // Environment variable names for each provider -const ENV_VAR_NAMES: Record = { +const ENV_VAR_NAMES: Record, string> = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", google: "GOOGLE_API_KEY", ollama: "" // Ollama doesn't require an API key } +// Generate env var name for custom endpoint API key +function getCustomEndpointEnvVarName(endpointId: string): string { + // Convert to uppercase and replace non-alphanumeric with underscore + const sanitized = endpointId.toUpperCase().replace(/[^A-Z0-9]/g, "_") + return `CUSTOM_ENDPOINT_${sanitized}_API_KEY` +} + export function getOpenworkDir(): string { if (!existsSync(OPENWORK_DIR)) { mkdirSync(OPENWORK_DIR, { recursive: true }) @@ -122,3 +130,101 @@ export function deleteApiKey(provider: string): void { export function hasApiKey(provider: string): boolean { return !!getApiKey(provider) } + +// ============================================================================= +// Custom Endpoint Management +// ============================================================================= + +// Read endpoints from JSON file +function readEndpointsFile(): CustomEndpoint[] { + if (!existsSync(ENDPOINTS_FILE)) return [] + + try { + const content = readFileSync(ENDPOINTS_FILE, "utf-8") + return JSON.parse(content) as CustomEndpoint[] + } catch { + return [] + } +} + +// Write endpoints to JSON file +function writeEndpointsFile(endpoints: CustomEndpoint[]): void { + getOpenworkDir() // ensure dir exists + writeFileSync(ENDPOINTS_FILE, JSON.stringify(endpoints, null, 2)) +} + +// Get all custom endpoints +export function getCustomEndpoints(): CustomEndpoint[] { + return readEndpointsFile() +} + +// Get a specific custom endpoint by ID +export function getCustomEndpoint(id: string): CustomEndpoint | undefined { + const endpoints = readEndpointsFile() + return endpoints.find((e) => e.id === id) +} + +// Save a custom endpoint (create or update) +export function saveCustomEndpoint(endpoint: CustomEndpoint): void { + const endpoints = readEndpointsFile() + const index = endpoints.findIndex((e) => e.id === endpoint.id) + + if (index >= 0) { + endpoints[index] = endpoint + } else { + endpoints.push(endpoint) + } + + writeEndpointsFile(endpoints) +} + +// Delete a custom endpoint +export function deleteCustomEndpoint(id: string): void { + const endpoints = readEndpointsFile() + const filtered = endpoints.filter((e) => e.id !== id) + writeEndpointsFile(filtered) + + // Also delete the API key + deleteCustomEndpointApiKey(id) +} + +// Get API key for a custom endpoint +export function getCustomEndpointApiKey(endpointId: string): string | undefined { + const envVarName = getCustomEndpointEnvVarName(endpointId) + + // Check .env file first + const env = parseEnvFile() + if (env[envVarName]) return env[envVarName] + + // Fall back to process environment + return process.env[envVarName] +} + +// Set API key for a custom endpoint +export function setCustomEndpointApiKey(endpointId: string, apiKey: string): void { + const envVarName = getCustomEndpointEnvVarName(endpointId) + + const env = parseEnvFile() + env[envVarName] = apiKey + writeEnvFile(env) + + // Also set in process.env for current session + process.env[envVarName] = apiKey +} + +// Delete API key for a custom endpoint +export function deleteCustomEndpointApiKey(endpointId: string): void { + const envVarName = getCustomEndpointEnvVarName(endpointId) + + const env = parseEnvFile() + delete env[envVarName] + writeEnvFile(env) + + // Also clear from process.env + delete process.env[envVarName] +} + +// Check if custom endpoint has an API key +export function hasCustomEndpointApiKey(endpointId: string): boolean { + return !!getCustomEndpointApiKey(endpointId) +} diff --git a/src/main/types.ts b/src/main/types.ts index e0ebab313..58350a026 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -80,12 +80,14 @@ export interface Run { } // Provider configuration -export type ProviderId = "anthropic" | "openai" | "google" | "ollama" +export type ProviderId = "anthropic" | "openai" | "google" | "ollama" | "custom" export interface Provider { id: ProviderId name: string hasApiKey: boolean + // For custom endpoints, reference the endpoint ID + endpointId?: string } // Model configuration @@ -96,6 +98,29 @@ export interface ModelConfig { model: string description?: string available: boolean + // For custom endpoint models, reference the endpoint ID + endpointId?: string +} + +// Custom OpenAI-compatible endpoint configuration +export interface CustomEndpoint { + id: string // Unique ID (e.g., "azure-prod", "local-llm") + name: string // Display name + baseUrl: string // Base URL (e.g., "https://api.openai.com/v1") + models?: string[] // Discovered models (cached) +} + +// IPC params for custom endpoints +export interface CreateEndpointParams { + id: string + name: string + baseUrl: string + apiKey: string +} + +export interface UpdateEndpointParams { + id: string + updates: Partial> & { apiKey?: string } } // Subagent types (from deepagentsjs) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 51e74c473..ffdf67826 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -1,4 +1,13 @@ -import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from "../main/types" +import type { + Thread, + ModelConfig, + Provider, + StreamEvent, + HITLDecision, + CustomEndpoint, + CreateEndpointParams, + UpdateEndpointParams +} from "../main/types" interface ElectronAPI { ipcRenderer: { @@ -53,6 +62,18 @@ interface CustomAPI { setApiKey: (provider: string, apiKey: string) => Promise getApiKey: (provider: string) => Promise } + endpoints: { + list: () => Promise + get: (id: string) => Promise + create: (params: CreateEndpointParams) => Promise + update: (params: UpdateEndpointParams) => Promise + delete: (id: string) => Promise + discoverModels: (id: string) => Promise + testConnection: ( + baseUrl: string, + apiKey: string + ) => Promise<{ success: boolean; models?: string[]; error?: string }> + } workspace: { get: (threadId?: string) => Promise set: (threadId: string | undefined, path: string | null) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ffb2b36f0..e28ea90d6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,5 +1,14 @@ import { contextBridge, ipcRenderer } from "electron" -import type { Thread, ModelConfig, Provider, StreamEvent, HITLDecision } from "../main/types" +import type { + Thread, + ModelConfig, + Provider, + StreamEvent, + HITLDecision, + CustomEndpoint, + CreateEndpointParams, + UpdateEndpointParams +} from "../main/types" // Simple electron API - replaces @electron-toolkit/preload const electronAPI = { @@ -150,6 +159,32 @@ const api = { return ipcRenderer.invoke("models:deleteApiKey", provider) } }, + endpoints: { + list: (): Promise => { + return ipcRenderer.invoke("endpoints:list") + }, + get: (id: string): Promise => { + return ipcRenderer.invoke("endpoints:get", id) + }, + create: (params: CreateEndpointParams): Promise => { + return ipcRenderer.invoke("endpoints:create", params) + }, + update: (params: UpdateEndpointParams): Promise => { + return ipcRenderer.invoke("endpoints:update", params) + }, + delete: (id: string): Promise => { + return ipcRenderer.invoke("endpoints:delete", id) + }, + discoverModels: (id: string): Promise => { + return ipcRenderer.invoke("endpoints:discoverModels", id) + }, + testConnection: ( + baseUrl: string, + apiKey: string + ): Promise<{ success: boolean; models?: string[]; error?: string }> => { + return ipcRenderer.invoke("endpoints:testConnection", { baseUrl, apiKey }) + } + }, workspace: { get: (threadId?: string): Promise => { return ipcRenderer.invoke("workspace:get", threadId) diff --git a/src/renderer/src/components/chat/CustomEndpointDialog.tsx b/src/renderer/src/components/chat/CustomEndpointDialog.tsx new file mode 100644 index 000000000..4a5fdd926 --- /dev/null +++ b/src/renderer/src/components/chat/CustomEndpointDialog.tsx @@ -0,0 +1,350 @@ +import { useState, useEffect } from "react" +import { Eye, EyeOff, Loader2, Trash2, CheckCircle2, XCircle, Zap } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import type { CustomEndpoint } from "@/types" + +interface CustomEndpointDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + endpoint?: CustomEndpoint | null // If provided, we're editing; otherwise creating + onSave?: () => void +} + +interface ConnectionTestResult { + success: boolean + models?: string[] + error?: string +} + +export function CustomEndpointDialog({ + open, + onOpenChange, + endpoint, + onSave +}: CustomEndpointDialogProps): React.JSX.Element { + const isEditing = !!endpoint + + const [id, setId] = useState("") + const [name, setName] = useState("") + const [baseUrl, setBaseUrl] = useState("") + const [apiKey, setApiKey] = useState("") + const [showKey, setShowKey] = useState(false) + const [saving, setSaving] = useState(false) + const [deleting, setDeleting] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState(null) + + // Reset form when dialog opens/closes or endpoint changes + useEffect(() => { + if (open) { + if (endpoint) { + setId(endpoint.id) + setName(endpoint.name) + setBaseUrl(endpoint.baseUrl) + setApiKey("") // Don't show existing API key + } else { + setId("") + setName("") + setBaseUrl("") + setApiKey("") + } + setShowKey(false) + setTestResult(null) + } + }, [open, endpoint]) + + // Auto-generate ID from name for new endpoints + useEffect(() => { + if (!isEditing && name) { + const generatedId = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + setId(generatedId) + } + }, [name, isEditing]) + + async function handleTestConnection(): Promise { + if (!baseUrl.trim() || !apiKey.trim()) return + + setTesting(true) + setTestResult(null) + + try { + const result = await window.api.endpoints.testConnection(baseUrl.trim(), apiKey.trim()) + setTestResult(result) + } catch (e) { + setTestResult({ + success: false, + error: e instanceof Error ? e.message : "Connection test failed" + }) + } finally { + setTesting(false) + } + } + + async function handleSave(): Promise { + if (!id.trim() || !name.trim() || !baseUrl.trim()) return + if (!isEditing && !apiKey.trim()) return + + setSaving(true) + try { + if (isEditing) { + // Update existing endpoint + await window.api.endpoints.update({ + id: endpoint!.id, + updates: { + name: name.trim(), + baseUrl: baseUrl.trim(), + ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}) + } + }) + + // If test was successful, we already have the models - trigger discovery to cache them + if (testResult?.success && testResult.models) { + await window.api.endpoints.update({ + id: endpoint!.id, + updates: { models: testResult.models } + }) + } + } else { + // Create new endpoint + await window.api.endpoints.create({ + id: id.trim(), + name: name.trim(), + baseUrl: baseUrl.trim(), + apiKey: apiKey.trim() + }) + + // If test was successful, cache the discovered models + if (testResult?.success && testResult.models) { + await window.api.endpoints.update({ + id: id.trim(), + updates: { models: testResult.models } + }) + } + } + + onSave?.() + onOpenChange(false) + } catch (e) { + console.error("Failed to save endpoint:", e) + } finally { + setSaving(false) + } + } + + async function handleDelete(): Promise { + if (!endpoint) return + + setDeleting(true) + try { + await window.api.endpoints.delete(endpoint.id) + onSave?.() + onOpenChange(false) + } catch (e) { + console.error("Failed to delete endpoint:", e) + } finally { + setDeleting(false) + } + } + + const canTest = baseUrl.trim() && apiKey.trim() + const canSave = id.trim() && name.trim() && baseUrl.trim() && (isEditing || apiKey.trim()) + + return ( + + + + {isEditing ? `Edit ${endpoint?.name}` : "Add Custom Endpoint"} + + {isEditing + ? "Update the endpoint configuration or API key." + : "Add an OpenAI-compatible API endpoint (e.g., Azure OpenAI, local LLM server, vLLM)."} + + + +
+ {/* Name */} +
+ + setName(e.target.value)} + placeholder="My Custom Endpoint" + autoFocus={!isEditing} + /> +
+ + {/* ID (read-only when editing) */} +
+ + setId(e.target.value)} + placeholder="my-custom-endpoint" + disabled={isEditing} + className={isEditing ? "bg-muted" : ""} + /> +
+ + {/* Base URL */} +
+ + setBaseUrl(e.target.value)} + placeholder="https://api.example.com/v1" + /> +

+ The base URL for the OpenAI-compatible API (e.g., https://api.openai.com/v1) +

+
+ + {/* API Key */} +
+ +
+ setApiKey(e.target.value)} + placeholder={isEditing ? "••••••••••••••••" : "sk-..."} + className="pr-10" + /> + +
+
+ + {/* Test Connection */} +
+ +
+ + {/* Test Result */} + {testResult && ( +
+ {testResult.success ? ( +
+
+ + Connection successful! +
+ {testResult.models && testResult.models.length > 0 && ( +
+

+ Discovered {testResult.models.length} models: +

+
+
    + {testResult.models.slice(0, 10).map((model) => ( +
  • + {model} +
  • + ))} + {testResult.models.length > 10 && ( +
  • + ...and {testResult.models.length - 10} more +
  • + )} +
+
+
+ )} +
+ ) : ( +
+ + {testResult.error || "Connection failed"} +
+ )} +
+ )} +
+ +
+ {isEditing ? ( + + ) : ( +
+ )} +
+ + +
+
+ +
+ ) +} diff --git a/src/renderer/src/components/chat/ModelSwitcher.tsx b/src/renderer/src/components/chat/ModelSwitcher.tsx index 45ea665cb..9930a9d68 100644 --- a/src/renderer/src/components/chat/ModelSwitcher.tsx +++ b/src/renderer/src/components/chat/ModelSwitcher.tsx @@ -1,12 +1,13 @@ import { useState, useEffect } from "react" -import { ChevronDown, Check, AlertCircle, Key } from "lucide-react" +import { ChevronDown, Check, AlertCircle, Key, Plus, Server, Settings } from "lucide-react" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Button } from "@/components/ui/button" import { useAppStore } from "@/lib/store" import { useCurrentThread } from "@/lib/thread-context" import { cn } from "@/lib/utils" import { ApiKeyDialog } from "./ApiKeyDialog" -import type { Provider, ProviderId } from "@/types" +import { CustomEndpointDialog } from "./CustomEndpointDialog" +import type { Provider, ProviderId, CustomEndpoint } from "@/types" // Provider icons as simple SVG components function AnthropicIcon({ className }: { className?: string }): React.JSX.Element { @@ -33,11 +34,16 @@ function GoogleIcon({ className }: { className?: string }): React.JSX.Element { ) } +function CustomEndpointIcon({ className }: { className?: string }): React.JSX.Element { + return +} + const PROVIDER_ICONS: Record> = { anthropic: AnthropicIcon, openai: OpenAIIcon, google: GoogleIcon, - ollama: () => null // No icon for ollama yet + ollama: () => null, // No icon for ollama yet + custom: CustomEndpointIcon } // Fallback providers in case the backend hasn't loaded them yet @@ -54,35 +60,87 @@ interface ModelSwitcherProps { export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Element { const [open, setOpen] = useState(false) const [selectedProviderId, setSelectedProviderId] = useState(null) + const [selectedEndpointId, setSelectedEndpointId] = useState(null) const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false) const [apiKeyProvider, setApiKeyProvider] = useState(null) + const [endpointDialogOpen, setEndpointDialogOpen] = useState(false) + const [editingEndpoint, setEditingEndpoint] = useState(null) + const [customEndpoints, setCustomEndpoints] = useState([]) const { models, providers, loadModels, loadProviders } = useAppStore() const { currentModel, setCurrentModel } = useCurrentThread(threadId) - // Load models and providers on mount + // Load models, providers, and custom endpoints on mount useEffect(() => { loadModels() loadProviders() + + async function fetchCustomEndpoints(): Promise { + try { + const endpoints = await window.api.endpoints.list() + setCustomEndpoints(endpoints) + } catch (e) { + console.error("Failed to load custom endpoints:", e) + } + } + fetchCustomEndpoints() }, [loadModels, loadProviders]) + async function loadCustomEndpoints(): Promise { + try { + const endpoints = await window.api.endpoints.list() + setCustomEndpoints(endpoints) + } catch (e) { + console.error("Failed to load custom endpoints:", e) + } + } + // Use fallback providers if none loaded const displayProviders = providers.length > 0 ? providers : FALLBACK_PROVIDERS + // Separate built-in providers from custom endpoint providers + const builtInProviders = displayProviders.filter((p) => p.id !== "custom") + const customProviders = displayProviders.filter((p) => p.id === "custom") + // Determine effective provider ID (manual selection > current model > default) const effectiveProviderId = selectedProviderId || (currentModel ? models.find((m) => m.id === currentModel)?.provider : null) || - (displayProviders.length > 0 ? displayProviders[0].id : null) + (builtInProviders.length > 0 ? builtInProviders[0].id : null) + + // For custom endpoints, also track the endpoint ID + const effectiveEndpointId = + selectedEndpointId || + (currentModel?.startsWith("custom:") ? currentModel.split(":")[1] : null) || + null const selectedModel = models.find((m) => m.id === currentModel) - const filteredModels = effectiveProviderId - ? models.filter((m) => m.provider === effectiveProviderId) - : [] - const selectedProvider = displayProviders.find((p) => p.id === effectiveProviderId) + + // Filter models based on provider and endpoint + const filteredModels = (() => { + if (effectiveProviderId === "custom" && effectiveEndpointId) { + return models.filter((m) => m.provider === "custom" && m.endpointId === effectiveEndpointId) + } + if (effectiveProviderId) { + return models.filter((m) => m.provider === effectiveProviderId && m.provider !== "custom") + } + return [] + })() + + const selectedProvider = builtInProviders.find((p) => p.id === effectiveProviderId) + const selectedCustomEndpoint = + effectiveProviderId === "custom" && effectiveEndpointId + ? customEndpoints.find((e) => e.id === effectiveEndpointId) + : null function handleProviderClick(provider: Provider): void { setSelectedProviderId(provider.id) + setSelectedEndpointId(null) + } + + function handleCustomEndpointClick(endpoint: CustomEndpoint): void { + setSelectedProviderId("custom") + setSelectedEndpointId(endpoint.id) } function handleModelSelect(modelId: string): void { @@ -104,6 +162,26 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme } } + function handleAddEndpoint(): void { + setEditingEndpoint(null) + setEndpointDialogOpen(true) + } + + function handleEditEndpoint(endpoint: CustomEndpoint): void { + setEditingEndpoint(endpoint) + setEndpointDialogOpen(true) + } + + function handleEndpointDialogClose(isOpen: boolean): void { + setEndpointDialogOpen(isOpen) + if (!isOpen) { + // Refresh everything after dialog closes + loadProviders() + loadModels() + loadCustomEndpoints() + } + } + return ( <> @@ -131,12 +209,13 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme >
{/* Provider column */} -
+
Provider
-
- {displayProviders.map((provider) => { +
+ {/* Built-in providers */} + {builtInProviders.map((provider) => { const Icon = PROVIDER_ICONS[provider.id] return ( ) })} + + {/* Custom endpoints section */} + {customEndpoints.length > 0 && ( + <> +
+ Custom +
+ {customEndpoints.map((endpoint) => { + const matchingProvider = customProviders.find( + (p) => p.endpointId === endpoint.id + ) + const isSelected = + effectiveProviderId === "custom" && effectiveEndpointId === endpoint.id + + return ( +
+ + +
+ ) + })} + + )}
+ + {/* Add endpoint button */} +
{/* Models column */} @@ -166,8 +297,21 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme Model
- {selectedProvider && !selectedProvider.hasApiKey ? ( - // No API key configured + {/* Custom endpoint with no models discovered yet */} + {effectiveProviderId === "custom" && + selectedCustomEndpoint && + filteredModels.length === 0 ? ( +
+ +

+ No models discovered for {selectedCustomEndpoint.name} +

+ +
+ ) : selectedProvider && !selectedProvider.hasApiKey ? ( + // No API key configured for built-in provider

@@ -192,7 +336,10 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme : "text-muted-foreground hover:text-foreground hover:bg-muted/50" )} > - {model.id} + + {/* For custom models, show just the model name without the prefix */} + {model.provider === "custom" ? model.model : model.id} + {currentModel === model.id && ( )} @@ -204,8 +351,8 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme )}

- {/* Configure API key link for providers that have a key */} - {selectedProvider?.hasApiKey && ( + {/* Configure API key link for built-in providers that have a key */} + {selectedProvider?.hasApiKey && effectiveProviderId !== "custom" && ( )} + + {/* Configure endpoint link for custom endpoints */} + {effectiveProviderId === "custom" && selectedCustomEndpoint && ( + + )}
)}
@@ -226,6 +384,17 @@ export function ModelSwitcher({ threadId }: ModelSwitcherProps): React.JSX.Eleme onOpenChange={handleApiKeyDialogClose} provider={apiKeyProvider} /> + + { + loadProviders() + loadModels() + loadCustomEndpoints() + }} + /> ) } diff --git a/src/renderer/src/lib/store.ts b/src/renderer/src/lib/store.ts index b0ebc8419..83d99ccf8 100644 --- a/src/renderer/src/lib/store.ts +++ b/src/renderer/src/lib/store.ts @@ -1,5 +1,5 @@ import { create } from "zustand" -import type { Thread, ModelConfig, Provider } from "@/types" +import type { Thread, ModelConfig, Provider, CustomEndpoint, CreateEndpointParams } from "@/types" interface AppState { // Threads @@ -10,6 +10,9 @@ interface AppState { models: ModelConfig[] providers: Provider[] + // Custom endpoints + customEndpoints: CustomEndpoint[] + // Right panel state (UI state, not thread data) rightPanelTab: "todos" | "files" | "subagents" @@ -33,6 +36,12 @@ interface AppState { setApiKey: (providerId: string, apiKey: string) => Promise deleteApiKey: (providerId: string) => Promise + // Custom endpoint actions + loadEndpoints: () => Promise + createEndpoint: (params: CreateEndpointParams) => Promise + deleteEndpoint: (id: string) => Promise + discoverEndpointModels: (id: string) => Promise + // Panel actions setRightPanelTab: (tab: "todos" | "files" | "subagents") => void @@ -50,6 +59,7 @@ export const useAppStore = create((set, get) => ({ currentThreadId: null, models: [], providers: [], + customEndpoints: [], rightPanelTab: "todos", settingsOpen: false, sidebarCollapsed: false, @@ -151,6 +161,37 @@ export const useAppStore = create((set, get) => ({ await get().loadModels() }, + // Custom endpoint actions + loadEndpoints: async () => { + const customEndpoints = await window.api.endpoints.list() + set({ customEndpoints }) + }, + + createEndpoint: async (params: CreateEndpointParams) => { + const endpoint = await window.api.endpoints.create(params) + // Reload endpoints, providers, and models + await get().loadEndpoints() + await get().loadProviders() + await get().loadModels() + return endpoint + }, + + deleteEndpoint: async (id: string) => { + await window.api.endpoints.delete(id) + // Reload endpoints, providers, and models + await get().loadEndpoints() + await get().loadProviders() + await get().loadModels() + }, + + discoverEndpointModels: async (id: string) => { + const models = await window.api.endpoints.discoverModels(id) + // Reload endpoints and models to reflect discovered models + await get().loadEndpoints() + await get().loadModels() + return models + }, + // Panel actions setRightPanelTab: (tab: "todos" | "files" | "subagents") => { set({ rightPanelTab: tab }) diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 08033b415..97d0567ff 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -24,12 +24,14 @@ export interface Run { } // Provider configuration -export type ProviderId = "anthropic" | "openai" | "google" | "ollama" +export type ProviderId = "anthropic" | "openai" | "google" | "ollama" | "custom" export interface Provider { id: ProviderId name: string hasApiKey: boolean + // For custom endpoints, reference the endpoint ID + endpointId?: string } export interface ModelConfig { @@ -39,6 +41,29 @@ export interface ModelConfig { model: string description?: string available: boolean + // For custom endpoint models, reference the endpoint ID + endpointId?: string +} + +// Custom OpenAI-compatible endpoint configuration +export interface CustomEndpoint { + id: string // Unique ID (e.g., "azure-prod", "local-llm") + name: string // Display name + baseUrl: string // Base URL (e.g., "https://api.openai.com/v1") + models?: string[] // Discovered models (cached) +} + +// IPC params for custom endpoints +export interface CreateEndpointParams { + id: string + name: string + baseUrl: string + apiKey: string +} + +export interface UpdateEndpointParams { + id: string + updates: Partial> & { apiKey?: string } } // Subagent types (from deepagentsjs)