Files
proffesor-for-testing__agen…/tests/integration/llm/multi-provider.test.ts
Dragan Spiridonov 4a641e7ef1 fix(adr-093): post-audit sweep — CI workflow, consensus provider, MCP sampling, 12 skill evals, 9 test files
Fifth post-audit sweep. 24 files, +66/-60.

CRITICAL (user-facing):
* .github/workflows/skill-validation.yml:422: CI eval runner
  --model claude-sonnet-4 → claude-sonnet-4-6. Previously CI would
  target a retiring model.
* src/coordination/consensus/providers/claude-provider.ts: Claude 3.x
  consensus provider modernized — type union extended with
  claude-sonnet-4-6 / claude-opus-4-7 / claude-haiku-4-5 as first
  entries, default model updated from claude-3-5-sonnet-20241022
  to claude-sonnet-4-6, availableModels list prepends new models.
  Legacy 3.x entries retained for backward compat.
* src/mcp/security/sampling-server.ts: default model
  claude-3-sonnet-20240229 (retired 2026-02-19) → claude-sonnet-4-6.

Skill evals (12 files, 6 + 6 mirrors):
* .claude/skills/ and assets/skills/ mirrors for:
  performance-testing, chaos-engineering-resilience, quality-metrics,
  localization-testing, accessibility-testing,
  n8n-trigger-testing-strategies. models_to_test list updated to
  claude-sonnet-4-6 / claude-haiku-4-5.

Test files (9):
* tests/unit/coordination/consensus/providers/claude-provider.test.ts:
  default model, test descriptions, and references updated to 4.6/4.7.
* tests/unit/routing/economic-routing.test.ts: claude-3-opus-20240229
  usage → claude-opus-4-7.
* tests/unit/learning/skill-validation-learner.test.ts: claude-3-haiku
  / claude-3.5-sonnet / claude-3-opus → new canonicals.
* tests/unit/mcp/handlers/handler-test-utils.ts: claude-3-haiku →
  claude-haiku-4-5.
* tests/integration/llm/{real-provider-integration,multi-provider}.test.ts:
  bare 'claude-sonnet-4' canonical → 'claude-sonnet-4-6'.
* tests/integration/validation/parallel-eval-runner.test.ts:
  claude-3.5-sonnet / claude-3-haiku → new canonicals.
* tests/routing/advisor/multi-model-executor.test.ts:
  CRITICAL BUG — 'claude-opus-4-6' (non-existent model ID, typo) ×2
  → 'claude-opus-4-7'. Stale test name 'anthropic/claude-opus-4
  when no provider' → '-4.7'.
* tests/unit/opencode/degradation.test.ts: CRITICAL BUG —
  modelId 'claude-opus-4-6' (typo) → 'claude-opus-4-7'.

Left intentionally untouched:
* tests/unit/coordination/consensus/providers/openrouter-provider.test.ts:
  tests that OpenRouter provider lists claude-3-haiku / claude-3.5-sonnet
  / claude-3-opus as supported. These assertions reflect actual
  OpenRouter provider data, which hasn't changed. Adding new-model
  assertions would be an enhancement, not a fix.

Verification:
* 0 tsc errors
* 174/174 tests across touched paths pass

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:26:23 +00:00

400 lines
13 KiB
TypeScript

/**
* Agentic QE v3 - Multi-Provider Integration Tests
* ADR-043: Vendor-Independent LLM Support - Milestone 12
*
* Tests HybridRouter with multiple providers configured including:
* - Provider registration and discovery
* - Model ID normalization across providers
* - Cost calculation accuracy
* - Token tracking integration
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { HybridRouter, createHybridRouter, createQERouter } from '../../../src/shared/llm/router/hybrid-router';
import { ProviderManager } from '../../../src/shared/llm/provider-manager';
import {
DEFAULT_ROUTER_CONFIG,
DEFAULT_FALLBACK_CHAIN,
ALL_PROVIDER_TYPES,
type ExtendedProviderType,
type ChatParams,
type RoutingDecision,
} from '../../../src/shared/llm/router/types';
import {
mapModelId,
normalizeModelId,
getCanonicalName,
MODEL_MAPPINGS,
} from '../../../src/shared/llm/model-mapping';
import { ClaudeProvider, OpenAIProvider, OllamaProvider } from '../../../src/shared/llm/providers';
// Mock fetch globally
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('Multi-Provider Integration Tests', () => {
let providerManager: ProviderManager;
let router: HybridRouter;
beforeEach(async () => {
vi.clearAllMocks();
providerManager = new ProviderManager({
defaultProvider: 'claude',
providers: {
claude: { apiKey: 'test-claude-key' },
openai: { apiKey: 'test-openai-key' },
ollama: { baseUrl: 'http://localhost:11434' },
},
});
await providerManager.initialize();
router = createHybridRouter(providerManager);
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Provider Registration and Discovery', () => {
it('should register multiple providers from configuration', () => {
const availableProviders = providerManager.getAvailableProviders();
expect(availableProviders).toContain('claude');
expect(availableProviders).toContain('openai');
expect(availableProviders).toContain('ollama');
});
it('should discover providers by type', () => {
const claudeProvider = providerManager.getProvider('claude');
const openaiProvider = providerManager.getProvider('openai');
const ollamaProvider = providerManager.getProvider('ollama');
expect(claudeProvider).toBeDefined();
expect(openaiProvider).toBeDefined();
expect(ollamaProvider).toBeDefined();
});
it('should return undefined for unregistered providers', () => {
const unknownProvider = providerManager.getProvider('gemini' as any);
expect(unknownProvider).toBeUndefined();
});
it('should track provider count', () => {
const providers = providerManager.getAvailableProviders();
expect(providers.length).toBeGreaterThanOrEqual(3);
});
it('should support all 7+ ADR-043 provider types', () => {
const expectedProviders: ExtendedProviderType[] = [
'claude', 'openai', 'ollama', 'openrouter',
'gemini', 'azure-openai', 'bedrock', 'onnx'
];
// Verify ALL_PROVIDER_TYPES includes all expected providers
for (const provider of expectedProviders) {
expect(ALL_PROVIDER_TYPES).toContain(provider);
}
expect(ALL_PROVIDER_TYPES.length).toBeGreaterThanOrEqual(7);
});
});
describe('Model ID Normalization', () => {
it('should map canonical ID to provider-specific ID', () => {
// Using the actual canonical ID format from model-mapping.ts
const claudeId = mapModelId('claude-sonnet-4-6', 'anthropic');
expect(claudeId).toBe('claude-sonnet-4-6');
});
it('should normalize provider-specific ID to canonical', () => {
const canonical = normalizeModelId('claude-sonnet-4-6');
expect(canonical).toBe('claude-sonnet-4-6');
});
it('should handle unknown model IDs by throwing error', () => {
// The model mapping throws an error for unknown models
expect(() => mapModelId('unknown-model-xyz', 'anthropic')).toThrow('Unknown model');
});
it('should support bidirectional mapping for Claude models', () => {
const original = 'claude-opus-4-5';
const providerId = mapModelId(original, 'anthropic');
const backToCanonical = normalizeModelId(providerId);
expect(providerId).toBe('claude-opus-4-7');
expect(backToCanonical).toBe(original);
});
it('should support bidirectional mapping for OpenAI models', () => {
const original = 'gpt-4o';
const providerId = mapModelId(original, 'openai');
const backToCanonical = normalizeModelId(providerId);
expect(providerId).toBe('gpt-4o');
expect(backToCanonical).toBe(original);
});
it('should get canonical name for display', () => {
const name = getCanonicalName('claude-sonnet-4-6');
expect(name).toBe('Claude Sonnet 4');
});
it('should have mappings for all major model families', () => {
const families = new Set(Object.values(MODEL_MAPPINGS).map(m => m.family));
expect(families.has('claude')).toBe(true);
expect(families.has('gpt')).toBe(true);
expect(families.has('gemini')).toBe(true);
expect(families.has('llama')).toBe(true);
});
});
describe('HybridRouter with Multiple Providers', () => {
it('should initialize router with provider manager', async () => {
await router.initialize();
const config = router.getConfig();
expect(config.mode).toBe('rule-based');
expect(config.defaultProvider).toBe('claude');
});
it('should select provider based on routing mode', async () => {
await router.initialize();
const params: ChatParams = {
messages: [{ role: 'user', content: 'Hello' }],
agentType: 'tester',
};
const decision = await router.selectProvider(params);
expect(decision).toBeDefined();
expect(decision.providerType).toBeDefined();
expect(decision.model).toBeDefined();
expect(decision.reason).toBeDefined();
});
it('should support changing routing modes', async () => {
await router.initialize();
router.setMode('manual');
expect(router.getMode()).toBe('manual');
router.setMode('cost-optimized');
expect(router.getMode()).toBe('cost-optimized');
router.setMode('performance-optimized');
expect(router.getMode()).toBe('performance-optimized');
router.setMode('rule-based');
expect(router.getMode()).toBe('rule-based');
});
it('should use QE-specific router configuration', async () => {
const qeRouter = createQERouter(providerManager);
await qeRouter.initialize();
const config = qeRouter.getConfig();
expect(config.mode).toBe('rule-based');
expect(config.defaultProvider).toBe('claude');
expect(config.defaultModel).toBe('claude-sonnet-4-6');
expect(config.enableMetrics).toBe(true);
expect(config.cacheDecisions).toBe(true);
});
});
describe('Cost Calculation Accuracy', () => {
it('should calculate cost for Claude provider', () => {
const claudeProvider = providerManager.getProvider('claude');
expect(claudeProvider).toBeDefined();
const cost = claudeProvider!.getCostPerToken();
expect(cost.input).toBeGreaterThan(0);
expect(cost.output).toBeGreaterThan(0);
expect(cost.output).toBeGreaterThan(cost.input); // Output typically costs more
});
it('should calculate cost for OpenAI provider', () => {
const openaiProvider = providerManager.getProvider('openai');
expect(openaiProvider).toBeDefined();
const cost = openaiProvider!.getCostPerToken();
expect(cost.input).toBeGreaterThan(0);
expect(cost.output).toBeGreaterThan(0);
});
it('should report zero cost for local Ollama provider', () => {
const ollamaProvider = providerManager.getProvider('ollama');
expect(ollamaProvider).toBeDefined();
const cost = ollamaProvider!.getCostPerToken();
expect(cost.input).toBe(0);
expect(cost.output).toBe(0);
});
it('should estimate cost in routing decision', async () => {
await router.initialize();
router.setMode('cost-optimized');
const params: ChatParams = {
messages: [{ role: 'user', content: 'Generate a test case for authentication' }],
maxTokens: 1000,
};
const decision = await router.selectProvider(params);
// Cost-optimized mode should include cost estimate
if (decision.metadata.estimatedCost) {
expect(decision.metadata.estimatedCost.inputTokens).toBeGreaterThan(0);
expect(decision.metadata.estimatedCost.outputTokens).toBeGreaterThan(0);
expect(decision.metadata.estimatedCost.totalCostUsd).toBeDefined();
}
});
it('should consider alternatives in cost optimization', async () => {
await router.initialize();
router.setMode('cost-optimized');
const params: ChatParams = {
messages: [{ role: 'user', content: 'Hello' }],
};
const decision = await router.selectProvider(params);
if (decision.metadata.alternativesConsidered) {
expect(decision.metadata.alternativesConsidered.length).toBeGreaterThan(0);
for (const alt of decision.metadata.alternativesConsidered) {
expect(alt.provider).toBeDefined();
expect(alt.reason).toBeDefined();
}
}
});
});
describe('Token Tracking Integration', () => {
it('should track token usage in response', async () => {
await router.initialize();
// Mock successful response
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Test response' }],
model: 'claude-sonnet-4-6',
stop_reason: 'end_turn',
usage: { input_tokens: 50, output_tokens: 100 },
}),
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test message' }],
preferredProvider: 'claude',
};
router.setMode('manual');
const response = await router.chat(params);
expect(response.usage).toBeDefined();
expect(response.usage.promptTokens).toBe(50);
expect(response.usage.completionTokens).toBe(100);
expect(response.usage.totalTokens).toBe(150);
});
it('should include cost information in response', async () => {
await router.initialize();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Response with cost' }],
model: 'claude-sonnet-4-6',
stop_reason: 'end_turn',
usage: { input_tokens: 100, output_tokens: 200 },
}),
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test' }],
preferredProvider: 'claude',
};
router.setMode('manual');
const response = await router.chat(params);
expect(response.cost).toBeDefined();
expect(response.cost.totalCost).toBeGreaterThan(0);
expect(response.cost.inputCost).toBeDefined();
expect(response.cost.outputCost).toBeDefined();
});
it('should track latency in response', async () => {
await router.initialize();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({
id: 'msg_test',
type: 'message',
role: 'assistant',
content: [{ type: 'text', text: 'Fast response' }],
model: 'claude-sonnet-4-6',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 20 },
}),
});
const params: ChatParams = {
messages: [{ role: 'user', content: 'Test' }],
preferredProvider: 'claude',
};
router.setMode('manual');
const response = await router.chat(params);
expect(response.latencyMs).toBeDefined();
expect(response.latencyMs).toBeGreaterThanOrEqual(0);
});
});
describe('Provider Configuration', () => {
it('should respect default provider configuration', () => {
const config = router.getConfig();
expect(config.defaultProvider).toBe('claude');
expect(config.defaultModel).toBe('claude-sonnet-4-6');
});
it('should have valid fallback chain configuration', () => {
const config = router.getConfig();
expect(config.fallbackChain).toBeDefined();
expect(config.fallbackChain.entries.length).toBeGreaterThan(0);
expect(config.fallbackChain.maxRetries).toBeGreaterThan(0);
});
it('should support updating configuration', () => {
const newConfig = {
mode: 'cost-optimized' as const,
defaultModel: 'claude-haiku-4-5-20251001',
};
router.updateConfig(newConfig);
const config = router.getConfig();
expect(config.mode).toBe('cost-optimized');
expect(config.defaultModel).toBe('claude-haiku-4-5-20251001');
});
it('should have default fallback entries for all base providers', () => {
const fallbackProviders = DEFAULT_FALLBACK_CHAIN.entries.map(e => e.provider);
expect(fallbackProviders).toContain('claude');
expect(fallbackProviders).toContain('openai');
expect(fallbackProviders).toContain('ollama');
});
});
});