UNPKG

@tehreet/conduit

Version:

LLM API gateway with intelligent routing, robust process management, and health monitoring

1,530 lines (1,320 loc) 46.7 kB
# Synapse-Conduit Integration Plan ## Overview This document outlines the careful, phased approach to integrating Conduit into Synapse with comprehensive testing at each step. ## Integration Principles 1. **Test-Driven Development**: Write tests before implementation 2. **Incremental Changes**: Small, reviewable commits 3. **Backward Compatibility**: Ensure existing functionality remains intact 4. **Observability**: Sentry instrumentation at every critical point 5. **Configuration**: All features configurable via UI 6. **Data Safety**: Export/import utilities for rollback ## Phase 1: Foundation (Week 1) ### 1.1 Setup & Dependencies ```bash # In Synapse directory npm install --save @tehreet/conduit@file:../conduit npm install --save-dev @testing-library/react vitest @vitest/ui ``` ### 1.2 Create Core Service Structure ``` synapse/ ├── src/ │ ├── main/ │ │ ├── services/ │ │ │ ├── conduit/ │ │ │ │ ├── conduit-service.ts │ │ │ │ ├── conduit-service.test.ts │ │ │ │ ├── metadata-handler.ts │ │ │ │ ├── metadata-handler.test.ts │ │ │ │ └── index.ts │ │ │ └── ... │ │ └── ... │ ├── renderer/ │ │ ├── components/ │ │ │ ├── model-config/ │ │ │ │ ├── ModelConfiguration.tsx │ │ │ │ ├── ModelConfiguration.test.tsx │ │ │ │ ├── RoutingRulesEditor.tsx │ │ │ │ ├── RoutingRulesEditor.test.tsx │ │ │ │ └── index.ts │ │ │ ├── usage-analytics/ │ │ │ │ ├── UsageAnalytics.tsx │ │ │ │ ├── UsageAnalytics.test.tsx │ │ │ │ ├── CostBreakdown.tsx │ │ │ │ ├── ModelUsageChart.tsx │ │ │ │ └── index.ts │ │ │ └── ... │ │ └── ... │ └── shared/ │ ├── types/ │ │ ├── conduit.ts │ │ └── ... │ └── ... └── supabase/ └── migrations/ ├── 20250115_conduit_integration_base.sql ├── 20250115_usage_tracking_tables.sql └── 20250115_routing_rules_tables.sql ``` ### 1.3 Database Schema Design #### Projects Table Extension ```sql -- Model configuration with validation ALTER TABLE projects ADD COLUMN conduit_config JSONB DEFAULT '{ "enabled": false, "defaultModel": "claude-3-5-sonnet-20241022", "routingRules": [], "costLimits": { "daily": null, "monthly": null }, "features": { "autoRouting": true, "costTracking": true, "usageAnalytics": true } }'::jsonb CHECK ( conduit_config ? 'enabled' AND conduit_config ? 'defaultModel' AND conduit_config ? 'features' ); ``` #### Usage Tracking with Partitioning ```sql -- Partitioned table for scalability CREATE TABLE usage_tracking ( id UUID DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, agent_id UUID REFERENCES agents(id) ON DELETE SET NULL, session_id UUID, model TEXT NOT NULL, input_tokens INTEGER NOT NULL CHECK (input_tokens >= 0), output_tokens INTEGER NOT NULL CHECK (output_tokens >= 0), total_tokens INTEGER GENERATED ALWAYS AS (input_tokens + output_tokens) STORED, cost DECIMAL(10, 6) NOT NULL CHECK (cost >= 0), cost_currency TEXT DEFAULT 'USD', routing_source TEXT NOT NULL, routing_reason TEXT, latency_ms INTEGER, metadata JSONB DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (id, created_at) ) PARTITION BY RANGE (created_at); -- Create monthly partitions CREATE TABLE usage_tracking_2025_01 PARTITION OF usage_tracking FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); ``` #### Custom Routing Rules ```sql CREATE TABLE routing_rules ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, description TEXT, enabled BOOLEAN DEFAULT true, priority INTEGER NOT NULL DEFAULT 0, conditions JSONB NOT NULL, target_model TEXT NOT NULL, created_by UUID REFERENCES auth.users(id), created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), UNIQUE(project_id, name) ); -- Example conditions structure: -- { -- "type": "AND", -- "rules": [ -- {"field": "tokenCount", "operator": ">", "value": 50000}, -- {"field": "agentType", "operator": "equals", "value": "research"} -- ] -- } ``` ## Phase 2: Core Implementation (Week 1-2) ### 2.1 ConduitService Implementation ```typescript // src/main/services/conduit/conduit-service.ts import { EventEmitter } from 'events'; import { ChildProcess, spawn } from 'child_process'; import * as Sentry from '@sentry/electron/main'; import { app } from 'electron'; import path from 'path'; import fs from 'fs'; export interface ConduitConfig { enabled: boolean; defaultModel: string; routingRules: RoutingRule[]; costLimits: { daily: number | null; monthly: number | null; }; features: { autoRouting: boolean; costTracking: boolean; usageAnalytics: boolean; }; } export interface RoutingRule { id: string; name: string; enabled: boolean; priority: number; conditions: RuleCondition; targetModel: string; } export interface RuleCondition { type: 'AND' | 'OR'; rules: Array<{ field: string; operator: '>' | '<' | '>=' | '<=' | '=' | '!=' | 'contains' | 'matches'; value: any; }>; } export class ConduitService extends EventEmitter { private static instance: ConduitService; private conduitBinary: string; private serverProcess: ChildProcess | null = null; private healthCheckTimer: NodeJS.Timer | null = null; private constructor( private sentryService: typeof Sentry, private supabaseService: any, private logger: any ) { super(); this.setupConduit(); } static getInstance(sentryService: any, supabaseService: any, logger: any): ConduitService { if (!ConduitService.instance) { ConduitService.instance = new ConduitService(sentryService, supabaseService, logger); } return ConduitService.instance; } private async setupConduit(): Promise<void> { const transaction = this.sentryService.startTransaction({ op: 'conduit.setup', name: 'Conduit Service Setup' }); try { // Find or install conduit binary this.conduitBinary = await this.ensureConduitInstalled(); // Start health monitoring this.startHealthMonitoring(); transaction.setStatus('ok'); } catch (error) { transaction.setStatus('internal_error'); this.sentryService.captureException(error); throw error; } finally { transaction.finish(); } } private startHealthMonitoring(): void { this.healthCheckTimer = setInterval(async () => { try { const health = await this.checkHealth(); if (!health.healthy) { this.emit('unhealthy', health); await this.restart(); } } catch (error) { this.logger.error('Health check failed:', error); } }, 30000); // Every 30 seconds } async executeClaude( args: string[], projectId: string, agentConfig?: any, sessionId?: string ): Promise<ChildProcess> { const span = this.sentryService.getCurrentHub().getScope()?.getTransaction()?.startChild({ op: 'conduit.execute', description: 'Execute Claude through Conduit' }); try { // Get project configuration const projectConfig = await this.getProjectConfig(projectId); if (!projectConfig.enabled) { // Bypass Conduit if disabled return this.executeClaudeDirect(args); } // Check cost limits await this.checkCostLimits(projectId, projectConfig.costLimits); // Prepare environment const env = this.prepareEnvironment(projectId, projectConfig, agentConfig); // Apply custom routing rules const model = await this.applyRoutingRules( args, projectConfig.routingRules, { projectId, agentConfig, sessionId } ); if (model) { // Override model in args if routing rule matched this.overrideModelInArgs(args, model); } // Execute through Conduit const process = spawn(this.conduitBinary, args, { env }); // Setup monitoring this.monitorProcess(process, projectId, agentConfig?.id, sessionId); span?.setStatus('ok'); return process; } catch (error) { span?.setStatus('internal_error'); this.sentryService.captureException(error); throw error; } finally { span?.finish(); } } private async applyRoutingRules( args: string[], rules: RoutingRule[], context: any ): Promise<string | null> { // Sort by priority (higher first) const sortedRules = [...rules] .filter(r => r.enabled) .sort((a, b) => b.priority - a.priority); for (const rule of sortedRules) { if (await this.evaluateRule(rule, args, context)) { this.logger.info(`Routing rule '${rule.name}' matched, using model: ${rule.targetModel}`); return rule.targetModel; } } return null; } private async evaluateRule( rule: RoutingRule, args: string[], context: any ): Promise<boolean> { const evaluate = async (condition: RuleCondition): Promise<boolean> => { const results = await Promise.all( condition.rules.map(r => this.evaluateCondition(r, args, context)) ); return condition.type === 'AND' ? results.every(r => r) : results.some(r => r); }; return evaluate(rule.conditions); } // ... more implementation } ``` ### 2.2 Test Implementation ```typescript // src/main/services/conduit/conduit-service.test.ts import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ConduitService } from './conduit-service'; import { EventEmitter } from 'events'; import * as child_process from 'child_process'; vi.mock('child_process'); describe('ConduitService', () => { let service: ConduitService; let mockSentry: any; let mockSupabase: any; let mockLogger: any; beforeEach(() => { mockSentry = { startTransaction: vi.fn(() => ({ setStatus: vi.fn(), finish: vi.fn(), startChild: vi.fn(() => ({ setStatus: vi.fn(), finish: vi.fn() })) })), captureException: vi.fn(), getCurrentHub: vi.fn(() => ({ getScope: vi.fn(() => ({ getTransaction: vi.fn(() => ({ startChild: vi.fn() })) })) })) }; mockSupabase = { from: vi.fn(() => ({ select: vi.fn(() => ({ eq: vi.fn(() => ({ single: vi.fn(() => Promise.resolve({ data: { conduit_config: { enabled: true, defaultModel: 'claude-3-5-sonnet-20241022', routingRules: [], costLimits: { daily: null, monthly: null }, features: { autoRouting: true, costTracking: true, usageAnalytics: true } } } })) })) })) })) }; mockLogger = { info: vi.fn(), error: vi.fn(), debug: vi.fn() }; service = ConduitService.getInstance(mockSentry, mockSupabase, mockLogger); }); afterEach(() => { vi.clearAllMocks(); }); describe('executeClaude', () => { it('should execute Claude through Conduit when enabled', async () => { const mockProcess = new EventEmitter() as any; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); mockProcess.pid = 12345; vi.spyOn(child_process, 'spawn').mockReturnValue(mockProcess); const args = ['--message', 'Test message']; const projectId = 'test-project'; const result = await service.executeClaude(args, projectId); expect(result).toBe(mockProcess); expect(child_process.spawn).toHaveBeenCalledWith( expect.any(String), args, expect.objectContaining({ env: expect.objectContaining({ SYNAPSE_PROJECT_ID: projectId }) }) ); }); it('should bypass Conduit when disabled', async () => { mockSupabase.from = vi.fn(() => ({ select: vi.fn(() => ({ eq: vi.fn(() => ({ single: vi.fn(() => Promise.resolve({ data: { conduit_config: { enabled: false, defaultModel: 'claude-3-5-sonnet-20241022' } } })) })) })) })); const mockProcess = new EventEmitter() as any; vi.spyOn(service as any, 'executeClaudeDirect').mockResolvedValue(mockProcess); const result = await service.executeClaude(['--message', 'Test'], 'project-id'); expect(result).toBe(mockProcess); expect((service as any).executeClaudeDirect).toHaveBeenCalled(); }); it('should apply routing rules correctly', async () => { const rules = [{ id: 'rule-1', name: 'Large Context Rule', enabled: true, priority: 10, conditions: { type: 'AND' as const, rules: [{ field: 'tokenCount', operator: '>' as const, value: 50000 }] }, targetModel: 'claude-3-5-sonnet-20241022' }]; mockSupabase.from = vi.fn(() => ({ select: vi.fn(() => ({ eq: vi.fn(() => ({ single: vi.fn(() => Promise.resolve({ data: { conduit_config: { enabled: true, defaultModel: 'claude-3-5-haiku-20241022', routingRules: rules } } })) })) })) })); // Mock token count evaluation vi.spyOn(service as any, 'getTokenCount').mockResolvedValue(60000); const mockProcess = new EventEmitter() as any; vi.spyOn(child_process, 'spawn').mockReturnValue(mockProcess); await service.executeClaude(['--message', 'Long message'], 'project-id'); // Verify model was overridden expect(child_process.spawn).toHaveBeenCalledWith( expect.any(String), expect.arrayContaining(['--model', 'claude-3-5-sonnet-20241022']), expect.any(Object) ); }); it('should track usage when cost tracking is enabled', async () => { const mockProcess = new EventEmitter() as any; mockProcess.stdout = new EventEmitter(); mockProcess.stderr = new EventEmitter(); vi.spyOn(child_process, 'spawn').mockReturnValue(mockProcess); vi.spyOn(service, 'emit'); await service.executeClaude(['--message', 'Test'], 'project-id'); // Simulate Conduit metadata const metadata = { type: 'conduit_metadata', data: { routingDecision: { model: 'claude-3-5-sonnet-20241022', source: 'default', tokenCount: 150 }, usage: { inputTokens: 100, outputTokens: 50, cost: 0.0015 } } }; mockProcess.stdout.emit('data', JSON.stringify(metadata) + '\n'); expect(service.emit).toHaveBeenCalledWith('usage-tracked', expect.objectContaining({ projectId: 'project-id', model: 'claude-3-5-sonnet-20241022', inputTokens: 100, outputTokens: 50, cost: 0.0015 })); }); it('should enforce cost limits', async () => { mockSupabase.from = vi.fn(() => ({ select: vi.fn(() => ({ eq: vi.fn(() => ({ single: vi.fn(() => Promise.resolve({ data: { conduit_config: { enabled: true, costLimits: { daily: 10, monthly: 100 } } } })) })) })) })); // Mock current usage exceeding limit vi.spyOn(service as any, 'getCurrentUsage').mockResolvedValue({ daily: 11, monthly: 50 }); await expect( service.executeClaude(['--message', 'Test'], 'project-id') ).rejects.toThrow('Daily cost limit exceeded'); }); }); describe('routing rules evaluation', () => { it('should evaluate complex AND conditions', async () => { const rule = { id: 'complex-rule', name: 'Complex Rule', enabled: true, priority: 1, conditions: { type: 'AND' as const, rules: [ { field: 'tokenCount', operator: '>' as const, value: 1000 }, { field: 'agentType', operator: '=' as const, value: 'research' }, { field: 'message', operator: 'contains' as const, value: 'analyze' } ] }, targetModel: 'claude-3-opus-20240229' }; vi.spyOn(service as any, 'getTokenCount').mockResolvedValue(1500); const context = { agentConfig: { persona_type: 'research' } }; const result = await (service as any).evaluateRule( rule, ['--message', 'Please analyze this data'], context ); expect(result).toBe(true); }); it('should evaluate OR conditions correctly', async () => { const rule = { conditions: { type: 'OR' as const, rules: [ { field: 'tokenCount', operator: '>' as const, value: 100000 }, { field: 'flags', operator: 'contains' as const, value: '--thinking' } ] } }; vi.spyOn(service as any, 'getTokenCount').mockResolvedValue(500); const result = await (service as any).evaluateRule( rule, ['--message', 'Test', '--thinking'], {} ); expect(result).toBe(true); }); }); }); ``` ## Phase 3: UI Components (Week 2) ### 3.1 Model Configuration Component ```typescript // src/renderer/components/model-config/ModelConfiguration.tsx import React, { useState, useEffect, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Input } from '@/components/ui/input'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Badge } from '@/components/ui/badge'; import { Settings, DollarSign, Zap, ChartBar, Plus, Trash2, AlertCircle, CheckCircle } from 'lucide-react'; import { RoutingRulesEditor } from './RoutingRulesEditor'; import { useDebounce } from '@/hooks/useDebounce'; interface ModelConfigurationProps { projectId: string; onSave?: (config: ConduitConfig) => void; onCancel?: () => void; } export const ModelConfiguration: React.FC<ModelConfigurationProps> = ({ projectId, onSave, onCancel }) => { const [config, setConfig] = useState<ConduitConfig>({ enabled: false, defaultModel: 'claude-3-5-sonnet-20241022', routingRules: [], costLimits: { daily: null, monthly: null }, features: { autoRouting: true, costTracking: true, usageAnalytics: true } }); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null); const [isDirty, setIsDirty] = useState(false); const [testResult, setTestResult] = useState<any>(null); const debouncedConfig = useDebounce(config, 500); useEffect(() => { loadConfiguration(); }, [projectId]); useEffect(() => { if (!loading) { setIsDirty(true); } }, [debouncedConfig]); const loadConfiguration = async () => { try { setLoading(true); const response = await window.api.getConduitConfig(projectId); if (response.success) { setConfig(response.config); setIsDirty(false); } else { throw new Error(response.error); } } catch (err) { setError(err.message); console.error('Failed to load configuration:', err); } finally { setLoading(false); } }; const handleSave = async () => { try { setSaving(true); setError(null); // Validate configuration const validation = validateConfig(config); if (!validation.valid) { setError(validation.error); return; } const response = await window.api.saveConduitConfig(projectId, config); if (response.success) { setIsDirty(false); onSave?.(config); } else { throw new Error(response.error); } } catch (err) { setError(err.message); } finally { setSaving(false); } }; const testConfiguration = async () => { try { setTestResult(null); const response = await window.api.testConduitConfig(projectId, config); setTestResult(response); } catch (err) { setTestResult({ success: false, error: err.message }); } }; if (loading) { return <div className="flex items-center justify-center p-8">Loading configuration...</div>; } return ( <div className="space-y-6"> <Card> <CardHeader> <CardTitle className="flex items-center justify-between"> <span className="flex items-center gap-2"> <Settings className="h-5 w-5" /> Conduit Configuration </span> <Badge variant={config.enabled ? 'default' : 'secondary'}> {config.enabled ? 'Enabled' : 'Disabled'} </Badge> </CardTitle> </CardHeader> <CardContent> <Tabs defaultValue="general" className="space-y-4"> <TabsList className="grid w-full grid-cols-4"> <TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="routing">Routing Rules</TabsTrigger> <TabsTrigger value="costs">Cost Limits</TabsTrigger> <TabsTrigger value="features">Features</TabsTrigger> </TabsList> <TabsContent value="general" className="space-y-4"> <div className="flex items-center justify-between"> <div className="space-y-0.5"> <Label htmlFor="enabled">Enable Conduit Integration</Label> <p className="text-sm text-muted-foreground"> Use Conduit for intelligent model routing and usage tracking </p> </div> <Switch id="enabled" checked={config.enabled} onCheckedChange={(enabled) => setConfig(prev => ({ ...prev, enabled })) } /> </div> <div className="space-y-2"> <Label htmlFor="defaultModel">Default Model</Label> <Select value={config.defaultModel} onValueChange={(model) => setConfig(prev => ({ ...prev, defaultModel: model })) } disabled={!config.enabled} > <SelectTrigger id="defaultModel"> <SelectValue placeholder="Select default model" /> </SelectTrigger> <SelectContent> <SelectItem value="claude-3-5-sonnet-20241022"> Claude 3.5 Sonnet (Balanced) </SelectItem> <SelectItem value="claude-3-5-haiku-20241022"> Claude 3.5 Haiku (Fast & Efficient) </SelectItem> <SelectItem value="claude-3-opus-20240229"> Claude 3 Opus (Most Capable) </SelectItem> </SelectContent> </Select> <p className="text-xs text-muted-foreground"> Model used when no routing rules match </p> </div> {config.enabled && ( <Alert> <AlertCircle className="h-4 w-4" /> <AlertDescription> When enabled, all Claude requests will be routed through Conduit for optimal model selection and usage tracking. </AlertDescription> </Alert> )} </TabsContent> <TabsContent value="routing" className="space-y-4"> <RoutingRulesEditor rules={config.routingRules} onChange={(rules) => setConfig(prev => ({ ...prev, routingRules: rules })) } disabled={!config.enabled || !config.features.autoRouting} /> </TabsContent> <TabsContent value="costs" className="space-y-4"> <div className="space-y-4"> <div> <Label htmlFor="dailyLimit" className="flex items-center gap-2"> <DollarSign className="h-4 w-4" /> Daily Cost Limit </Label> <div className="flex items-center gap-2 mt-2"> <span>$</span> <Input id="dailyLimit" type="number" placeholder="No limit" value={config.costLimits.daily || ''} onChange={(e) => setConfig(prev => ({ ...prev, costLimits: { ...prev.costLimits, daily: e.target.value ? parseFloat(e.target.value) : null } })) } disabled={!config.enabled} min="0" step="0.01" /> </div> </div> <div> <Label htmlFor="monthlyLimit" className="flex items-center gap-2"> <DollarSign className="h-4 w-4" /> Monthly Cost Limit </Label> <div className="flex items-center gap-2 mt-2"> <span>$</span> <Input id="monthlyLimit" type="number" placeholder="No limit" value={config.costLimits.monthly || ''} onChange={(e) => setConfig(prev => ({ ...prev, costLimits: { ...prev.costLimits, monthly: e.target.value ? parseFloat(e.target.value) : null } })) } disabled={!config.enabled} min="0" step="0.01" /> </div> </div> <Alert> <AlertCircle className="h-4 w-4" /> <AlertDescription> When a cost limit is reached, requests will be blocked until the limit period resets. Set to empty for no limit. </AlertDescription> </Alert> </div> </TabsContent> <TabsContent value="features" className="space-y-4"> <div className="space-y-4"> <div className="flex items-center justify-between"> <div className="space-y-0.5"> <Label htmlFor="autoRouting" className="flex items-center gap-2"> <Zap className="h-4 w-4" /> Automatic Routing </Label> <p className="text-sm text-muted-foreground"> Automatically select the best model based on context </p> </div> <Switch id="autoRouting" checked={config.features.autoRouting} onCheckedChange={(autoRouting) => setConfig(prev => ({ ...prev, features: { ...prev.features, autoRouting } })) } disabled={!config.enabled} /> </div> <div className="flex items-center justify-between"> <div className="space-y-0.5"> <Label htmlFor="costTracking" className="flex items-center gap-2"> <DollarSign className="h-4 w-4" /> Cost Tracking </Label> <p className="text-sm text-muted-foreground"> Track costs for each request and model </p> </div> <Switch id="costTracking" checked={config.features.costTracking} onCheckedChange={(costTracking) => setConfig(prev => ({ ...prev, features: { ...prev.features, costTracking } })) } disabled={!config.enabled} /> </div> <div className="flex items-center justify-between"> <div className="space-y-0.5"> <Label htmlFor="usageAnalytics" className="flex items-center gap-2"> <ChartBar className="h-4 w-4" /> Usage Analytics </Label> <p className="text-sm text-muted-foreground"> Collect detailed usage statistics and insights </p> </div> <Switch id="usageAnalytics" checked={config.features.usageAnalytics} onCheckedChange={(usageAnalytics) => setConfig(prev => ({ ...prev, features: { ...prev.features, usageAnalytics } })) } disabled={!config.enabled} /> </div> </div> </TabsContent> </Tabs> {error && ( <Alert variant="destructive" className="mt-4"> <AlertCircle className="h-4 w-4" /> <AlertDescription>{error}</AlertDescription> </Alert> )} {testResult && ( <Alert variant={testResult.success ? 'default' : 'destructive'} className="mt-4"> {testResult.success ? ( <CheckCircle className="h-4 w-4" /> ) : ( <AlertCircle className="h-4 w-4" /> )} <AlertDescription> {testResult.success ? 'Configuration test passed! Conduit is working correctly.' : `Test failed: ${testResult.error}` } </AlertDescription> </Alert> )} <div className="flex justify-between items-center mt-6"> <Button variant="outline" onClick={testConfiguration} disabled={!config.enabled || saving} > Test Configuration </Button> <div className="flex gap-2"> {onCancel && ( <Button variant="outline" onClick={onCancel} disabled={saving} > Cancel </Button> )} <Button onClick={handleSave} disabled={!isDirty || saving} > {saving ? 'Saving...' : 'Save Configuration'} </Button> </div> </div> </CardContent> </Card> </div> ); }; // Validation function function validateConfig(config: ConduitConfig): { valid: boolean; error?: string } { if (config.enabled && !config.defaultModel) { return { valid: false, error: 'Default model is required when Conduit is enabled' }; } if (config.costLimits.daily && config.costLimits.daily < 0) { return { valid: false, error: 'Daily cost limit must be positive' }; } if (config.costLimits.monthly && config.costLimits.monthly < 0) { return { valid: false, error: 'Monthly cost limit must be positive' }; } if (config.costLimits.daily && config.costLimits.monthly && config.costLimits.daily > config.costLimits.monthly) { return { valid: false, error: 'Daily limit cannot exceed monthly limit' }; } // Validate routing rules for (const rule of config.routingRules) { if (!rule.name || !rule.targetModel) { return { valid: false, error: 'All routing rules must have a name and target model' }; } if (!rule.conditions || rule.conditions.rules.length === 0) { return { valid: false, error: `Routing rule "${rule.name}" must have at least one condition` }; } } return { valid: true }; } ``` ### 3.2 Component Tests ```typescript // src/renderer/components/model-config/ModelConfiguration.test.tsx import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ModelConfiguration } from './ModelConfiguration'; // Mock window.api const mockApi = { getConduitConfig: vi.fn(), saveConduitConfig: vi.fn(), testConduitConfig: vi.fn() }; (global as any).window = { api: mockApi }; describe('ModelConfiguration', () => { const defaultProps = { projectId: 'test-project', onSave: vi.fn(), onCancel: vi.fn() }; beforeEach(() => { vi.clearAllMocks(); mockApi.getConduitConfig.mockResolvedValue({ success: true, config: { enabled: false, defaultModel: 'claude-3-5-sonnet-20241022', routingRules: [], costLimits: { daily: null, monthly: null }, features: { autoRouting: true, costTracking: true, usageAnalytics: true } } }); }); it('should load configuration on mount', async () => { render(<ModelConfiguration {...defaultProps} />); await waitFor(() => { expect(mockApi.getConduitConfig).toHaveBeenCalledWith('test-project'); }); expect(screen.getByText('Disabled')).toBeInTheDocument(); }); it('should enable/disable Conduit integration', async () => { const user = userEvent.setup(); render(<ModelConfiguration {...defaultProps} />); await waitFor(() => { expect(screen.getByLabelText('Enable Conduit Integration')).toBeInTheDocument(); }); const toggle = screen.getByLabelText('Enable Conduit Integration'); expect(toggle).not.toBeChecked(); await user.click(toggle); expect(toggle).toBeChecked(); await waitFor(() => { expect(screen.getByText('Enabled')).toBeInTheDocument(); }); }); it('should validate cost limits', async () => { const user = userEvent.setup(); render(<ModelConfiguration {...defaultProps} />); await waitFor(() => { expect(screen.getByText('Cost Limits')).toBeInTheDocument(); }); await user.click(screen.getByText('Cost Limits')); const dailyInput = screen.getByLabelText('Daily Cost Limit'); const monthlyInput = screen.getByLabelText('Monthly Cost Limit'); // Set invalid limits (daily > monthly) await user.type(dailyInput, '100'); await user.type(monthlyInput, '50'); mockApi.saveConduitConfig.mockResolvedValue({ success: false }); await user.click(screen.getByText('Save Configuration')); await waitFor(() => { expect(screen.getByText('Daily limit cannot exceed monthly limit')).toBeInTheDocument(); }); }); it('should test configuration', async () => { const user = userEvent.setup(); render(<ModelConfiguration {...defaultProps} />); await waitFor(() => { expect(screen.getByText('General')).toBeInTheDocument(); }); // Enable Conduit first await user.click(screen.getByLabelText('Enable Conduit Integration')); mockApi.testConduitConfig.mockResolvedValue({ success: true, message: 'Configuration valid' }); await user.click(screen.getByText('Test Configuration')); await waitFor(() => { expect(mockApi.testConduitConfig).toHaveBeenCalledWith( 'test-project', expect.objectContaining({ enabled: true }) ); expect(screen.getByText(/Configuration test passed/)).toBeInTheDocument(); }); }); it('should handle save errors gracefully', async () => { const user = userEvent.setup(); render(<ModelConfiguration {...defaultProps} />); await waitFor(() => { expect(screen.getByText('General')).toBeInTheDocument(); }); mockApi.saveConduitConfig.mockRejectedValue(new Error('Network error')); await user.click(screen.getByLabelText('Enable Conduit Integration')); await user.click(screen.getByText('Save Configuration')); await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); }); it('should show dirty state when configuration changes', async () => { const user = userEvent.setup(); render(<ModelConfiguration {...defaultProps} />); await waitFor(() => { expect(screen.getByText('Save Configuration')).toBeDisabled(); }); await user.click(screen.getByLabelText('Enable Conduit Integration')); await waitFor(() => { expect(screen.getByText('Save Configuration')).toBeEnabled(); }); }); }); ``` ## Phase 4: Migration & Rollout (Week 3) ### 4.1 Data Export/Import Utilities ```typescript // src/main/services/migration/export-import.ts import fs from 'fs/promises'; import path from 'path'; import { app } from 'electron'; import * as Sentry from '@sentry/electron/main'; export class MigrationService { private backupDir: string; constructor( private supabaseService: any, private localDb: any ) { this.backupDir = path.join(app.getPath('userData'), 'backups'); } async exportData(projectId?: string): Promise<string> { const transaction = Sentry.startTransaction({ op: 'migration.export', name: 'Export Data' }); try { await fs.mkdir(this.backupDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `backup-${timestamp}.json`; const filepath = path.join(this.backupDir, filename); const data = { version: '1.0', timestamp, projects: [], agents: [], sessions: [], usage: [] }; // Export from Supabase if (projectId) { const { data: project } = await this.supabaseService.client .from('projects') .select('*') .eq('id', projectId) .single(); data.projects = [project]; const { data: agents } = await this.supabaseService.client .from('agents') .select('*') .eq('project_id', projectId); data.agents = agents || []; // Export usage data const { data: usage } = await this.supabaseService.client .from('usage_tracking') .select('*') .eq('project_id', projectId) .order('created_at', { ascending: false }) .limit(1000); data.usage = usage || []; } // Export from local SQLite const sessions = await this.localDb.all(` SELECT * FROM sessions WHERE project_id = ? OR ? IS NULL ORDER BY updated_at DESC LIMIT 100 `, [projectId, projectId]); data.sessions = sessions; await fs.writeFile(filepath, JSON.stringify(data, null, 2)); transaction.setStatus('ok'); return filepath; } catch (error) { transaction.setStatus('internal_error'); Sentry.captureException(error); throw error; } finally { transaction.finish(); } } async importData(filepath: string): Promise<void> { const transaction = Sentry.startTransaction({ op: 'migration.import', name: 'Import Data' }); try { const content = await fs.readFile(filepath, 'utf-8'); const data = JSON.parse(content); // Validate version if (data.version !== '1.0') { throw new Error(`Unsupported backup version: ${data.version}`); } // Import projects for (const project of data.projects) { await this.supabaseService.client .from('projects') .upsert(project, { onConflict: 'id' }); } // Import agents for (const agent of data.agents) { await this.supabaseService.client .from('agents') .upsert(agent, { onConflict: 'id' }); } // Import sessions to local DB for (const session of data.sessions) { await this.localDb.run(` INSERT OR REPLACE INTO sessions (id, project_id, agent_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?) `, [ session.id, session.project_id, session.agent_id, session.title, session.created_at, session.updated_at ]); } transaction.setStatus('ok'); } catch (error) { transaction.setStatus('internal_error'); Sentry.captureException(error); throw error; } finally { transaction.finish(); } } async listBackups(): Promise<Array<{ filename: string; timestamp: string; size: number }>> { try { await fs.mkdir(this.backupDir, { recursive: true }); const files = await fs.readdir(this.backupDir); const backups = await Promise.all( files .filter(f => f.endsWith('.json')) .map(async (filename) => { const filepath = path.join(this.backupDir, filename); const stats = await fs.stat(filepath); const match = filename.match(/backup-(.+)\.json/); return { filename, timestamp: match ? match[1].replace(/-/g, ':') : '', size: stats.size }; }) ); return backups.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); } catch (error) { console.error('Failed to list backups:', error); return []; } } } ``` ## Deployment Strategy ### Phased Rollout Plan #### Phase A: Internal Testing (Week 3) 1. Deploy to test environment 2. Run integration test suite 3. Performance benchmarking 4. Security audit #### Phase B: Beta Release (Week 4) 1. Feature flag controlled rollout 2. Enable for 10% of users initially 3. Monitor metrics: - Error rates - Performance impact - Cost tracking accuracy - User feedback #### Phase C: Full Release (Week 5) 1. Gradual rollout to 100% 2. Documentation updates 3. Support team training 4. Marketing announcement ### Monitoring & Alerts ```typescript // src/main/services/monitoring/conduit-monitoring.ts export class ConduitMonitoring { constructor(private sentry: typeof Sentry) {} setupMonitoring() { // Performance monitoring this.sentry.addGlobalEventProcessor((event) => { if (event.contexts?.trace?.op?.startsWith('conduit.')) { event.tags = { ...event.tags, 'conduit.enabled': 'true', 'conduit.version': process.env.CONDUIT_VERSION }; } return event; }); // Custom metrics this.trackMetric('conduit.requests.total', 'counter'); this.trackMetric('conduit.requests.failed', 'counter'); this.trackMetric('conduit.routing.duration', 'histogram'); this.trackMetric('conduit.cost.daily', 'gauge'); } private trackMetric(name: string, type: string) { // Implementation depends on metrics backend } } ``` ### Rollback Plan 1. **Feature Flag Disable**: Immediate disable via feature flag 2. **Configuration Override**: Force `enabled: false` for all projects 3. **Binary Fallback**: Direct Claude CLI execution path 4. **Data Preservation**: All tracking data retained for analysis ## Success Metrics 1. **Performance** - Routing decision latency < 50ms - No increase in overall request latency - 99.9% uptime for Conduit service 2. **Accuracy** - Cost tracking accuracy > 99% - Routing rule match rate > 90% - Model selection improvement > 20% 3. **User Satisfaction** - Feature adoption > 80% within 30 days - Support tickets < 1% of active users - Positive feedback > 90% 4. **Business Impact** - Cost reduction > 15% average - Model diversity increase > 30% - Analytics dashboard usage > 70%