Files
Dragan Spiridonov 3bc09cda6d fix(llm-router): address 10 findings from qe-devils-advocate audit
A post-Phase-4 devil's-advocate audit identified 10 issues. All addressed
with code fixes + tests. Some were real bugs (silent billing regression,
2 unwired MCP tools, 2 HybridRouter routing bugs); others were robustness
improvements (split singletons, silent errors, decorative type fields).

Findings → fixes (severity tagged in audit report):

1. CRITICAL — silent billing regression on upgrade
   Users with ANTHROPIC_API_KEY in env would silently start paying for
   Claude API calls on every Fleet operation. The CHANGELOG's "opt out
   via code changes" wasn't sufficient — needed an env-only escape.
   Fix: AQE_LLM_ROUTER_DISABLED=1 env kill-switch honored in both
   QEKernelImpl._initializeLLMRouter and src/mcp/tools/base.ts
   getSharedLLMRouter. Truthy values disable; 'false'/'0'/'no'/'off'/''
   pass. Unified parse rule between kernel and MCP base.

2. HIGH — 2 unwired MCP standalone tools (audit said 3; QualityCriteria
   isn't LLM-aware so excluded)
   src/mcp/tools/test-generation/generate.ts:82 was calling
   createTestGeneratorService(memory) without router. Same for
   visual-accessibility/index.ts:177. Both now use the
   createXxxServiceWithDependencies factories with { memory, llmRouter }.

3. CRITICAL — E2E only tested manual routing mode, not production
   Production default is rule-based. Adding two new E2E tests for
   rule-based mode SURFACED two real bugs in HybridRouter that I fixed:

   3a. executeWithFallback hardcoded ['claude','openai','ollama'] as the
       only fallback providers; gemini/openrouter/azure-openai/bedrock
       users had no fallback even though ProviderManager can construct
       them. Plus the user's defaultProvider wasn't auto-included in the
       chain. Both fixed at src/shared/llm/router/hybrid-router.ts:612.

   3b. A non-retryable error from one provider (e.g. Claude "invalid
       x-api-key") aborted the entire fallback loop via `throw error`,
       defeating the whole point of a fallback chain. Fixed: skip the
       inter-attempt delay for non-retryable but continue to the next
       fallback entry.

4. HIGH — MCPToolContext.llmRouter field was decorative
   Added the field but no code populated it. Every getLLMRouter(context)
   call fell through to getSharedLLMRouter().
   Fix: added llmRouter and memory to BaseMCPTool.invoke() options;
   qe-tool-bridge.ts and registry.ts now explicitly forward the shared
   singleton via { llmRouter } at invoke time.

5. HIGH — 14 of 15 wired services had only STRUCTURAL verification, not
   behavioral (only TestExecutor had a chat() round-trip test)
   Fix: new tests/integration/llm-router-behavioral.test.ts asserts
   isLLMAnalysisAvailable()===true (the exact gate the audit named) for
   all 14 services + 2 full behavioral round-trips. 16 tests total.

6. HIGH — MCP and kernel built SEPARATE HybridRouter instances
   Different cost trackers, caches, circuit breakers per side.
   routing_economics/routing_metrics would only see half the traffic.
   Fix: kernel.ts._initializeLLMRouter calls setSharedLLMRouter(router)
   on init so both paths share one instance. kernel.dispose() calls
   resetSharedLLMRouter() so the next kernel doesn't inherit the
   previous instance.

7. HIGH — Silent error swallowing in _initializeLLMRouter
   console.warn'd then continued without LLM, with no signal for
   monitoring. A misconfigured provider would silently degrade with no
   alert.
   Fix: now publishes kernel.llm-router.init-failed (caught error) and
   kernel.llm-router.init-no-provider (enabled=true but nothing
   available) events on the event bus. Test confirms event delivery.

8. MEDIUM — Service count mismatch in docs (ADR/CHANGELOG said "14
   services / 12 domains"; test enumerates 15 services / 11
   LLM-enhanced domains)
   Fix: ADR-043 addendum + CHANGELOG corrected to 15/11. Added
   explicit service list and noted the 2 non-LLM domains
   (enterprise-integration, coordination).

9. MEDIUM — aqe llm config --set apiKey=... silently dropped the key
   stripApiKeys() returned a successfully-saved file without the key,
   user thought it was saved.
   Fix: CLI updateConfig() now throws a clear error pointing to env
   vars BEFORE calling save. stripApiKeys also now logs a console.warn
   when it strips anything, so programmatic callers see the strip.

10. LOW — Env vs disk precedence was counterintuitive and undocumented
    Force-enable-on-env overrides explicit `enabled: false` on disk.
    Fix: doc comment in config-store.applyEnvProviderDetection +
    ADR-043 "Precedence & env-var conventions" section explains.

Verification
- npx tsc --noEmit: clean
- 191 unit + integration tests pass (16 new behavioral tests for #5,
  17 wiring tests, gemini provider, config-store, llm-router-service,
  kernel-llm-router with 17 tests including all kill-switch cases,
  CLI persistence with 28 tests)
- 5 real-provider E2E tests pass (Gemini 2.5 Flash, OpenAI gpt-4o-mini,
  OpenRouter, plus 2 rule-based-mode tests that previously failed and
  now pass with the HybridRouter fallback fixes)

Refs ADR-043 audit findings (2026-05-21)
Builds on 94033e3 (Phase 1), be1e60d (Phase 2), c206c81 (Phase 3+4)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 12:09:18 +00:00

459 lines
16 KiB
TypeScript

/**
* LLM Router CLI Commands Tests
* ADR-043: Vendor-Independent LLM Support - Milestone 10
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { createLLMRouterCommand } from '../../../../src/cli/commands/llm-router.js';
import type { Command } from 'commander';
// Mock console.log and console.error for output testing
let mockConsoleLog: ReturnType<typeof vi.spyOn>;
let mockConsoleError: ReturnType<typeof vi.spyOn>;
let mockExit: ReturnType<typeof vi.spyOn>;
// Helper to parse command and execute it
async function executeCommand(
cmd: Command,
args: string[],
): Promise<void> {
// Configure commander to not exit on error
cmd.exitOverride();
cmd.configureOutput({
writeOut: () => {},
writeErr: () => {},
});
// Parse will trigger the action - use 'node' as default (first two items are node and script)
await cmd.parseAsync(args, { from: 'user' });
}
describe('LLM Router CLI Commands', () => {
let command: ReturnType<typeof createLLMRouterCommand>;
let tmpConfigRoot: string;
let savedConfigRootEnv: string | undefined;
beforeEach(() => {
// Redirect the CLI's config persistence to a per-test temp dir so
// `aqe llm config --set` doesn't mutate the real .agentic-qe/.
tmpConfigRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'aqe-cli-llm-'));
savedConfigRootEnv = process.env.AQE_CONFIG_ROOT;
process.env.AQE_CONFIG_ROOT = tmpConfigRoot;
command = createLLMRouterCommand();
mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never);
});
afterEach(() => {
vi.restoreAllMocks();
if (savedConfigRootEnv === undefined) {
delete process.env.AQE_CONFIG_ROOT;
} else {
process.env.AQE_CONFIG_ROOT = savedConfigRootEnv;
}
if (fs.existsSync(tmpConfigRoot)) {
fs.rmSync(tmpConfigRoot, { recursive: true, force: true });
}
});
describe('createLLMRouterCommand', () => {
it('should create a command with name "llm"', () => {
expect(command.name()).toBe('llm');
});
it('should have a description', () => {
expect(command.description()).toBeTruthy();
expect(command.description()).toContain('LLM Router');
});
it('should have subcommands', () => {
const subcommandNames = command.commands.map(c => c.name());
expect(subcommandNames).toContain('providers');
expect(subcommandNames).toContain('models');
expect(subcommandNames).toContain('route');
expect(subcommandNames).toContain('config');
expect(subcommandNames).toContain('health');
expect(subcommandNames).toContain('cost');
});
});
describe('providers subcommand', () => {
it('should exist and have correct options', () => {
const providersCmd = command.commands.find(c => c.name() === 'providers');
expect(providersCmd).toBeDefined();
const options = providersCmd!.options.map(o => o.long);
expect(options).toContain('--json');
expect(options).toContain('--verbose');
});
it('should list all provider types via parse', async () => {
await executeCommand(command, ['providers', '--json']);
expect(mockConsoleLog).toHaveBeenCalled();
// Get the JSON output
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const providers = JSON.parse(jsonOutput);
expect(Array.isArray(providers)).toBe(true);
expect(providers.length).toBeGreaterThan(0);
// Check for expected providers
const providerNames = providers.map((p: { provider: string }) => p.provider);
expect(providerNames).toContain('claude');
expect(providerNames).toContain('openai');
expect(providerNames).toContain('ollama');
});
});
describe('models subcommand', () => {
it('should exist and have correct options', () => {
const modelsCmd = command.commands.find(c => c.name() === 'models');
expect(modelsCmd).toBeDefined();
const options = modelsCmd!.options.map(o => o.long);
expect(options).toContain('--provider');
expect(options).toContain('--tier');
expect(options).toContain('--json');
});
it('should list models as JSON', async () => {
await executeCommand(command, ['models', '--json']);
expect(mockConsoleLog).toHaveBeenCalled();
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const models = JSON.parse(jsonOutput);
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBeGreaterThan(0);
// Check model structure
const firstModel = models[0];
expect(firstModel).toHaveProperty('canonicalId');
expect(firstModel).toHaveProperty('tier');
expect(firstModel).toHaveProperty('family');
});
it('should filter models by provider', async () => {
await executeCommand(command, ['models', '--json', '--provider', 'claude']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const models = JSON.parse(jsonOutput);
expect(models.length).toBeGreaterThan(0);
// All returned models should have Claude provider ID
for (const model of models) {
expect(model.providerIds).toHaveProperty('claude');
}
});
it('should filter models by tier', async () => {
await executeCommand(command, ['models', '--json', '--tier', 'flagship']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const models = JSON.parse(jsonOutput);
expect(models.length).toBeGreaterThan(0);
// All returned models should be flagship tier
for (const model of models) {
expect(model.tier).toBe('flagship');
}
});
});
describe('route subcommand', () => {
it('should exist and have correct arguments and options', () => {
const routeCmd = command.commands.find(c => c.name() === 'route');
expect(routeCmd).toBeDefined();
// Check for task argument
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((routeCmd as any).registeredArguments.length).toBe(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((routeCmd as any).registeredArguments[0].name()).toBe('task');
const options = routeCmd!.options.map(o => o.long);
expect(options).toContain('--agent');
expect(options).toContain('--complexity');
expect(options).toContain('--mode');
expect(options).toContain('--json');
});
it('should route a security task to Claude', async () => {
await executeCommand(command, ['route', 'security vulnerability scan', '--json']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const decision = JSON.parse(jsonOutput);
expect(decision).toHaveProperty('provider');
expect(decision).toHaveProperty('model');
expect(decision).toHaveProperty('reason');
expect(decision).toHaveProperty('confidence');
expect(decision.provider).toBe('claude');
});
it('should route with agent type consideration', async () => {
await executeCommand(command, ['route', 'analyze code', '--json', '--agent', 'security-auditor']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const decision = JSON.parse(jsonOutput);
expect(decision.provider).toBe('claude');
expect(decision.reason).toContain('Security');
});
it('should route simple tasks to efficient models', async () => {
await executeCommand(command, [
'route',
'simple formatting task',
'--json',
'--complexity',
'trivial',
'--mode',
'cost-optimized',
]);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const decision = JSON.parse(jsonOutput);
// Should select cost-effective model
expect(decision.model).toContain('mini');
});
});
describe('config subcommand', () => {
it('should exist and have correct options', () => {
const configCmd = command.commands.find(c => c.name() === 'config');
expect(configCmd).toBeDefined();
const options = configCmd!.options.map(o => o.long);
expect(options).toContain('--set');
expect(options).toContain('--json');
});
it('should output configuration as JSON', async () => {
await executeCommand(command, ['config', '--json']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const config = JSON.parse(jsonOutput);
expect(config).toHaveProperty('mode');
expect(config).toHaveProperty('defaultProvider');
expect(config).toHaveProperty('defaultModel');
expect(config).toHaveProperty('enableMetrics');
});
it('should set and persist configuration value', async () => {
// Set mode to cost-optimized
await executeCommand(command, ['config', '--set', 'mode=cost-optimized']);
expect(mockConsoleLog).toHaveBeenCalled();
// Verify the setting was applied
mockConsoleLog.mockClear();
// Create new command instance to verify persistence (in-memory for tests)
const newCommand = createLLMRouterCommand();
await executeCommand(newCommand, ['config', '--json']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const config = JSON.parse(jsonOutput);
expect(config.mode).toBe('cost-optimized');
});
it('writes the setting through to disk so a fresh process picks it up', async () => {
// Simulate the cross-process case: --set writes the file.
await executeCommand(command, ['config', '--set', 'defaultProvider=gemini']);
const configPath = path.join(tmpConfigRoot, '.agentic-qe', 'llm-config.json');
expect(fs.existsSync(configPath)).toBe(true);
const onDisk = JSON.parse(fs.readFileSync(configPath, 'utf8'));
expect(onDisk.defaultProvider).toBe('gemini');
});
it('strips secrets defensively on persist (apiKey never reaches disk)', async () => {
// The config-store strips any apiKey fields it sees in the
// serialized payload — this protects against a future bug that
// ever copies an env API key into the in-memory config.
await executeCommand(command, ['config', '--set', 'defaultProvider=openai']);
const configPath = path.join(tmpConfigRoot, '.agentic-qe', 'llm-config.json');
const raw = fs.readFileSync(configPath, 'utf8');
expect(raw).not.toMatch(/sk-/);
expect(raw).not.toMatch(/apiKey/);
});
it('refuses to save apiKey with a clear error pointing to env vars', async () => {
// Audit Fix #9: previously `--set apiKey=...` returned "Unknown
// config key" which was unhelpful. Now it throws a clear error
// that explains api keys go in env vars.
let thrown: Error | undefined;
try {
await executeCommand(command, ['config', '--set', 'apiKey=sk-fake-123']);
} catch (e) {
thrown = e as Error;
}
expect(thrown).toBeDefined();
expect(thrown!.message).toMatch(/environment variable|ANTHROPIC_API_KEY|GEMINI_API_KEY/i);
// And nothing was written to disk
const configPath = path.join(tmpConfigRoot, '.agentic-qe', 'llm-config.json');
if (fs.existsSync(configPath)) {
const raw = fs.readFileSync(configPath, 'utf8');
expect(raw).not.toMatch(/sk-fake-123/);
}
});
});
describe('health subcommand', () => {
it('should exist and have correct options', () => {
const healthCmd = command.commands.find(c => c.name() === 'health');
expect(healthCmd).toBeDefined();
const options = healthCmd!.options.map(o => o.long);
expect(options).toContain('--json');
expect(options).toContain('--timeout');
});
it('should return health status for all providers', async () => {
await executeCommand(command, ['health', '--json']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const healthResults = JSON.parse(jsonOutput);
expect(Array.isArray(healthResults)).toBe(true);
expect(healthResults.length).toBeGreaterThan(0);
// Check structure of health result
const firstResult = healthResults[0];
expect(firstResult).toHaveProperty('provider');
expect(firstResult).toHaveProperty('healthy');
});
});
describe('cost subcommand', () => {
it('should exist and have correct arguments and options', () => {
const costCmd = command.commands.find(c => c.name() === 'cost');
expect(costCmd).toBeDefined();
// Check for model argument
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((costCmd as any).registeredArguments.length).toBe(1);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((costCmd as any).registeredArguments[0].name()).toBe('model');
const options = costCmd!.options.map(o => o.long);
expect(options).toContain('--tokens');
expect(options).toContain('--json');
});
it('should estimate cost for a known model', async () => {
await executeCommand(command, ['cost', 'claude-sonnet-4-6', '--json', '--tokens', '10000']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const costEstimate = JSON.parse(jsonOutput);
expect(costEstimate).toHaveProperty('model');
expect(costEstimate).toHaveProperty('tokens');
expect(costEstimate).toHaveProperty('inputCostPer1M');
expect(costEstimate).toHaveProperty('outputCostPer1M');
expect(costEstimate).toHaveProperty('estimatedTotalCost');
expect(costEstimate.tokens).toBe(10000);
});
it('should error for unknown model', async () => {
// Unknown model triggers console.error and process.exit
await executeCommand(command, ['cost', 'unknown-model-xyz', '--json']);
// Expected - console.error was called
expect(mockConsoleError).toHaveBeenCalled();
});
});
describe('command help', () => {
it('should have help text with subcommands listed', () => {
const helpText = command.helpInformation();
expect(helpText).toContain('providers');
expect(helpText).toContain('models');
expect(helpText).toContain('route');
expect(helpText).toContain('config');
expect(helpText).toContain('health');
expect(helpText).toContain('cost');
});
});
});
describe('LLM Router Integration', () => {
beforeEach(() => {
mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => {});
mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should support all 8 provider types', async () => {
const expectedProviders = [
'claude',
'openai',
'ollama',
'openrouter',
'gemini',
'azure-openai',
'bedrock',
'onnx',
];
const command = createLLMRouterCommand();
await executeCommand(command, ['providers', '--json']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const providers = JSON.parse(jsonOutput);
const providerNames = providers.map((p: { provider: string }) => p.provider);
for (const expected of expectedProviders) {
expect(providerNames).toContain(expected);
}
});
it('should support 4 routing modes', async () => {
const modes = ['manual', 'rule-based', 'cost-optimized', 'performance-optimized'];
for (const mode of modes) {
mockConsoleLog.mockClear();
const command = createLLMRouterCommand();
await executeCommand(command, ['route', 'test task', '--json', '--mode', mode]);
expect(mockConsoleLog).toHaveBeenCalled();
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const decision = JSON.parse(jsonOutput);
expect(decision).toHaveProperty('provider');
expect(decision).toHaveProperty('model');
}
});
it('should provide accurate cost estimates', async () => {
const command = createLLMRouterCommand();
await executeCommand(command, ['cost', 'gpt-4o-mini', '--json', '--tokens', '1000000']);
const jsonOutput = mockConsoleLog.mock.calls[0][0];
const costEstimate = JSON.parse(jsonOutput);
// GPT-4o-mini pricing: $0.15 input, $0.6 output per 1M tokens
expect(costEstimate.inputCostPer1M).toBe(0.15);
expect(costEstimate.outputCostPer1M).toBe(0.6);
// At 1M tokens, cost should be: 0.15 + 0.6 = 0.75
expect(costEstimate.estimatedTotalCost).toBeCloseTo(0.75, 2);
});
});