diff --git a/src/cliproxy/config-generator.ts b/src/cliproxy/config-generator.ts index c2d4b83d..5a73a2e4 100644 --- a/src/cliproxy/config-generator.ts +++ b/src/cliproxy/config-generator.ts @@ -23,6 +23,42 @@ interface ProviderSettings { env: NodeJS.ProcessEnv; } +/** + * Validate port is a valid positive integer (1-65535). + * Returns default port if invalid. + */ +function validatePort(port: number): number { + if (!Number.isFinite(port) || port < 1 || port > 65535 || !Number.isInteger(port)) { + return CLIPROXY_DEFAULT_PORT; + } + return port; +} + +/** + * Ensure required CLIProxy env vars are present. + * Falls back to bundled defaults if missing from user settings. + * This prevents 404 errors when users forget to set BASE_URL/AUTH_TOKEN. + */ +function ensureRequiredEnvVars( + envVars: NodeJS.ProcessEnv, + provider: CLIProxyProvider, + port: number +): NodeJS.ProcessEnv { + const validPort = validatePort(port); + const result = { ...envVars }; + const defaults = getClaudeEnvVars(provider, validPort); + + // Fill in missing required vars from defaults + if (!result.ANTHROPIC_BASE_URL?.trim()) { + result.ANTHROPIC_BASE_URL = defaults.ANTHROPIC_BASE_URL; + } + if (!result.ANTHROPIC_AUTH_TOKEN?.trim()) { + result.ANTHROPIC_AUTH_TOKEN = defaults.ANTHROPIC_AUTH_TOKEN; + } + + return result; +} + /** Default CLIProxy port */ export const CLIPROXY_DEFAULT_PORT = 8317; @@ -563,6 +599,8 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // Custom variant settings found - merge with global env envVars = { ...globalEnv, ...settings.env }; + // Ensure required vars are present (fall back to defaults if missing) + envVars = ensureRequiredEnvVars(envVars, provider, port); // Apply remote rewrite if configured if (remoteRewriteConfig) { envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); @@ -590,6 +628,8 @@ export function getEffectiveEnvVars( if (settings.env && typeof settings.env === 'object') { // User override found - merge with global env envVars = { ...globalEnv, ...settings.env }; + // Ensure required vars are present (fall back to defaults if missing) + envVars = ensureRequiredEnvVars(envVars, provider, port); // Apply remote rewrite if configured if (remoteRewriteConfig) { envVars = rewriteLocalhostUrls(envVars, provider, remoteRewriteConfig); diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index b7943bea..37a895dc 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -88,6 +88,16 @@ router.put('/:name', (req: Request, res: Response): void => { return; } + // Validate required fields if provided (prevent setting to empty) + if (baseUrl !== undefined && !baseUrl.trim()) { + res.status(400).json({ error: 'baseUrl cannot be empty' }); + return; + } + if (apiKey !== undefined && !apiKey.trim()) { + res.status(400).json({ error: 'apiKey cannot be empty' }); + return; + } + try { updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); res.json({ name, updated: true }); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 12e175aa..57b61aad 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -113,6 +113,15 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { } }); +/** Required env vars for CLIProxy providers to function */ +const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; + +/** Check if settings have required fields (returns missing list for warnings) */ +function checkRequiredEnvVars(settings: Settings): string[] { + const env = settings?.env || {}; + return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); +} + /** * PUT /api/settings/:profile - Update settings with conflict detection and backup */ @@ -120,7 +129,17 @@ router.put('/:profile', (req: Request, res: Response): void => { try { const { profile } = req.params; const { settings, expectedMtime } = req.body; + + // Validate settings object exists + if (!settings || typeof settings !== 'object') { + res.status(400).json({ error: 'settings object is required in request body' }); + return; + } + const ccsDir = getCcsDir(); + + // Check for missing required fields (warning, not blocking - runtime fills defaults) + const missingFields = checkRequiredEnvVars(settings); const settingsPath = resolveSettingsPath(profile); const fileExists = fs.existsSync(settingsPath); @@ -165,6 +184,11 @@ router.put('/:profile', (req: Request, res: Response): void => { mtime: newStat.mtime.getTime(), backupPath, created: !fileExists, + // Include warning if fields missing (runtime will use defaults) + ...(missingFields.length > 0 && { + warning: `Missing fields will use defaults: ${missingFields.join(', ')}`, + missingFields, + }), }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 8c642206..0feee3d7 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -63,8 +63,14 @@ router.post('/', (req: Request, res: Response): void => { return; } + // Require model for variant creation (prevents empty model causing issues) + if (!model || !model.trim()) { + res.status(400).json({ error: 'Missing required field: model' }); + return; + } + // Use variant-service for proper port allocation - const result = createVariant(name, provider as CLIProxyProvider, model || '', account); + const result = createVariant(name, provider as CLIProxyProvider, model, account); if (!result.success) { res.status(409).json({ error: result.error }); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 9d83e9c9..c8276ace 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -1,7 +1,7 @@ /** * Add Account Dialog Component * Triggers OAuth flow server-side to add another account to a provider - * Applies default preset when adding first account + * Always applies default preset to ensure required env vars are set * For Kiro: Also shows "Import from IDE" option as fallback */ @@ -26,7 +26,7 @@ interface AddAccountDialogProps { onClose: () => void; provider: string; displayName: string; - /** Whether this is the first account being added (triggers preset application) */ + /** Whether this is the first account being added (shows different toast message) */ isFirstAccount?: boolean; } @@ -49,14 +49,17 @@ export function AddAccountDialog({ { provider, nickname: nickname.trim() || undefined }, { onSuccess: async () => { - // Apply default preset if this is the first account - if (isFirstAccount) { - const result = await applyDefaultPreset(provider); - if (result.success && result.presetName) { + // Always apply default preset to ensure BASE_URL and AUTH_TOKEN are set + const result = await applyDefaultPreset(provider); + if (result.success && result.presetName) { + if (isFirstAccount) { toast.success(`Applied "${result.presetName}" preset`); - } else if (!result.success) { - toast.warning('Account added, but failed to apply default preset'); } + // Silent success for non-first accounts - preset ensures required vars exist + } else if (!result.success) { + toast.warning( + 'Account added, but failed to apply default preset. You may need to configure settings manually.' + ); } setNickname(''); onClose(); @@ -68,12 +71,10 @@ export function AddAccountDialog({ const handleKiroImport = () => { kiroImportMutation.mutate(undefined, { onSuccess: async () => { - // Apply default preset if this is the first account - if (isFirstAccount) { - const result = await applyDefaultPreset('kiro'); - if (result.success && result.presetName) { - toast.success(`Applied "${result.presetName}" preset`); - } + // Always apply default preset for Kiro as well + const result = await applyDefaultPreset('kiro'); + if (result.success && result.presetName && isFirstAccount) { + toast.success(`Applied "${result.presetName}" preset`); } setNickname(''); onClose(); diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 8cd38baf..17aa3e7d 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -88,6 +88,7 @@ export function ProviderEditor({ saveMutation, conflictDialog, handleConflictResolve, + missingRequiredFields, } = useProviderEditor(provider); const accounts = authStatus.accounts || []; @@ -216,6 +217,7 @@ export function ProviderEditor({ rawJsonEdits={rawJsonEdits} onRawJsonChange={handleRawJsonChange} profileEnv={data?.settings?.env} + missingRequiredFields={missingRequiredFields} /> diff --git a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx index 4150fd43..bae26325 100644 --- a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx @@ -4,7 +4,7 @@ */ import { lazy, Suspense } from 'react'; -import { Loader2, X } from 'lucide-react'; +import { Loader2, X, AlertTriangle } from 'lucide-react'; import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; import type { RawEditorSectionProps } from './types'; @@ -19,7 +19,10 @@ export function RawEditorSection({ rawJsonEdits, onRawJsonChange, profileEnv, + missingRequiredFields = [], }: RawEditorSectionProps) { + const hasMissingFields = missingRequiredFields.length > 0; + return ( )} + {isRawJsonValid && hasMissingFields && ( +
+ +
+ + Missing required fields: + {' '} + + {missingRequiredFields.join(', ')} + +

+ Apply a preset from Model Config tab or add these fields manually. Without them, + Claude Code will fail with 404 errors. +

+
+
+ )}
void; profileEnv?: Record; + /** List of required env vars that are missing (empty if all present) */ + missingRequiredFields?: string[]; } export interface ModelConfigSectionProps { @@ -113,4 +115,6 @@ export interface UseProviderEditorReturn { conflictDialog: boolean; setConflictDialog: (open: boolean) => void; handleConflictResolve: (overwrite: boolean) => Promise; + /** List of required env vars that are missing (empty if all present) */ + missingRequiredFields: string[]; } diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 7968a5b2..5476911c 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -8,6 +8,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { SettingsResponse, UseProviderEditorReturn } from './types'; +/** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */ +const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; + +/** Check settings for missing fields (for UI warnings) */ +function checkMissingFields(settings: { env?: Record }): string[] { + const env = settings?.env || {}; + return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); +} + export function useProviderEditor(provider: string): UseProviderEditorReturn { const [rawJsonEdits, setRawJsonEdits] = useState(null); const [conflictDialog, setConflictDialog] = useState(false); @@ -95,10 +104,14 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { return rawJsonEdits !== JSON.stringify(settings, null, 2); }, [rawJsonEdits, settings]); - // Save mutation + // Validation state for missing required fields (informational warning) + const missingFields = useMemo(() => checkMissingFields(currentSettings), [currentSettings]); + + // Save mutation (no blocking validation - runtime uses defaults) const saveMutation = useMutation({ mutationFn: async () => { const settingsToSave = JSON.parse(rawJsonContent); + const res = await fetch(`/api/settings/${provider}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, @@ -112,10 +125,17 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { if (!res.ok) throw new Error('Failed to save'); return res.json(); }, - onSuccess: () => { + onSuccess: (responseData) => { queryClient.invalidateQueries({ queryKey: ['settings', provider] }); setRawJsonEdits(null); - toast.success('Settings saved'); + // Show warning if fields missing (runtime uses defaults) + if (responseData?.warning) { + toast.success('Settings saved', { + description: responseData.warning, + }); + } else { + toast.success('Settings saved'); + } }, onError: (error: Error) => { if (error.message === 'CONFLICT') { @@ -159,5 +179,7 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { conflictDialog, setConflictDialog, handleConflictResolve, + // Validation (informational) + missingRequiredFields: missingFields, }; } diff --git a/ui/src/components/copilot/config-form/index.tsx b/ui/src/components/copilot/config-form/index.tsx index ec95e3c3..059477ed 100644 --- a/ui/src/components/copilot/config-form/index.tsx +++ b/ui/src/components/copilot/config-form/index.tsx @@ -50,6 +50,7 @@ export function CopilotConfigForm() { handleSave, handleConflictResolve, refetchRawSettings, + missingRequiredFields, } = useCopilotConfigForm(); if (configLoading || rawSettingsLoading) { @@ -146,6 +147,7 @@ export function CopilotConfigForm() { rawJsonEdits={rawJsonEdits} rawSettingsEnv={rawSettings?.settings?.env as Record | undefined} onChange={handleRawJsonChange} + missingRequiredFields={missingRequiredFields} />
diff --git a/ui/src/components/copilot/config-form/raw-editor-section.tsx b/ui/src/components/copilot/config-form/raw-editor-section.tsx index 8c0694d6..6a13c9e1 100644 --- a/ui/src/components/copilot/config-form/raw-editor-section.tsx +++ b/ui/src/components/copilot/config-form/raw-editor-section.tsx @@ -4,7 +4,7 @@ */ import { Suspense, lazy } from 'react'; -import { Loader2, X } from 'lucide-react'; +import { Loader2, X, AlertTriangle } from 'lucide-react'; import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; // Lazy load CodeEditor @@ -18,6 +18,7 @@ interface RawEditorSectionProps { rawJsonEdits: string | null; rawSettingsEnv: Record | undefined; onChange: (value: string) => void; + missingRequiredFields?: string[]; } export function RawEditorSection({ @@ -26,7 +27,10 @@ export function RawEditorSection({ rawJsonEdits, rawSettingsEnv, onChange, + missingRequiredFields = [], }: RawEditorSectionProps) { + const hasMissingFields = missingRequiredFields.length > 0; + return ( )} + {isRawJsonValid && hasMissingFields && ( +
+ +
+ + Missing required fields: + {' '} + + {missingRequiredFields.join(', ')} + +

+ These fields will use default values at runtime. +

+
+
+ )}
} | undefined): string[] { + const env = settings?.env || {}; + return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); +} + export function useCopilotConfigForm() { const { config, @@ -101,6 +110,23 @@ export function useCopilotConfigForm() { return hasLocalChanges || hasJsonChanges; }, [localOverrides, rawJsonEdits, rawSettings]); + // Validation state for missing required fields (informational warning) + const currentSettingsForValidation = useMemo(() => { + if (rawJsonEdits !== null) { + try { + return JSON.parse(rawJsonEdits); + } catch { + return rawSettings?.settings; + } + } + return rawSettings?.settings; + }, [rawJsonEdits, rawSettings?.settings]); + + const missingFields = useMemo( + () => checkMissingFields(currentSettingsForValidation), + [currentSettingsForValidation] + ); + const handleSave = async () => { try { // Save config changes @@ -119,19 +145,31 @@ export function useCopilotConfigForm() { }); } - // Save raw JSON changes + // Save raw JSON changes (no blocking validation - runtime uses defaults) if (rawJsonEdits !== null && isRawJsonValid) { const settingsToSave = JSON.parse(rawJsonContent); + const missing = checkMissingFields(settingsToSave); + await saveRawSettingsAsync({ settings: settingsToSave, expectedMtime: rawSettings?.mtime, }); + + // Show warning if fields missing + if (missing.length > 0) { + toast.success('Copilot configuration saved', { + description: `Missing fields will use defaults: ${missing.join(', ')}`, + }); + } else { + toast.success('Copilot configuration saved'); + } + } else { + toast.success('Copilot configuration saved'); } // Clear local state setLocalOverrides({}); setRawJsonEdits(null); - toast.success('Copilot configuration saved'); } catch (error) { if ((error as Error).message === 'CONFLICT') { setConflictDialog(true); @@ -189,5 +227,8 @@ export function useCopilotConfigForm() { handleSave, handleConflictResolve, refetchRawSettings, + + /** List of required env vars that are missing (empty if all present) - informational */ + missingRequiredFields: missingFields, }; } diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index da5f98c2..6dc2f2c5 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -100,6 +100,13 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro return Object.keys(localEdits).length > 0; }, [rawJsonEdits, localEdits, settings]); + // Check for missing required fields (informational warning) + const missingRequiredFields = useMemo(() => { + const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; + const env = currentSettings?.env || {}; + return REQUIRED_ENV_KEYS.filter((key) => !env[key]?.trim()); + }, [currentSettings]); + // Notify parent of hasChanges state useEffect(() => { onHasChangesUpdate?.(computedHasChanges); @@ -208,6 +215,7 @@ export function ProfileEditor({ profileName, onDelete, onHasChangesUpdate }: Pro rawJsonEdits={rawJsonEdits} settings={settings} onChange={handleRawJsonChange} + missingRequiredFields={missingRequiredFields} />
diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index 318a3610..d4bbe4ac 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -4,7 +4,7 @@ */ import { Suspense, lazy } from 'react'; -import { Loader2, X } from 'lucide-react'; +import { Loader2, X, AlertTriangle } from 'lucide-react'; import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator'; import type { Settings } from './types'; @@ -19,6 +19,7 @@ interface RawEditorSectionProps { rawJsonEdits: string | null; settings: Settings | undefined; onChange: (value: string) => void; + missingRequiredFields?: string[]; } export function RawEditorSection({ @@ -27,7 +28,10 @@ export function RawEditorSection({ rawJsonEdits, settings, onChange, + missingRequiredFields = [], }: RawEditorSectionProps) { + const hasMissingFields = missingRequiredFields.length > 0; + return ( )} + {isRawJsonValid && hasMissingFields && ( +
+ +
+ + Missing required fields: + {' '} + + {missingRequiredFields.join(', ')} + +

+ These fields will use default values at runtime. +

+
+
+ )}
!env[key]?.trim()); +} + interface UseProfileEditorOptions { profileName: string; localEdits: Record; @@ -78,6 +87,9 @@ export function useProfileEditor({ return Object.keys(localEdits).length > 0; }, [rawJsonEdits, localEdits, query.data?.settings]); + // Validation state for missing required fields (informational warning) + const missingFields = useMemo(() => checkMissingFields(currentSettings), [currentSettings]); + // Save mutation const saveMutation = useMutation({ mutationFn: async () => { @@ -114,11 +126,18 @@ export function useProfileEditor({ return res.json(); }, - onSuccess: () => { + onSuccess: (data) => { queryClient.invalidateQueries({ queryKey: ['settings', profileName] }); queryClient.invalidateQueries({ queryKey: ['profiles'] }); onSuccess(); - toast.success('Settings saved'); + // Show warning if fields missing (runtime uses defaults) + if (data?.warning) { + toast.success('Settings saved', { + description: data.warning, + }); + } else { + toast.success('Settings saved'); + } }, onError: (error: Error) => { if (error.message === 'CONFLICT') { @@ -135,5 +154,7 @@ export function useProfileEditor({ isRawJsonValid, hasChanges, saveMutation, + /** List of required env vars that are missing (empty if all present) - informational */ + missingRequiredFields: missingFields, }; }