diff --git a/docs/PROXY_SETUP.md b/docs/PROXY_SETUP.md new file mode 100644 index 0000000..8054669 --- /dev/null +++ b/docs/PROXY_SETUP.md @@ -0,0 +1,81 @@ +# Proxy/Gateway Configuration + +OpenWork now supports custom base URLs for API providers, allowing you to route requests through internal gateways or proxy servers. + +## Configuration + +Base URLs are stored in the `~/.openwork/.env` file alongside API keys. + +### Setting Base URLs via Environment Variables + +You can configure custom base URLs by setting environment variables: + +```bash +# Anthropic custom base URL +ANTHROPIC_BASE_URL=https://your-proxy.example.com/anthropic + +# OpenAI custom base URL +OPENAI_BASE_URL=https://your-proxy.example.com/openai + +# Google custom base URL +GOOGLE_BASE_URL=https://your-proxy.example.com/google +``` + +### Setting Base URLs via API + +You can also configure base URLs programmatically through the IPC API: + +```typescript +// Set a custom base URL +await window.api.models.setBaseUrl('anthropic', 'https://your-proxy.example.com/anthropic') + +// Get the current base URL +const baseUrl = await window.api.models.getBaseUrl('anthropic') + +// Remove a custom base URL (revert to default) +await window.api.models.deleteBaseUrl('anthropic') +``` + +## How It Works + +When a base URL is configured for a provider: + +1. **Anthropic (Claude)**: Uses `clientOptions.baseURL` +2. **OpenAI (GPT)**: Uses `configuration.baseURL` +3. **Google (Gemini)**: Uses `baseUrl` configuration option + +The application will automatically use the custom base URL when making API calls, allowing you to: + +- Route requests through internal API gateways +- Use proxy servers for compliance/security +- Implement custom rate limiting or caching layers +- Test against local/staging endpoints + +## Example Use Case + +If you have an internal API gateway at `https://ai-gateway.company.com`: + +```bash +# In ~/.openwork/.env +ANTHROPIC_API_KEY=sk-ant-your-key +ANTHROPIC_BASE_URL=https://ai-gateway.company.com/anthropic + +OPENAI_API_KEY=sk-your-openai-key +OPENAI_BASE_URL=https://ai-gateway.company.com/openai +``` + +All API requests will now be routed through your gateway while still using the official provider SDKs. + +## Storage Location + +Base URLs and API keys are stored in: +- **Location**: `~/.openwork/.env` +- **Format**: Standard environment variable format +- **Security**: File-based storage (ensure proper file permissions) + +## Notes + +- Base URLs are optional - if not set, requests go directly to provider APIs +- Custom base URLs can be set per-provider independently +- Changes take effect immediately for new agent sessions +- The `.env` file is created automatically in the `.openwork` directory in your home folder diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 9d997dd..b61edf2 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ import { createDeepAgent } from "deepagents" import { getDefaultModel } from "../ipc/models" -import { getApiKey, getThreadCheckpointPath } from "../storage" +import { getApiKey, getThreadCheckpointPath, getBaseUrl } from "../storage" import { ChatAnthropic } from "@langchain/anthropic" import { ChatOpenAI } from "@langchain/openai" import { ChatGoogleGenerativeAI } from "@langchain/google-genai" @@ -60,7 +60,8 @@ export async function closeCheckpointer(threadId: string): Promise { // Get the appropriate model instance based on configuration function getModelInstance( - modelId?: string + modelId?: string, + customConfig?: { base_url?: string } ): ChatAnthropic | ChatOpenAI | ChatGoogleGenerativeAI | string { const model = modelId || getDefaultModel() console.log("[Runtime] Using model:", model) @@ -72,10 +73,22 @@ function getModelInstance( if (!apiKey) { throw new Error("Anthropic API key not configured") } - return new ChatAnthropic({ + + const config: any = { model, anthropicApiKey: apiKey - }) + } + + // Add base URL if provided in customConfig or from storage + const baseUrl = customConfig?.base_url || getBaseUrl("anthropic") + if (baseUrl) { + console.log("[Runtime] Using custom base URL for Anthropic:", baseUrl) + config.clientOptions = { + baseURL: baseUrl + } + } + + return new ChatAnthropic(config) } else if ( model.startsWith("gpt") || model.startsWith("o1") || @@ -87,20 +100,42 @@ function getModelInstance( if (!apiKey) { throw new Error("OpenAI API key not configured") } - return new ChatOpenAI({ + + const config: any = { model, openAIApiKey: apiKey - }) + } + + // Add base URL if provided in customConfig or from storage + const baseUrl = customConfig?.base_url || getBaseUrl("openai") + if (baseUrl) { + console.log("[Runtime] Using custom base URL for OpenAI:", baseUrl) + config.configuration = { + baseURL: baseUrl + } + } + + return new ChatOpenAI(config) } else if (model.startsWith("gemini")) { const apiKey = getApiKey("google") console.log("[Runtime] Google API key present:", !!apiKey) if (!apiKey) { throw new Error("Google API key not configured") } - return new ChatGoogleGenerativeAI({ + + const config: any = { model, apiKey: apiKey - }) + } + + // Add base URL if provided in customConfig or from storage + const baseUrl = customConfig?.base_url || getBaseUrl("google") + if (baseUrl) { + console.log("[Runtime] Using custom base URL for Google:", baseUrl) + config.baseUrl = baseUrl + } + + return new ChatGoogleGenerativeAI(config) } // Default to model string (let deepagents handle it) diff --git a/src/main/ipc/models.ts b/src/main/ipc/models.ts index e56866b..7d66756 100644 --- a/src/main/ipc/models.ts +++ b/src/main/ipc/models.ts @@ -6,12 +6,13 @@ import type { ModelConfig, Provider, SetApiKeyParams, + SetBaseUrlParams, WorkspaceSetParams, WorkspaceLoadParams, WorkspaceFileParams } from "../types" import { startWatching, stopWatching } from "../services/workspace-watcher" -import { getOpenworkDir, getApiKey, setApiKey, deleteApiKey, hasApiKey } from "../storage" +import { getOpenworkDir, getApiKey, setApiKey, deleteApiKey, hasApiKey, getBaseUrl, setBaseUrl, deleteBaseUrl } from "../storage" // Store for non-sensitive settings only (no encryption needed) const store = new Store({ @@ -239,6 +240,21 @@ export function registerModelHandlers(ipcMain: IpcMain): void { deleteApiKey(provider) }) + // Set base URL for a provider (stored in ~/.openwork/.env) + ipcMain.handle("models:setBaseUrl", async (_event, { provider, baseUrl }: SetBaseUrlParams) => { + setBaseUrl(provider, baseUrl) + }) + + // Get base URL for a provider (from ~/.openwork/.env or process.env) + ipcMain.handle("models:getBaseUrl", async (_event, provider: string) => { + return getBaseUrl(provider) ?? null + }) + + // Delete base URL for a provider + ipcMain.handle("models:deleteBaseUrl", async (_event, provider: string) => { + deleteBaseUrl(provider) + }) + // List providers with their API key status ipcMain.handle("models:listProviders", async () => { return PROVIDERS.map((provider) => ({ diff --git a/src/main/storage.ts b/src/main/storage.ts index d09686c..387fb9d 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -122,3 +122,40 @@ export function deleteApiKey(provider: string): void { export function hasApiKey(provider: string): boolean { return !!getApiKey(provider) } + +// Base URL management for proxy/gateway support +export function getBaseUrl(provider: string): string | undefined { + const envVarName = `${ENV_VAR_NAMES[provider]?.replace('_API_KEY', '')}_BASE_URL` + if (!envVarName) return undefined + + // Check .env file first + const env = parseEnvFile() + if (env[envVarName]) return env[envVarName] + + // Fall back to process environment + return process.env[envVarName] +} + +export function setBaseUrl(provider: string, baseUrl: string): void { + const envVarName = `${ENV_VAR_NAMES[provider]?.replace('_API_KEY', '')}_BASE_URL` + if (!envVarName) return + + const env = parseEnvFile() + env[envVarName] = baseUrl + writeEnvFile(env) + + // Also set in process.env for current session + process.env[envVarName] = baseUrl +} + +export function deleteBaseUrl(provider: string): void { + const envVarName = `${ENV_VAR_NAMES[provider]?.replace('_API_KEY', '')}_BASE_URL` + if (!envVarName) return + + const env = parseEnvFile() + delete env[envVarName] + writeEnvFile(env) + + // Also clear from process.env + delete process.env[envVarName] +} diff --git a/src/main/types.ts b/src/main/types.ts index e0ebab3..f26d15d 100644 --- a/src/main/types.ts +++ b/src/main/types.ts @@ -54,6 +54,11 @@ export interface SetApiKeyParams { apiKey: string } +export interface SetBaseUrlParams { + provider: string + baseUrl: string +} + // ============================================================================= export interface Thread { @@ -96,6 +101,8 @@ export interface ModelConfig { model: string description?: string available: boolean + base_url?: string // Custom base URL for proxy/gateway support + custom?: boolean // Flag to identify custom models } // Subagent types (from deepagentsjs) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 51e74c4..86681e5 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -52,6 +52,9 @@ interface CustomAPI { setDefault: (modelId: string) => Promise setApiKey: (provider: string, apiKey: string) => Promise getApiKey: (provider: string) => Promise + setBaseUrl: (provider: string, baseUrl: string) => Promise + getBaseUrl: (provider: string) => Promise + deleteBaseUrl: (provider: string) => Promise } workspace: { get: (threadId?: string) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index ffb2b36..17321e4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -148,6 +148,15 @@ const api = { }, deleteApiKey: (provider: string): Promise => { return ipcRenderer.invoke("models:deleteApiKey", provider) + }, + setBaseUrl: (provider: string, baseUrl: string): Promise => { + return ipcRenderer.invoke("models:setBaseUrl", { provider, baseUrl }) + }, + getBaseUrl: (provider: string): Promise => { + return ipcRenderer.invoke("models:getBaseUrl", provider) + }, + deleteBaseUrl: (provider: string): Promise => { + return ipcRenderer.invoke("models:deleteBaseUrl", provider) } }, workspace: { diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 08033b4..1c57528 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -39,6 +39,8 @@ export interface ModelConfig { model: string description?: string available: boolean + base_url?: string // Custom base URL for proxy/gateway support + custom?: boolean // Flag to identify custom models } // Subagent types (from deepagentsjs)