Add LLM router skeleton (intent + routing.yaml)
This commit is contained in:
parent
b717724275
commit
8a7c2c8d9a
247
src/routing/llm-router/index.ts
Normal file
247
src/routing/llm-router/index.ts
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import type { ModelRef } from "../../agents/model-selection.js";
|
||||||
|
import { parseModelRef } from "../../agents/model-selection.js";
|
||||||
|
|
||||||
|
export type Intent = "chat" | "strategy" | "code" | "summarize" | "tool" | "continuity";
|
||||||
|
|
||||||
|
export type RouteDecision = {
|
||||||
|
intent: Intent;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
reason: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
fallbacks?: ModelRef[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type IntentRoutingSpec = {
|
||||||
|
primary?: string;
|
||||||
|
fallbacks?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouterRoutingConfig = {
|
||||||
|
intents?: Partial<Record<Intent, IntentRoutingSpec>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RouterPolicyConfig = {
|
||||||
|
complexity?: {
|
||||||
|
contextTokensGe?: number;
|
||||||
|
target?: string;
|
||||||
|
};
|
||||||
|
guardrails?: {
|
||||||
|
highStakes?: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouterConfig = {
|
||||||
|
routing?: RouterRoutingConfig;
|
||||||
|
policy?: RouterPolicyConfig;
|
||||||
|
limitsRaw?: string;
|
||||||
|
pricingRaw?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTENTS: Intent[] = ["chat", "strategy", "code", "summarize", "tool", "continuity"];
|
||||||
|
|
||||||
|
const DEFAULT_INTENT_ROUTES: Partial<
|
||||||
|
Record<Intent, { primary: ModelRef; fallbacks?: ModelRef[] }>
|
||||||
|
> = {
|
||||||
|
chat: {
|
||||||
|
primary: { provider: "anthropic", model: "haiku" },
|
||||||
|
fallbacks: [{ provider: "anthropic", model: "sonnet" }],
|
||||||
|
},
|
||||||
|
strategy: {
|
||||||
|
primary: { provider: "anthropic", model: "sonnet" },
|
||||||
|
fallbacks: [{ provider: "anthropic", model: "haiku" }],
|
||||||
|
},
|
||||||
|
code: {
|
||||||
|
primary: { provider: "openai-codex", model: "codex" },
|
||||||
|
},
|
||||||
|
summarize: {
|
||||||
|
primary: { provider: "anthropic", model: "haiku" },
|
||||||
|
},
|
||||||
|
continuity: {
|
||||||
|
primary: { provider: "local", model: "local_small" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOptionalString(value: unknown): string | undefined {
|
||||||
|
if (typeof value !== "string") return undefined;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringArray(value: unknown): string[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
return value
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOptionalFile(filePath: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return await fs.readFile(filePath, "utf-8");
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as NodeJS.ErrnoException;
|
||||||
|
if (err.code === "ENOENT") return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRoutingConfig(raw: string): RouterRoutingConfig {
|
||||||
|
const parsed = YAML.parse(raw) as unknown;
|
||||||
|
if (!isRecord(parsed)) return {};
|
||||||
|
const intentsRaw = isRecord(parsed.intents) ? parsed.intents : undefined;
|
||||||
|
if (!intentsRaw) return {};
|
||||||
|
|
||||||
|
const intents: Partial<Record<Intent, IntentRoutingSpec>> = {};
|
||||||
|
for (const intent of INTENTS) {
|
||||||
|
const entry = intentsRaw[intent];
|
||||||
|
if (!isRecord(entry)) continue;
|
||||||
|
const primary = toOptionalString(entry.primary);
|
||||||
|
const fallbacks = toStringArray(entry.fallbacks);
|
||||||
|
if (!primary && !fallbacks) continue;
|
||||||
|
intents[intent] = {
|
||||||
|
...(primary ? { primary } : {}),
|
||||||
|
...(fallbacks ? { fallbacks } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { intents };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePolicyConfig(raw: string): RouterPolicyConfig {
|
||||||
|
const parsed = YAML.parse(raw) as unknown;
|
||||||
|
if (!isRecord(parsed)) return {};
|
||||||
|
const complexityRaw = isRecord(parsed.complexity) ? parsed.complexity : undefined;
|
||||||
|
const guardrailsRaw = isRecord(parsed.guardrails) ? parsed.guardrails : undefined;
|
||||||
|
|
||||||
|
const contextTokensGe = (() => {
|
||||||
|
const value =
|
||||||
|
complexityRaw?.context_tokens_ge ??
|
||||||
|
complexityRaw?.contextTokensGe ??
|
||||||
|
complexityRaw?.threshold;
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const target = toOptionalString(complexityRaw?.target);
|
||||||
|
const highStakes = (() => {
|
||||||
|
const value = guardrailsRaw?.high_stakes ?? guardrailsRaw?.highStakes;
|
||||||
|
return typeof value === "boolean" ? value : undefined;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const hasComplexity = contextTokensGe !== undefined || target !== undefined;
|
||||||
|
return {
|
||||||
|
...(hasComplexity
|
||||||
|
? {
|
||||||
|
complexity: {
|
||||||
|
...(contextTokensGe !== undefined ? { contextTokensGe } : {}),
|
||||||
|
...(target ? { target } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(highStakes !== undefined ? { guardrails: { highStakes } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseModelRefOrNull(raw: string | undefined, defaultProvider: string): ModelRef | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
return parseModelRef(raw, defaultProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFallbackRefs(
|
||||||
|
fallbacks: string[] | undefined,
|
||||||
|
defaultProvider: string,
|
||||||
|
): ModelRef[] | undefined {
|
||||||
|
if (!fallbacks) return undefined;
|
||||||
|
const parsed = fallbacks
|
||||||
|
.map((entry) => parseModelRef(entry, defaultProvider))
|
||||||
|
.filter((entry): entry is ModelRef => !!entry);
|
||||||
|
return parsed.length > 0 ? parsed : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRouterConfig(dir: string): Promise<RouterConfig | null> {
|
||||||
|
const [routingRaw, limitsRaw, pricingRaw, policyRaw] = await Promise.all([
|
||||||
|
readOptionalFile(path.join(dir, "routing.yaml")),
|
||||||
|
readOptionalFile(path.join(dir, "limits.yaml")),
|
||||||
|
readOptionalFile(path.join(dir, "pricing.yaml")),
|
||||||
|
readOptionalFile(path.join(dir, "policy.yaml")),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!routingRaw && !limitsRaw && !pricingRaw && !policyRaw) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(routingRaw ? { routing: parseRoutingConfig(routingRaw) } : {}),
|
||||||
|
...(policyRaw ? { policy: parsePolicyConfig(policyRaw) } : {}),
|
||||||
|
...(limitsRaw ? { limitsRaw } : {}),
|
||||||
|
...(pricingRaw ? { pricingRaw } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRouteDecision(params: {
|
||||||
|
cfg: RouterConfig | null;
|
||||||
|
agentDir?: string;
|
||||||
|
intent: Intent;
|
||||||
|
defaultModelRef: ModelRef;
|
||||||
|
contextTokens?: number;
|
||||||
|
highStakes?: boolean;
|
||||||
|
}): RouteDecision {
|
||||||
|
const { cfg, intent, defaultModelRef, contextTokens } = params;
|
||||||
|
if (!cfg) {
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
provider: defaultModelRef.provider,
|
||||||
|
model: defaultModelRef.model,
|
||||||
|
reason: "default",
|
||||||
|
isDefault: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseRoute = DEFAULT_INTENT_ROUTES[intent];
|
||||||
|
let primary = baseRoute?.primary ?? defaultModelRef;
|
||||||
|
let fallbacks = baseRoute?.fallbacks;
|
||||||
|
const defaultProvider = defaultModelRef.provider;
|
||||||
|
|
||||||
|
const intentRouting = cfg.routing?.intents?.[intent];
|
||||||
|
const overridePrimary = parseModelRefOrNull(intentRouting?.primary, defaultProvider);
|
||||||
|
if (overridePrimary) {
|
||||||
|
primary = overridePrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (intentRouting && "fallbacks" in intentRouting) {
|
||||||
|
fallbacks = resolveFallbackRefs(intentRouting.fallbacks, defaultProvider) ?? fallbacks;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldEscalate =
|
||||||
|
(intent === "chat" || intent === "strategy") &&
|
||||||
|
cfg.policy?.complexity?.contextTokensGe !== undefined &&
|
||||||
|
contextTokens !== undefined &&
|
||||||
|
contextTokens >= cfg.policy.complexity.contextTokensGe;
|
||||||
|
|
||||||
|
if (shouldEscalate) {
|
||||||
|
const target = parseModelRefOrNull(cfg.policy?.complexity?.target, defaultProvider) ?? {
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "opus",
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
provider: target.provider,
|
||||||
|
model: target.model,
|
||||||
|
reason: "complexity",
|
||||||
|
isDefault: false,
|
||||||
|
...(fallbacks && fallbacks.length > 0 ? { fallbacks } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
intent,
|
||||||
|
provider: primary.provider,
|
||||||
|
model: primary.model,
|
||||||
|
reason: "intent",
|
||||||
|
isDefault: false,
|
||||||
|
...(fallbacks && fallbacks.length > 0 ? { fallbacks } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
141
src/routing/llm-router/llm-router.test.ts
Normal file
141
src/routing/llm-router/llm-router.test.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { ModelRef } from "../../agents/model-selection.js";
|
||||||
|
import { loadRouterConfig, resolveRouteDecision } from "./index.js";
|
||||||
|
|
||||||
|
async function withTempDir<T>(runner: (dir: string) => Promise<T>): Promise<T> {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "moltbot-router-"));
|
||||||
|
try {
|
||||||
|
return await runner(dir);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultModelRef: ModelRef = { provider: "anthropic", model: "default" };
|
||||||
|
|
||||||
|
describe("llm-router", () => {
|
||||||
|
it("returns default when config dir is missing or empty", async () => {
|
||||||
|
const missingDir = path.join(os.tmpdir(), `moltbot-router-missing-${Date.now()}`);
|
||||||
|
const missingCfg = await loadRouterConfig(missingDir);
|
||||||
|
expect(missingCfg).toBeNull();
|
||||||
|
|
||||||
|
const decision = resolveRouteDecision({
|
||||||
|
cfg: missingCfg,
|
||||||
|
intent: "chat",
|
||||||
|
defaultModelRef,
|
||||||
|
});
|
||||||
|
expect(decision).toEqual({
|
||||||
|
intent: "chat",
|
||||||
|
provider: "anthropic",
|
||||||
|
model: "default",
|
||||||
|
reason: "default",
|
||||||
|
isDefault: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
const emptyCfg = await loadRouterConfig(dir);
|
||||||
|
expect(emptyCfg).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves routing.yaml intents", async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, "routing.yaml"),
|
||||||
|
[
|
||||||
|
"intents:",
|
||||||
|
" chat:",
|
||||||
|
" primary: anthropic/haiku",
|
||||||
|
" fallbacks:",
|
||||||
|
" - anthropic/sonnet",
|
||||||
|
" strategy:",
|
||||||
|
" primary: anthropic/sonnet",
|
||||||
|
" fallbacks:",
|
||||||
|
" - anthropic/haiku",
|
||||||
|
" code:",
|
||||||
|
" primary: openai-codex/codex",
|
||||||
|
" summarize:",
|
||||||
|
" primary: anthropic/haiku",
|
||||||
|
" continuity:",
|
||||||
|
" primary: local/local_small",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg = await loadRouterConfig(dir);
|
||||||
|
expect(cfg).not.toBeNull();
|
||||||
|
|
||||||
|
expect(resolveRouteDecision({ cfg, intent: "chat", defaultModelRef }).fallbacks).toEqual([
|
||||||
|
{ provider: "anthropic", model: "sonnet" },
|
||||||
|
]);
|
||||||
|
expect(resolveRouteDecision({ cfg, intent: "chat", defaultModelRef }).provider).toBe(
|
||||||
|
"anthropic",
|
||||||
|
);
|
||||||
|
expect(resolveRouteDecision({ cfg, intent: "chat", defaultModelRef }).model).toBe("haiku");
|
||||||
|
|
||||||
|
const strategy = resolveRouteDecision({ cfg, intent: "strategy", defaultModelRef });
|
||||||
|
expect(strategy.provider).toBe("anthropic");
|
||||||
|
expect(strategy.model).toBe("sonnet");
|
||||||
|
|
||||||
|
const code = resolveRouteDecision({ cfg, intent: "code", defaultModelRef });
|
||||||
|
expect(code.provider).toBe("openai-codex");
|
||||||
|
expect(code.model).toBe("codex");
|
||||||
|
|
||||||
|
const summarize = resolveRouteDecision({ cfg, intent: "summarize", defaultModelRef });
|
||||||
|
expect(summarize.provider).toBe("anthropic");
|
||||||
|
expect(summarize.model).toBe("haiku");
|
||||||
|
|
||||||
|
const continuity = resolveRouteDecision({ cfg, intent: "continuity", defaultModelRef });
|
||||||
|
expect(continuity.provider).toBe("local");
|
||||||
|
expect(continuity.model).toBe("local_small");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escalates chat/strategy to opus based on complexity policy", async () => {
|
||||||
|
await withTempDir(async (dir) => {
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, "routing.yaml"),
|
||||||
|
[
|
||||||
|
"intents:",
|
||||||
|
" chat:",
|
||||||
|
" primary: anthropic/haiku",
|
||||||
|
" strategy:",
|
||||||
|
" primary: anthropic/sonnet",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(dir, "policy.yaml"),
|
||||||
|
["complexity:", " context_tokens_ge: 2000", " target: anthropic/opus", ""].join("\n"),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
|
||||||
|
const cfg = await loadRouterConfig(dir);
|
||||||
|
const chat = resolveRouteDecision({
|
||||||
|
cfg,
|
||||||
|
intent: "chat",
|
||||||
|
defaultModelRef,
|
||||||
|
contextTokens: 2000,
|
||||||
|
});
|
||||||
|
const strategy = resolveRouteDecision({
|
||||||
|
cfg,
|
||||||
|
intent: "strategy",
|
||||||
|
defaultModelRef,
|
||||||
|
contextTokens: 2500,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chat.provider).toBe("anthropic");
|
||||||
|
expect(chat.model).toBe("opus");
|
||||||
|
expect(chat.reason).toBe("complexity");
|
||||||
|
|
||||||
|
expect(strategy.provider).toBe("anthropic");
|
||||||
|
expect(strategy.model).toBe("opus");
|
||||||
|
expect(strategy.reason).toBe("complexity");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user