diff --git a/src/__tests__/auth-dashboard.test.tsx b/src/__tests__/auth-dashboard.test.tsx index 41908e6e..cb57239d 100644 --- a/src/__tests__/auth-dashboard.test.tsx +++ b/src/__tests__/auth-dashboard.test.tsx @@ -148,7 +148,7 @@ describe('AuthDashboard', () => { const enterAddWizard = async () => { await sendInputs(['a']); - await waitForFrameToContain({ mockStdout, substring: 'Step 1/4 — Provider:' }); + await waitForFrameToContain({ mockStdout, substring: 'Step 1/6 — Provider:' }); await sleep(50); }; @@ -194,13 +194,19 @@ describe('AuthDashboard', () => { await enterAddWizard(); - await sendInputs([{ key: 'return' }, { key: 'return' }, 'secret-api-key', { key: 'return' }, { key: 'return' }]); + await sendInputs([ + { key: 'return' }, { key: 'return' }, 'secret-api-key', { key: 'return' }, { key: 'return' }, + 'http://ollama.example:11434', { key: 'return' }, { key: 'backspace' }, { key: 'backspace' }, + '{"X-Test":"yes"}', { key: 'return' }, + ]); await waitForFrameToContain({ mockStdout, substring: 'Successfully added AI provider "OpenAI"' }); expect(mockStore.providers).toHaveLength(1); expect(mockStore.providers[0].name).toBe('openai'); expect(mockStore.providers[0].model).toBe('gpt-4o'); expect(mockStore.providers[0].password).toBe('secret-api-key'); + expect(mockStore.providers[0].baseUrl).toBe('http://ollama.example:11434'); + expect(mockStore.providers[0].customHeaders).toEqual({ 'X-Test': 'yes' }); await cleanupTest(unmount); }); @@ -208,18 +214,26 @@ describe('AuthDashboard', () => { it('performs Edit Provider Wizard flow successfully', async () => { const { unmount } = await setupDashboardTest({ initialProviders: [ - { name: 'openai', model: 'gpt-4o', password: 'old-key', temperature: 0.7 }, + { name: 'openai', model: 'gpt-4o', password: 'old-key', temperature: 0.7, baseUrl: 'https://proxy.example/v1', customHeaders: { 'X-Org': 'kdm' } }, { name: 'ollama', model: 'llama3.1', password: 'ollama-key', temperature: 0.7 }, ], }); await enterEditWizard(); - await sendInputs([{ key: 'backspace' }, '-turbo', { key: 'return' }, 'new-key', { key: 'return' }, { key: 'return' }]); + await sendInputs([ + { key: 'backspace' }, '-turbo', { key: 'return' }, 'new-key', { key: 'return' }, { key: 'return' }, + ...Array.from({ length: 24 }, () => ({ key: 'backspace' })), + 'https://edited.example/v1', { key: 'return' }, + ...Array.from({ length: 15 }, () => ({ key: 'backspace' })), + '{"X-Env":"test"}', { key: 'return' }, + ]); await waitForFrameToContain({ mockStdout, substring: 'Successfully updated AI provider "OpenAI"' }); expect(mockStore.providers[0].model).toBe('gpt-4-turbo'); expect(mockStore.providers[0].password).toBe('new-key'); + expect(mockStore.providers[0].baseUrl).toBe('https://edited.example/v1'); + expect(mockStore.providers[0].customHeaders).toEqual({ 'X-Env': 'test' }); await cleanupTest(unmount); }); @@ -300,6 +314,8 @@ describe('AuthDashboard', () => { { key: 'backspace' }, { key: 'backspace' }, '0.7abc', + { key: 'return' }, + { key: 'return' }, { key: 'return' } ]); await waitForFrameToContain({ mockStdout, substring: 'Temperature must be a valid number.' }); @@ -314,10 +330,8 @@ describe('AuthDashboard', () => { ], }); - await enterAddWizard(); - - await sendInputs([{ key: 'return' }, { key: 'return' }, { key: 'return' }, { key: 'return' }]); - await waitForFrameToContain({ mockStdout, substring: 'Provider "openai" is already configured. Use edit (E) instead.' }); + await sendInputs(['a']); + await waitForFrameToContain({ mockStdout, substring: 'Provider "OpenAI" is already configured. Use edit (E) instead.' }); await cleanupTest(unmount); }); @@ -338,6 +352,8 @@ describe('AuthDashboard', () => { { key: 'backspace' }, { key: 'backspace' }, 'invalidtemp', + { key: 'return' }, + { key: 'return' }, { key: 'return' } ]); await waitForFrameToContain({ mockStdout, substring: 'Temperature must be a valid number.' }); @@ -374,7 +390,7 @@ describe('AuthDashboard', () => { await sendInputs([{ key: 'escape' }]); const lastRendered = [...mockStdout.frames].reverse().find((f) => f.includes('AI Provider Manager')); expect(lastRendered).toBeDefined(); - expect(lastRendered).not.toContain('Step 1/4'); + expect(lastRendered).not.toContain('Step 1/6'); await cleanupTest(unmount); }); diff --git a/src/analysis/__tests__/analysis-baseline.test.ts b/src/analysis/__tests__/analysis-baseline.test.ts index e607b595..b987f918 100644 --- a/src/analysis/__tests__/analysis-baseline.test.ts +++ b/src/analysis/__tests__/analysis-baseline.test.ts @@ -11,7 +11,8 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { runAnalysis, explainSingleResult, resolveBackend } from '../analysis'; -import { clearConfig } from '../../config/store'; +import { clearConfig, setAIConfig } from '../../config/store'; +import { isPythonAgentAvailable, runPythonAgentCouncil } from '../../agent/python-bridge'; import { registry, PodAnalyzer, @@ -254,6 +255,30 @@ describe('Baseline Pipeline Regression Tests', () => { expect(resolvedOllama).toBe('ollama'); }); + it('passes the configured Ollama URL to the Python council', async () => { + registry.clear(); + registry.register(createMockAnalyzer('Pod', [OOM_KILLED_FIXTURE])); + setAIConfig({ providers: [{ name: 'ollama', model: 'gemma:2b', baseUrl: 'http://ollama.remote:11434' }] }); + vi.mocked(isPythonAgentAvailable).mockResolvedValueOnce(true); + vi.mocked(runPythonAgentCouncil).mockResolvedValueOnce({ + confidence: 'high', + rootCause: 'test cause', + bestSolution: { actionTitle: 'test fix', steps: [] }, + findings: [], + } as any); + + await runAnalysis({ + filters: ['Pod'], + explain: true, + backend: 'ollama', + noCache: true, + }); + + expect(runPythonAgentCouncil).toHaveBeenCalledWith(expect.objectContaining({ + baseUrl: 'http://ollama.remote:11434', + })); + }); + // ─── Test 6: Feature Flag Toggle ───────────────────────────────────────── it('respects KDM_MULTI_AGENT feature flag toggle', async () => { diff --git a/src/analysis/analysis.ts b/src/analysis/analysis.ts index dd7dc39c..e63abddb 100644 --- a/src/analysis/analysis.ts +++ b/src/analysis/analysis.ts @@ -161,6 +161,7 @@ export interface ExplainSingleParams { shouldAnonymize: boolean; noCache: boolean; customHeaders?: Record; + baseUrl?: string; onAgentProgress?: (event: AgentProgressEvent) => void; } @@ -219,6 +220,7 @@ async function tryRunAgentCouncil( namespace: params.result.namespace, }, model: model || 'gemma:2b', + baseUrl: params.baseUrl, onProgress: params.onAgentProgress, }); return formatConsensusExplanation(consensus); @@ -318,6 +320,7 @@ async function explainResults(results: AnalyzerResult[], options: AnalysisOption shouldAnonymize, noCache, customHeaders: options.customHeaders, + baseUrl: getAIConfig().providers.find((provider) => provider.name.toLowerCase() === backend.toLowerCase())?.baseUrl, }); } } diff --git a/src/ui/AuthDashboard.tsx b/src/ui/AuthDashboard.tsx index 048eb3db..5f1e30e9 100644 --- a/src/ui/AuthDashboard.tsx +++ b/src/ui/AuthDashboard.tsx @@ -273,6 +273,8 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) model: '', apiKey: '', temp: '0.7', + baseUrl: '', + customHeaders: '{}', }); const selectedBackend = VALID_BACKENDS[selectedIndex]; @@ -334,7 +336,7 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) }; const handleAddReturn = () => { - if (wizardStep < 3) { + if (wizardStep < 5) { if (wizardStep === 0) { const prov = wizardValues.provider.trim().toLowerCase(); if (!VALID_BACKENDS.includes(prov)) { @@ -361,6 +363,17 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) return; } + let customHeaders: Record; + try { + const parsed: unknown = JSON.parse(wizardValues.customHeaders || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== 'string')) throw new Error(); + customHeaders = parsed as Record; + } catch { + setErrorMsg('Custom headers must be a JSON object with string values.'); + return; + } + const tempFloat = parseTemperature(wizardValues.temp); if (tempFloat === null) { setErrorMsg('Temperature must be a valid number.'); @@ -372,6 +385,8 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) model: wizardValues.model.trim() || DEFAULT_MODELS[prov] || 'default', password: wizardValues.apiKey.trim() || undefined, temperature: tempFloat, + baseUrl: wizardValues.baseUrl.trim() || undefined, + customHeaders, }; const newProviders = [...currentProviders, newProvider]; @@ -387,7 +402,7 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) }; const handleEditReturn = () => { - if (wizardStep < 2) { + if (wizardStep < 4) { setWizardStep((prev) => prev + 1); return; } @@ -398,6 +413,17 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) return; } + let customHeaders: Record | undefined; + try { + const parsed: unknown = JSON.parse(wizardValues.customHeaders || '{}'); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || + Object.values(parsed).some((value) => typeof value !== 'string')) throw new Error(); + customHeaders = parsed as Record; + } catch { + setErrorMsg('Custom headers must be a JSON object with string values.'); + return; + } + const updatedProviders = currentProviders.map((p) => { if (p.name.toLowerCase() === selectedBackend.toLowerCase()) { return { @@ -405,6 +431,8 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) model: wizardValues.model.trim() || p.model, password: wizardValues.apiKey.trim() ? wizardValues.apiKey.trim() : p.password, temperature: tempFloat, + baseUrl: wizardValues.baseUrl.trim() || undefined, + customHeaders, }; } return p; @@ -422,12 +450,18 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) }; const selectAddProvider = () => { + if (selectedConfig) { + setErrorMsg(`Provider "${selectedProviderName}" is already configured. Use edit (E) instead.`); + return; + } const defaultModel = DEFAULT_MODELS[selectedBackend] || 'default'; setWizardValues({ provider: selectedBackend, model: defaultModel, apiKey: '', temp: '0.7', + baseUrl: '', + customHeaders: '{}', }); setWizardStep(0); setMode('add'); @@ -443,6 +477,8 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) model: selectedConfig.model || '', apiKey: '', temp: selectedConfig.temperature !== undefined ? String(selectedConfig.temperature) : '0.7', + baseUrl: selectedConfig.baseUrl ?? '', + customHeaders: JSON.stringify(selectedConfig.customHeaders ?? {}), }); setWizardStep(0); setMode('edit'); @@ -514,7 +550,7 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) return; } if (key.downArrow) { - setWizardStep((prev) => Math.min(3, prev + 1)); + setWizardStep((prev) => Math.min(5, prev + 1)); return; } @@ -523,7 +559,7 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) return; } - handleTyping({ input, key, fields: ['provider', 'model', 'apiKey', 'temp'], activeStep: wizardStep }); + handleTyping({ input, key, fields: ['provider', 'model', 'apiKey', 'temp', 'baseUrl', 'customHeaders'], activeStep: wizardStep }); }; const handleEditInput = ({ input, key }: { input: string; key: any }) => { @@ -537,7 +573,7 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) return; } if (key.downArrow) { - setWizardStep((prev) => Math.min(2, prev + 1)); + setWizardStep((prev) => Math.min(4, prev + 1)); return; } @@ -546,7 +582,7 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) return; } - handleTyping({ input, key, fields: ['model', 'apiKey', 'temp'], activeStep: wizardStep }); + handleTyping({ input, key, fields: ['model', 'apiKey', 'temp', 'baseUrl', 'customHeaders'], activeStep: wizardStep }); }; const handleRemoveConfirmInput = ({ input, key }: { input: string; key: any }) => { @@ -609,6 +645,8 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) { label: 'Model: ', value: wizardValues.model }, { label: 'API Key: ', value: '*'.repeat(wizardValues.apiKey.length) }, { label: 'Temp: ', value: wizardValues.temp }, + { label: 'Base URL: ', value: wizardValues.baseUrl || '(provider default)' }, + { label: 'Headers: ', value: wizardValues.customHeaders }, ]} activeStep={wizardStep} errorMsg={errorMsg} @@ -627,6 +665,8 @@ export const AuthDashboard: React.FC = ({ onBack, onExit }) : '(leave empty to keep existing)', }, { label: 'Temp: ', value: wizardValues.temp }, + { label: 'Base URL: ', value: wizardValues.baseUrl || '(provider default)' }, + { label: 'Headers: ', value: wizardValues.customHeaders }, ]} activeStep={wizardStep} errorMsg={errorMsg}