Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
461 lines
16 KiB
TypeScript
461 lines
16 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { RoutingEngineService } from './routing-engine.service.js';
|
|
import type { RoutingRule, TaskClassification } from './routing.types.js';
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function makeRule(
|
|
overrides: Partial<RoutingRule> &
|
|
Pick<RoutingRule, 'name' | 'priority' | 'conditions' | 'action'>,
|
|
): RoutingRule {
|
|
return {
|
|
id: overrides.id ?? crypto.randomUUID(),
|
|
scope: 'system',
|
|
enabled: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeClassification(overrides: Partial<TaskClassification> = {}): TaskClassification {
|
|
return {
|
|
taskType: 'conversation',
|
|
complexity: 'simple',
|
|
domain: 'general',
|
|
requiredCapabilities: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
/** Build a minimal RoutingEngineService with mocked DB and ProviderService. */
|
|
function makeService(
|
|
rules: RoutingRule[] = [],
|
|
healthMap: Record<string, { status: string }> = {},
|
|
): RoutingEngineService {
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue(
|
|
rules.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
priority: r.priority,
|
|
scope: r.scope,
|
|
userId: r.userId ?? null,
|
|
conditions: r.conditions,
|
|
action: r.action,
|
|
enabled: r.enabled,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})),
|
|
),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
|
|
const mockProviderService = {
|
|
healthCheckAll: vi.fn().mockResolvedValue(healthMap),
|
|
};
|
|
|
|
// Inject mocked dependencies directly (bypass NestJS DI for unit tests)
|
|
const service = new (RoutingEngineService as unknown as new (
|
|
db: unknown,
|
|
ps: unknown,
|
|
) => RoutingEngineService)(mockDb, mockProviderService);
|
|
|
|
return service;
|
|
}
|
|
|
|
// ─── matchConditions ──────────────────────────────────────────────────────────
|
|
|
|
describe('RoutingEngineService.matchConditions', () => {
|
|
let service: RoutingEngineService;
|
|
|
|
beforeEach(() => {
|
|
service = makeService();
|
|
});
|
|
|
|
it('returns true for empty conditions array (catch-all rule)', () => {
|
|
const rule = makeRule({
|
|
name: 'fallback',
|
|
priority: 99,
|
|
conditions: [],
|
|
action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
});
|
|
expect(service.matchConditions(rule, makeClassification())).toBe(true);
|
|
});
|
|
|
|
it('matches eq operator on scalar field', () => {
|
|
const rule = makeRule({
|
|
name: 'coding',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
});
|
|
expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(true);
|
|
expect(service.matchConditions(rule, makeClassification({ taskType: 'conversation' }))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('matches in operator: field value is in the condition array', () => {
|
|
const rule = makeRule({
|
|
name: 'simple or moderate',
|
|
priority: 2,
|
|
conditions: [{ field: 'complexity', operator: 'in', value: ['simple', 'moderate'] }],
|
|
action: { provider: 'anthropic', model: 'claude-haiku-4-5' },
|
|
});
|
|
expect(service.matchConditions(rule, makeClassification({ complexity: 'simple' }))).toBe(true);
|
|
expect(service.matchConditions(rule, makeClassification({ complexity: 'moderate' }))).toBe(
|
|
true,
|
|
);
|
|
expect(service.matchConditions(rule, makeClassification({ complexity: 'complex' }))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('matches includes operator: field array includes the condition value', () => {
|
|
const rule = makeRule({
|
|
name: 'reasoning required',
|
|
priority: 3,
|
|
conditions: [{ field: 'requiredCapabilities', operator: 'includes', value: 'reasoning' }],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
});
|
|
expect(
|
|
service.matchConditions(rule, makeClassification({ requiredCapabilities: ['reasoning'] })),
|
|
).toBe(true);
|
|
expect(
|
|
service.matchConditions(
|
|
rule,
|
|
makeClassification({ requiredCapabilities: ['tools', 'reasoning'] }),
|
|
),
|
|
).toBe(true);
|
|
expect(
|
|
service.matchConditions(rule, makeClassification({ requiredCapabilities: ['tools'] })),
|
|
).toBe(false);
|
|
expect(service.matchConditions(rule, makeClassification({ requiredCapabilities: [] }))).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it('requires ALL conditions to match (AND logic)', () => {
|
|
const rule = makeRule({
|
|
name: 'complex coding',
|
|
priority: 1,
|
|
conditions: [
|
|
{ field: 'taskType', operator: 'eq', value: 'coding' },
|
|
{ field: 'complexity', operator: 'eq', value: 'complex' },
|
|
],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
});
|
|
|
|
// Both match
|
|
expect(
|
|
service.matchConditions(
|
|
rule,
|
|
makeClassification({ taskType: 'coding', complexity: 'complex' }),
|
|
),
|
|
).toBe(true);
|
|
|
|
// Only one matches
|
|
expect(
|
|
service.matchConditions(
|
|
rule,
|
|
makeClassification({ taskType: 'coding', complexity: 'simple' }),
|
|
),
|
|
).toBe(false);
|
|
|
|
// Neither matches
|
|
expect(
|
|
service.matchConditions(
|
|
rule,
|
|
makeClassification({ taskType: 'conversation', complexity: 'simple' }),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it('returns false for eq when condition value is an array (type mismatch)', () => {
|
|
const rule = makeRule({
|
|
name: 'bad eq',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: ['coding', 'research'] }],
|
|
action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
});
|
|
expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(false);
|
|
});
|
|
|
|
it('returns false for includes when field is not an array', () => {
|
|
const rule = makeRule({
|
|
name: 'bad includes',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'includes', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
});
|
|
// taskType is a string, not an array — should be false
|
|
expect(service.matchConditions(rule, makeClassification({ taskType: 'coding' }))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ─── resolve — priority ordering ─────────────────────────────────────────────
|
|
|
|
describe('RoutingEngineService.resolve — priority ordering', () => {
|
|
it('selects the highest-priority matching rule', async () => {
|
|
// Rules are supplied in priority-ascending order, as the DB would return them.
|
|
const rules = [
|
|
makeRule({
|
|
name: 'high priority',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
}),
|
|
makeRule({
|
|
name: 'low priority',
|
|
priority: 10,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'openai', model: 'gpt-4o' },
|
|
}),
|
|
];
|
|
|
|
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
|
|
|
const decision = await service.resolve('implement a function');
|
|
expect(decision.ruleName).toBe('high priority');
|
|
expect(decision.provider).toBe('anthropic');
|
|
expect(decision.model).toBe('claude-opus-4-6');
|
|
});
|
|
|
|
it('skips non-matching rules and picks first match', async () => {
|
|
const rules = [
|
|
makeRule({
|
|
name: 'research rule',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'research' }],
|
|
action: { provider: 'openai', model: 'gpt-4o' },
|
|
}),
|
|
makeRule({
|
|
name: 'coding rule',
|
|
priority: 2,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
}),
|
|
];
|
|
|
|
const service = makeService(rules, { anthropic: { status: 'up' }, openai: { status: 'up' } });
|
|
|
|
const decision = await service.resolve('implement a function');
|
|
expect(decision.ruleName).toBe('coding rule');
|
|
expect(decision.provider).toBe('anthropic');
|
|
});
|
|
});
|
|
|
|
// ─── resolve — unhealthy provider fallback ────────────────────────────────────
|
|
|
|
describe('RoutingEngineService.resolve — unhealthy provider handling', () => {
|
|
it('skips matched rule when provider is unhealthy, tries next rule', async () => {
|
|
const rules = [
|
|
makeRule({
|
|
name: 'primary rule',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
}),
|
|
makeRule({
|
|
name: 'secondary rule',
|
|
priority: 2,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'openai', model: 'gpt-4o' },
|
|
}),
|
|
];
|
|
|
|
const service = makeService(rules, {
|
|
anthropic: { status: 'down' }, // primary is unhealthy
|
|
openai: { status: 'up' },
|
|
});
|
|
|
|
const decision = await service.resolve('implement a function');
|
|
expect(decision.ruleName).toBe('secondary rule');
|
|
expect(decision.provider).toBe('openai');
|
|
});
|
|
|
|
it('falls back to Sonnet when all rules have unhealthy providers', async () => {
|
|
// Override the rule's provider to something unhealthy but keep anthropic up for fallback
|
|
const unhealthyRules = [
|
|
makeRule({
|
|
name: 'only rule',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'openai', model: 'gpt-4o' }, // openai is unhealthy
|
|
}),
|
|
];
|
|
|
|
const service2 = makeService(unhealthyRules, {
|
|
anthropic: { status: 'up' },
|
|
openai: { status: 'down' },
|
|
});
|
|
|
|
const decision = await service2.resolve('implement a function');
|
|
// Should fall through to Sonnet fallback on anthropic
|
|
expect(decision.provider).toBe('anthropic');
|
|
expect(decision.model).toBe('claude-sonnet-4-6');
|
|
expect(decision.ruleName).toBe('fallback');
|
|
});
|
|
|
|
it('falls back to Haiku when Sonnet provider is also down', async () => {
|
|
const rules: RoutingRule[] = []; // no rules
|
|
|
|
const service = makeService(rules, {
|
|
anthropic: { status: 'down' }, // Sonnet is on anthropic — down
|
|
ollama: { status: 'up' }, // Haiku is also on anthropic — use Ollama as next
|
|
});
|
|
|
|
const decision = await service.resolve('hello there');
|
|
// Sonnet (anthropic) is down, Haiku (anthropic) is down, Ollama is up
|
|
expect(decision.provider).toBe('ollama');
|
|
expect(decision.model).toBe('llama3.2');
|
|
expect(decision.ruleName).toBe('fallback');
|
|
});
|
|
|
|
it('uses last resort (Sonnet) when all fallback providers are unhealthy', async () => {
|
|
const rules: RoutingRule[] = [];
|
|
|
|
const service = makeService(rules, {
|
|
anthropic: { status: 'down' },
|
|
ollama: { status: 'down' },
|
|
});
|
|
|
|
const decision = await service.resolve('hello');
|
|
// All unhealthy — still returns first fallback entry as last resort
|
|
expect(decision.provider).toBe('anthropic');
|
|
expect(decision.model).toBe('claude-sonnet-4-6');
|
|
expect(decision.ruleName).toBe('fallback');
|
|
});
|
|
});
|
|
|
|
// ─── resolve — empty conditions (catch-all rule) ──────────────────────────────
|
|
|
|
describe('RoutingEngineService.resolve — empty conditions (fallback rule)', () => {
|
|
it('matches catch-all rule for any message', async () => {
|
|
const rules = [
|
|
makeRule({
|
|
name: 'catch-all',
|
|
priority: 99,
|
|
conditions: [],
|
|
action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
}),
|
|
];
|
|
|
|
const service = makeService(rules, { anthropic: { status: 'up' } });
|
|
|
|
const decision = await service.resolve('completely unrelated message xyz');
|
|
expect(decision.ruleName).toBe('catch-all');
|
|
expect(decision.provider).toBe('anthropic');
|
|
expect(decision.model).toBe('claude-sonnet-4-6');
|
|
});
|
|
|
|
it('catch-all is overridden by a higher-priority specific rule', async () => {
|
|
const rules = [
|
|
makeRule({
|
|
name: 'specific coding rule',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
}),
|
|
makeRule({
|
|
name: 'catch-all',
|
|
priority: 99,
|
|
conditions: [],
|
|
action: { provider: 'anthropic', model: 'claude-haiku-4-5' },
|
|
}),
|
|
];
|
|
|
|
const service = makeService(rules, { anthropic: { status: 'up' } });
|
|
|
|
const codingDecision = await service.resolve('implement a function');
|
|
expect(codingDecision.ruleName).toBe('specific coding rule');
|
|
expect(codingDecision.model).toBe('claude-opus-4-6');
|
|
|
|
const conversationDecision = await service.resolve('hello how are you');
|
|
expect(conversationDecision.ruleName).toBe('catch-all');
|
|
expect(conversationDecision.model).toBe('claude-haiku-4-5');
|
|
});
|
|
});
|
|
|
|
// ─── resolve — disabled rules ─────────────────────────────────────────────────
|
|
|
|
describe('RoutingEngineService.resolve — disabled rules', () => {
|
|
it('skips disabled rules', async () => {
|
|
const rules = [
|
|
makeRule({
|
|
name: 'disabled rule',
|
|
priority: 1,
|
|
enabled: false,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
}),
|
|
makeRule({
|
|
name: 'enabled fallback',
|
|
priority: 99,
|
|
conditions: [],
|
|
action: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
|
}),
|
|
];
|
|
|
|
const service = makeService(rules, { anthropic: { status: 'up' } });
|
|
|
|
const decision = await service.resolve('implement a function');
|
|
expect(decision.ruleName).toBe('enabled fallback');
|
|
expect(decision.model).toBe('claude-sonnet-4-6');
|
|
});
|
|
});
|
|
|
|
// ─── resolve — pre-fetched health map ────────────────────────────────────────
|
|
|
|
describe('RoutingEngineService.resolve — availableProviders override', () => {
|
|
it('uses the provided health map instead of calling healthCheckAll', async () => {
|
|
const rules = [
|
|
makeRule({
|
|
name: 'coding rule',
|
|
priority: 1,
|
|
conditions: [{ field: 'taskType', operator: 'eq', value: 'coding' }],
|
|
action: { provider: 'anthropic', model: 'claude-opus-4-6' },
|
|
}),
|
|
];
|
|
|
|
const mockHealthCheckAll = vi.fn().mockResolvedValue({});
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
orderBy: vi.fn().mockResolvedValue(
|
|
rules.map((r) => ({
|
|
id: r.id,
|
|
name: r.name,
|
|
priority: r.priority,
|
|
scope: r.scope,
|
|
userId: r.userId ?? null,
|
|
conditions: r.conditions,
|
|
action: r.action,
|
|
enabled: r.enabled,
|
|
createdAt: new Date(),
|
|
updatedAt: new Date(),
|
|
})),
|
|
),
|
|
}),
|
|
}),
|
|
}),
|
|
};
|
|
const mockProviderService = { healthCheckAll: mockHealthCheckAll };
|
|
|
|
const service = new (RoutingEngineService as unknown as new (
|
|
db: unknown,
|
|
ps: unknown,
|
|
) => RoutingEngineService)(mockDb, mockProviderService);
|
|
|
|
const preSupplied = { anthropic: { status: 'up' } };
|
|
await service.resolve('implement a function', undefined, preSupplied);
|
|
|
|
expect(mockHealthCheckAll).not.toHaveBeenCalled();
|
|
});
|
|
});
|