feat(routing): implement routing decision pipeline — M4-006
Add RoutingEngineService that classifies tasks, evaluates routing rules in priority order, checks provider health, and applies a Sonnet → Haiku → Ollama fallback chain when no healthy rule match is found. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
216
apps/gateway/src/agent/routing/routing-engine.service.ts
Normal file
216
apps/gateway/src/agent/routing/routing-engine.service.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { routingRules, type Db, and, asc, eq, or } from '@mosaic/db';
|
||||
import { DB } from '../../database/database.module.js';
|
||||
import { ProviderService } from '../provider.service.js';
|
||||
import { classifyTask } from './task-classifier.js';
|
||||
import type {
|
||||
RoutingCondition,
|
||||
RoutingRule,
|
||||
RoutingDecision,
|
||||
TaskClassification,
|
||||
} from './routing.types.js';
|
||||
|
||||
// ─── Injection tokens ────────────────────────────────────────────────────────
|
||||
|
||||
export const PROVIDER_SERVICE = Symbol('ProviderService');
|
||||
|
||||
// ─── Fallback chain ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordered fallback providers tried when no rule matches or all matched
|
||||
* providers are unhealthy.
|
||||
*/
|
||||
const FALLBACK_CHAIN: Array<{ provider: string; model: string }> = [
|
||||
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
||||
{ provider: 'anthropic', model: 'claude-haiku-4-5' },
|
||||
{ provider: 'ollama', model: 'llama3.2' },
|
||||
];
|
||||
|
||||
// ─── Service ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Injectable()
|
||||
export class RoutingEngineService {
|
||||
private readonly logger = new Logger(RoutingEngineService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(DB) private readonly db: Db,
|
||||
@Inject(ProviderService) private readonly providerService: ProviderService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Classify the message, evaluate routing rules in priority order, and return
|
||||
* the best routing decision.
|
||||
*
|
||||
* @param message - Raw user message text used for classification.
|
||||
* @param userId - Optional user ID for loading user-scoped rules.
|
||||
* @param availableProviders - Optional pre-fetched provider health map to
|
||||
* avoid redundant health checks inside tight loops.
|
||||
*/
|
||||
async resolve(
|
||||
message: string,
|
||||
userId?: string,
|
||||
availableProviders?: Record<string, { status: string }>,
|
||||
): Promise<RoutingDecision> {
|
||||
const classification = classifyTask(message);
|
||||
this.logger.debug(
|
||||
`Classification: taskType=${classification.taskType} complexity=${classification.complexity} domain=${classification.domain}`,
|
||||
);
|
||||
|
||||
// Load health data once (re-use caller-supplied map if provided)
|
||||
const health = availableProviders ?? (await this.providerService.healthCheckAll());
|
||||
|
||||
// Load all applicable rules ordered by priority
|
||||
const rules = await this.loadRules(userId);
|
||||
|
||||
// Evaluate rules in priority order
|
||||
for (const rule of rules) {
|
||||
if (!rule.enabled) continue;
|
||||
|
||||
if (!this.matchConditions(rule, classification)) continue;
|
||||
|
||||
const providerStatus = health[rule.action.provider]?.status;
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
|
||||
if (!isHealthy) {
|
||||
this.logger.debug(
|
||||
`Rule "${rule.name}" matched but provider "${rule.action.provider}" is unhealthy (status: ${providerStatus ?? 'unknown'})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`Rule matched: "${rule.name}" → ${rule.action.provider}/${rule.action.model}`,
|
||||
);
|
||||
|
||||
return {
|
||||
provider: rule.action.provider,
|
||||
model: rule.action.model,
|
||||
agentConfigId: rule.action.agentConfigId,
|
||||
ruleName: rule.name,
|
||||
reason: `Matched routing rule "${rule.name}"`,
|
||||
};
|
||||
}
|
||||
|
||||
// No rule matched (or all matched providers were unhealthy) — apply fallback chain
|
||||
this.logger.debug('No rule matched; applying fallback chain');
|
||||
return this.applyFallbackChain(health);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether all conditions of a rule match the given task classification.
|
||||
* An empty conditions array always matches (catch-all / fallback rule).
|
||||
*/
|
||||
matchConditions(
|
||||
rule: Pick<RoutingRule, 'conditions'>,
|
||||
classification: TaskClassification,
|
||||
): boolean {
|
||||
if (rule.conditions.length === 0) return true;
|
||||
|
||||
return rule.conditions.every((condition) => this.evaluateCondition(condition, classification));
|
||||
}
|
||||
|
||||
// ─── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private evaluateCondition(
|
||||
condition: RoutingCondition,
|
||||
classification: TaskClassification,
|
||||
): boolean {
|
||||
// `costTier` is a valid condition field but is not part of TaskClassification
|
||||
// (it is supplied via userOverrides / request context). Treat unknown fields as
|
||||
// undefined so conditions referencing them simply do not match.
|
||||
const fieldValue = (classification as unknown as Record<string, unknown>)[condition.field];
|
||||
|
||||
switch (condition.operator) {
|
||||
case 'eq': {
|
||||
// Scalar equality: field value must equal condition value (string)
|
||||
if (typeof condition.value !== 'string') return false;
|
||||
return fieldValue === condition.value;
|
||||
}
|
||||
|
||||
case 'in': {
|
||||
// Set membership: condition value (array) contains field value
|
||||
if (!Array.isArray(condition.value)) return false;
|
||||
return condition.value.includes(fieldValue as string);
|
||||
}
|
||||
|
||||
case 'includes': {
|
||||
// Array containment: field value (array) includes condition value (string)
|
||||
if (!Array.isArray(fieldValue)) return false;
|
||||
if (typeof condition.value !== 'string') return false;
|
||||
return (fieldValue as string[]).includes(condition.value);
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load routing rules from the database.
|
||||
* System rules + user-scoped rules (when userId is provided) are returned,
|
||||
* ordered by priority ascending.
|
||||
*/
|
||||
private async loadRules(userId?: string): Promise<RoutingRule[]> {
|
||||
const whereClause = userId
|
||||
? or(
|
||||
eq(routingRules.scope, 'system'),
|
||||
and(eq(routingRules.scope, 'user'), eq(routingRules.userId, userId)),
|
||||
)
|
||||
: eq(routingRules.scope, 'system');
|
||||
|
||||
const rows = await this.db
|
||||
.select()
|
||||
.from(routingRules)
|
||||
.where(whereClause)
|
||||
.orderBy(asc(routingRules.priority));
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
priority: row.priority,
|
||||
scope: row.scope as 'system' | 'user',
|
||||
userId: row.userId ?? undefined,
|
||||
conditions: (row.conditions as unknown as RoutingCondition[]) ?? [],
|
||||
action: row.action as unknown as {
|
||||
provider: string;
|
||||
model: string;
|
||||
agentConfigId?: string;
|
||||
systemPromptOverride?: string;
|
||||
toolAllowlist?: string[];
|
||||
},
|
||||
enabled: row.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the fallback chain and return the first healthy provider/model pair.
|
||||
* If none are healthy, return the first entry unconditionally (last resort).
|
||||
*/
|
||||
private applyFallbackChain(health: Record<string, { status: string }>): RoutingDecision {
|
||||
for (const candidate of FALLBACK_CHAIN) {
|
||||
const providerStatus = health[candidate.provider]?.status;
|
||||
const isHealthy = providerStatus === 'up' || providerStatus === 'ok';
|
||||
if (isHealthy) {
|
||||
this.logger.debug(`Fallback resolved: ${candidate.provider}/${candidate.model}`);
|
||||
return {
|
||||
provider: candidate.provider,
|
||||
model: candidate.model,
|
||||
ruleName: 'fallback',
|
||||
reason: `Fallback chain — no matching rule; selected ${candidate.provider}/${candidate.model}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// All providers in the fallback chain are unhealthy — use the first entry
|
||||
const lastResort = FALLBACK_CHAIN[0]!;
|
||||
this.logger.warn(
|
||||
`All fallback providers unhealthy; using last resort: ${lastResort.provider}/${lastResort.model}`,
|
||||
);
|
||||
return {
|
||||
provider: lastResort.provider,
|
||||
model: lastResort.model,
|
||||
ruleName: 'fallback',
|
||||
reason: `Fallback chain exhausted (all providers unhealthy); using ${lastResort.provider}/${lastResort.model}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
460
apps/gateway/src/agent/routing/routing-engine.test.ts
Normal file
460
apps/gateway/src/agent/routing/routing-engine.test.ts
Normal file
@@ -0,0 +1,460 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user