diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9ba53ed86..294d7a65a 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -206,7 +206,7 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/worktree', createWorktreeRoutes()); app.use('/api/git', createGitRoutes()); -app.use('/api/setup', createSetupRoutes()); +app.use('/api/setup', createSetupRoutes(settingsService)); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 50e378beb..7c6363ba9 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -22,6 +22,7 @@ import type { // Only these vars are passed - nothing else from process.env leaks through. const ALLOWED_ENV_VARS = [ 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', 'PATH', 'HOME', 'SHELL', diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index 6c9f42a2f..1bf092288 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -3,6 +3,7 @@ */ import { Router } from 'express'; +import type { SettingsService } from '../../services/settings-service.js'; import { createClaudeStatusHandler } from './routes/claude-status.js'; import { createInstallClaudeHandler } from './routes/install-claude.js'; import { createAuthClaudeHandler } from './routes/auth-claude.js'; @@ -24,13 +25,13 @@ import { createGetExampleConfigHandler, } from './routes/cursor-config.js'; -export function createSetupRoutes(): Router { +export function createSetupRoutes(settingsService: SettingsService): Router { const router = Router(); router.get('/claude-status', createClaudeStatusHandler()); router.post('/install-claude', createInstallClaudeHandler()); router.post('/auth-claude', createAuthClaudeHandler()); - router.post('/store-api-key', createStoreApiKeyHandler()); + router.post('/store-api-key', createStoreApiKeyHandler(settingsService)); router.post('/delete-api-key', createDeleteApiKeyHandler()); router.get('/api-keys', createApiKeysHandler()); router.get('/platform', createPlatformHandler()); diff --git a/apps/server/src/routes/setup/routes/store-api-key.ts b/apps/server/src/routes/setup/routes/store-api-key.ts index e77a697e8..4e0c67140 100644 --- a/apps/server/src/routes/setup/routes/store-api-key.ts +++ b/apps/server/src/routes/setup/routes/store-api-key.ts @@ -5,15 +5,17 @@ import type { Request, Response } from 'express'; import { setApiKey, persistApiKeyToEnv, getErrorMessage, logError } from '../common.js'; import { createLogger } from '@automaker/utils'; +import type { SettingsService } from '../../../services/settings-service.js'; const logger = createLogger('Setup'); -export function createStoreApiKeyHandler() { +export function createStoreApiKeyHandler(settingsService: SettingsService) { return async (req: Request, res: Response): Promise => { try { - const { provider, apiKey } = req.body as { + const { provider, apiKey, baseUrl } = req.body as { provider: string; apiKey: string; + baseUrl?: string; }; if (!provider || !apiKey) { @@ -29,6 +31,28 @@ export function createStoreApiKeyHandler() { process.env.ANTHROPIC_API_KEY = apiKey; await persistApiKeyToEnv('ANTHROPIC_API_KEY', apiKey); logger.info('[Setup] Stored API key as ANTHROPIC_API_KEY'); + + // Handle custom base URL if provided + if (baseUrl && baseUrl.trim()) { + process.env.ANTHROPIC_BASE_URL = baseUrl.trim(); + await persistApiKeyToEnv('ANTHROPIC_BASE_URL', baseUrl.trim()); + logger.info('[Setup] Stored custom base URL as ANTHROPIC_BASE_URL'); + } else if (process.env.ANTHROPIC_BASE_URL) { + // Clear existing base URL if not provided + delete process.env.ANTHROPIC_BASE_URL; + // Note: we don't remove from .env file as that's more complex + logger.info('[Setup] Cleared ANTHROPIC_BASE_URL from environment'); + } + + // Persist base URL to settings service + if (baseUrl !== undefined) { + await settingsService.updateCredentials({ + baseUrls: { + anthropic: baseUrl.trim() || undefined, + }, + }); + logger.info('[Setup] Persisted base URL to settings service'); + } } else { res.status(400).json({ success: false, diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index c202ff968..8a1736e55 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -10,6 +10,33 @@ import { getApiKey } from '../common.js'; const logger = createLogger('Setup'); +// Allowed environment variables to pass to the SDK +// Must be passed explicitly - SDK doesn't inherit from process.env +const ALLOWED_ENV_VARS = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_BASE_URL', + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', +]; + +/** + * Build environment for the SDK with only explicitly allowed variables + */ +function buildEnv(): Record { + const env: Record = {}; + for (const key of ALLOWED_ENV_VARS) { + if (process.env[key]) { + env[key] = process.env[key]; + } + } + return env; +} + // Known error patterns that indicate auth failure const AUTH_ERROR_PATTERNS = [ 'OAuth token revoked', @@ -91,13 +118,16 @@ export function createVerifyClaudeAuthHandler() { // Save original env values const originalAnthropicKey = process.env.ANTHROPIC_API_KEY; + const originalBaseUrl = process.env.ANTHROPIC_BASE_URL; try { // Configure environment based on auth method if (authMethod === 'cli') { // For CLI verification, remove any API key so it uses CLI credentials only delete process.env.ANTHROPIC_API_KEY; - logger.info('[Setup] Cleared API key environment for CLI verification'); + // CLI auth only works with standard Anthropic API, not custom endpoints + delete process.env.ANTHROPIC_BASE_URL; + logger.info('[Setup] Cleared API key and base URL for CLI verification'); } else if (authMethod === 'api_key') { // For API key verification, use provided key, stored key, or env var (in order of priority) if (apiKey) { @@ -128,6 +158,7 @@ export function createVerifyClaudeAuthHandler() { maxTurns: 1, allowedTools: [], abortController, + env: buildEnv(), }, }); @@ -285,6 +316,13 @@ export function createVerifyClaudeAuthHandler() { // If we cleared it and there was no original, keep it cleared delete process.env.ANTHROPIC_API_KEY; } + // Restore base URL + if (originalBaseUrl !== undefined) { + process.env.ANTHROPIC_BASE_URL = originalBaseUrl; + } else if (authMethod === 'cli') { + // If we cleared it and there was no original, keep it cleared + delete process.env.ANTHROPIC_BASE_URL; + } } logger.info('[Setup] Verification result:', { diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 94bdce249..72e0caefb 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -334,7 +334,7 @@ export class SettingsService { /** * Update credentials with partial changes * - * Updates individual API keys. Uses deep merge for apiKeys object. + * Updates individual API keys. Uses deep merge for apiKeys and baseUrls objects. * Creates dataDir if needed. Credentials are written atomically. * WARNING: Use only in secure contexts - keys are unencrypted. * @@ -360,6 +360,14 @@ export class SettingsService { }; } + // Deep merge base URLs if provided + if (updates.baseUrls) { + updated.baseUrls = { + ...current.baseUrls, + ...updates.baseUrls, + }; + } + await atomicWriteJson(credentialsPath, updated); logger.info('Credentials updated'); @@ -376,7 +384,7 @@ export class SettingsService { * @returns Promise resolving to masked credentials object with each provider's status */ async getMaskedCredentials(): Promise<{ - anthropic: { configured: boolean; masked: string }; + anthropic: { configured: boolean; masked: string; baseUrl?: string }; }> { const credentials = await this.getCredentials(); @@ -389,6 +397,7 @@ export class SettingsService { anthropic: { configured: !!credentials.apiKeys.anthropic, masked: maskKey(credentials.apiKeys.anthropic), + baseUrl: credentials.baseUrls?.anthropic, }, }; } diff --git a/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts b/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts index 8168f4454..b16c5b9fc 100644 --- a/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts +++ b/apps/ui/src/components/views/setup-view/hooks/use-token-save.ts @@ -14,7 +14,7 @@ export function useTokenSave({ provider, onSuccess }: UseTokenSaveOptions) { const [isSaving, setIsSaving] = useState(false); const saveToken = useCallback( - async (tokenValue: string) => { + async (tokenValue: string, baseUrl?: string) => { if (!tokenValue.trim()) { toast.error('Please enter a valid token'); return false; @@ -26,11 +26,11 @@ export function useTokenSave({ provider, onSuccess }: UseTokenSaveOptions) { const setupApi = api.setup; if (setupApi?.storeApiKey) { - const result = await setupApi.storeApiKey(provider, tokenValue); + const result = await setupApi.storeApiKey(provider, tokenValue, baseUrl); logger.info(`Store result for ${provider}:`, result); if (result.success) { - const tokenType = provider.includes('oauth') ? 'subscription token' : 'API key'; + const tokenType = provider.includes('oauth') ? 'subscription token' : 'configuration'; toast.success(`${tokenType} saved successfully`); onSuccess?.(); return true; diff --git a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx index 529cfc026..867701963 100644 --- a/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/claude-setup-step.tsx @@ -55,6 +55,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps const { setApiKeys, apiKeys } = useAppStore(); const [apiKey, setApiKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); // CLI Verification state const [cliVerificationStatus, setCliVerificationStatus] = useState('idle'); @@ -111,7 +112,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps apiKeyValid: true, }); setApiKeys({ ...apiKeys, anthropic: apiKey }); - toast.success('API key saved successfully!'); + toast.success('Anthropic configuration saved successfully!'); }, }); @@ -220,6 +221,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps if (result.success) { // Clear local state setApiKey(''); + setBaseUrl(''); setApiKeys({ ...apiKeys, anthropic: '' }); setApiKeyVerificationStatus('idle'); setApiKeyVerificationError(null); @@ -228,12 +230,13 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps method: 'none', hasCredentialsFile: claudeAuthStatus?.hasCredentialsFile || false, }); - toast.success('API key deleted successfully'); + toast.success('Anthropic configuration deleted successfully'); } else { - toast.error(result.error || 'Failed to delete API key'); + toast.error(result.error || 'Failed to delete configuration'); } } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key'; + const errorMessage = + error instanceof Error ? error.message : 'Failed to delete configuration'; toast.error(errorMessage); } finally { setIsDeletingApiKey(false); @@ -531,7 +534,7 @@ export function ClaudeSetupStep({ onNext, onBack, onSkip }: ClaudeSetupStepProps - {/* API Key Input */} + {/* API Key Configuration */}
+
+ + setBaseUrl(e.target.value)} + className="bg-input border-border text-foreground" + data-testid="anthropic-base-url-input" + /> +

+ Optional: Set a custom API base URL for enterprise deployments or custom + endpoints +

+
+
{hasApiKey && ( diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index d81b46b6e..95511cab7 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -649,7 +649,8 @@ export interface ElectronAPI { }>; storeApiKey: ( provider: string, - apiKey: string + apiKey: string, + baseUrl?: string ) => Promise<{ success: boolean; error?: string }>; deleteApiKey: ( provider: string @@ -1219,7 +1220,11 @@ interface SetupAPI { message?: string; output?: string; }>; - storeApiKey: (provider: string, apiKey: string) => Promise<{ success: boolean; error?: string }>; + storeApiKey: ( + provider: string, + apiKey: string, + baseUrl?: string + ) => Promise<{ success: boolean; error?: string }>; getApiKeys: () => Promise<{ success: boolean; hasAnthropicKey: boolean; @@ -1295,8 +1300,9 @@ function createMockSetupAPI(): SetupAPI { }; }, - storeApiKey: async (provider: string, apiKey: string) => { + storeApiKey: async (provider: string, apiKey: string, baseUrl?: string) => { console.log('[Mock] Storing API key for:', provider); + console.log('[Mock] Base URL:', baseUrl); // In mock mode, we just pretend to store it (it's already in the app store) return { success: true }; }, diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 9bf58d8e1..3c73e5cba 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1011,11 +1011,12 @@ export class HttpApiClient implements ElectronAPI { storeApiKey: ( provider: string, - apiKey: string + apiKey: string, + baseUrl?: string ): Promise<{ success: boolean; error?: string; - }> => this.post('/api/setup/store-api-key', { provider, apiKey }), + }> => this.post('/api/setup/store-api-key', { provider, apiKey, baseUrl }), deleteApiKey: ( provider: string diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 0220da3aa..c6d1b8952 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -506,6 +506,11 @@ export interface Credentials { /** OpenAI API key (for compatibility or alternative providers) */ openai: string; }; + /** Custom base URLs for API providers (optional) */ + baseUrls?: { + /** Custom Anthropic API base URL (sets ANTHROPIC_BASE_URL) */ + anthropic?: string; + }; } /** @@ -685,6 +690,9 @@ export const DEFAULT_CREDENTIALS: Credentials = { google: '', openai: '', }, + baseUrls: { + anthropic: undefined, + }, }; /** Default project settings (empty - all settings are optional and fall back to global) */