Skip to content
34 changes: 25 additions & 9 deletions src/__tests__/auth-dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

Expand Down Expand Up @@ -194,32 +194,46 @@ 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);
});

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);
});
Expand Down Expand Up @@ -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.' });
Expand All @@ -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);
});
Expand All @@ -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.' });
Expand Down Expand Up @@ -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);
});
Expand Down
27 changes: 26 additions & 1 deletion src/analysis/__tests__/analysis-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions src/analysis/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ export interface ExplainSingleParams {
shouldAnonymize: boolean;
noCache: boolean;
customHeaders?: Record<string, string>;
baseUrl?: string;
onAgentProgress?: (event: AgentProgressEvent) => void;
}

Expand Down Expand Up @@ -219,6 +220,7 @@ async function tryRunAgentCouncil(
namespace: params.result.namespace,
},
model: model || 'gemma:2b',
baseUrl: params.baseUrl,
onProgress: params.onAgentProgress,
});
return formatConsensusExplanation(consensus);
Expand Down Expand Up @@ -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,
});
}
}
Expand Down
52 changes: 46 additions & 6 deletions src/ui/AuthDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ onBack, onExit })
model: '',
apiKey: '',
temp: '0.7',
baseUrl: '',
customHeaders: '{}',
});

const selectedBackend = VALID_BACKENDS[selectedIndex];
Expand Down Expand Up @@ -334,7 +336,7 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ onBack, onExit })
};

const handleAddReturn = () => {
if (wizardStep < 3) {
if (wizardStep < 5) {
if (wizardStep === 0) {
const prov = wizardValues.provider.trim().toLowerCase();
if (!VALID_BACKENDS.includes(prov)) {
Expand All @@ -361,6 +363,17 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ onBack, onExit })
return;
}

let customHeaders: Record<string, string>;
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<string, string>;
} 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.');
Expand All @@ -372,6 +385,8 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ 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];
Expand All @@ -387,7 +402,7 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ onBack, onExit })
};

const handleEditReturn = () => {
if (wizardStep < 2) {
if (wizardStep < 4) {
setWizardStep((prev) => prev + 1);
return;
}
Expand All @@ -398,13 +413,26 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ onBack, onExit })
return;
}

let customHeaders: Record<string, string> | 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<string, string>;
} 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 {
...p,
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;
Expand All @@ -422,12 +450,18 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ 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');
Expand All @@ -443,6 +477,8 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ 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');
Expand Down Expand Up @@ -514,7 +550,7 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ onBack, onExit })
return;
}
if (key.downArrow) {
setWizardStep((prev) => Math.min(3, prev + 1));
setWizardStep((prev) => Math.min(5, prev + 1));
return;
}

Expand All @@ -523,7 +559,7 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ 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 }) => {
Expand All @@ -537,7 +573,7 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ onBack, onExit })
return;
}
if (key.downArrow) {
setWizardStep((prev) => Math.min(2, prev + 1));
setWizardStep((prev) => Math.min(4, prev + 1));
return;
}

Expand All @@ -546,7 +582,7 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ 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 }) => {
Expand Down Expand Up @@ -609,6 +645,8 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ 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}
Expand All @@ -627,6 +665,8 @@ export const AuthDashboard: React.FC<AuthDashboardProps> = ({ 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}
Expand Down
Loading