mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 16:22:19 +03:00
Compare commits
1 Commits
fix/9536-u
...
fix/codeql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
535c75b60a |
@@ -1 +0,0 @@
|
||||
- fix(backend): map cache tokens in OpenAI-to-Claude non-streaming usage translation (#9536)
|
||||
@@ -687,35 +687,6 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
|
||||
if (stopReason === "tool_calls") stopReason = "tool_use";
|
||||
|
||||
const usageSrc = toRecord(openaiResponse.usage);
|
||||
const promptTokens = toNumber(usageSrc.prompt_tokens, 0);
|
||||
const outputTokens = toNumber(usageSrc.completion_tokens, 0);
|
||||
|
||||
// Extract cache tokens from prompt_tokens_details (mirrors the streaming
|
||||
// translator in open-sse/translator/response/openai-to-claude.ts lines 119-148).
|
||||
const promptDetails = toRecord(usageSrc.prompt_tokens_details);
|
||||
const cachedTokens = toNumber(promptDetails.cached_tokens, 0);
|
||||
const cacheCreationTokens = toNumber(promptDetails.cache_creation_tokens, 0);
|
||||
|
||||
// OpenAI's prompt_tokens includes all prompt-side tokens (cached + non-cached).
|
||||
// Claude expects input_tokens to be only non-cached tokens, with cached tokens
|
||||
// exposed separately as cache_read_input_tokens.
|
||||
const inputTokens = promptTokens - cachedTokens - cacheCreationTokens;
|
||||
|
||||
const usage: JsonRecord = {
|
||||
input_tokens: inputTokens,
|
||||
output_tokens: outputTokens,
|
||||
};
|
||||
|
||||
// Add cache_read_input_tokens if present
|
||||
if (cachedTokens > 0) {
|
||||
usage.cache_read_input_tokens = cachedTokens;
|
||||
}
|
||||
|
||||
// Add cache_creation_input_tokens if present
|
||||
if (cacheCreationTokens > 0) {
|
||||
usage.cache_creation_input_tokens = cacheCreationTokens;
|
||||
}
|
||||
|
||||
const claudeResponse: JsonRecord = {
|
||||
id: toString(openaiResponse.id, `msg_${Date.now()}`),
|
||||
type: "message",
|
||||
@@ -724,7 +695,10 @@ function convertOpenAINonStreamingToClaude(openaiResponse: JsonRecord): JsonReco
|
||||
content,
|
||||
stop_reason: stopReason,
|
||||
stop_sequence: null,
|
||||
usage,
|
||||
usage: {
|
||||
input_tokens: toNumber(usageSrc.prompt_tokens, 0),
|
||||
output_tokens: toNumber(usageSrc.completion_tokens, 0),
|
||||
},
|
||||
};
|
||||
|
||||
return claudeResponse;
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { MCP_TOOLS, MCP_TOOL_MAP, createComboInput, createComboTool } from "../schemas/tools.ts";
|
||||
import { createMcpServer } from "../server.ts";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal("fetch", mockFetch);
|
||||
|
||||
const mockLogToolCall = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
vi.mock("../audit.ts", () => ({
|
||||
logToolCall: mockLogToolCall,
|
||||
}));
|
||||
|
||||
describe("omniroute_create_combo MCP tool schema", () => {
|
||||
it("should be registered in MCP_TOOLS and MCP_TOOL_MAP", () => {
|
||||
const tool = MCP_TOOLS.find((t) => t.name === "omniroute_create_combo");
|
||||
expect(tool).toBeDefined();
|
||||
expect(MCP_TOOL_MAP["omniroute_create_combo"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("should require write:combos scope", () => {
|
||||
expect(createComboTool.scopes).toContain("write:combos");
|
||||
});
|
||||
|
||||
it("should validate a minimal payload (name + models)", () => {
|
||||
const result = createComboInput.safeParse({
|
||||
name: "My Combo",
|
||||
models: [{ provider: "anthropic", model: "claude-sonnet" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should validate a full payload with description and strategy", () => {
|
||||
const result = createComboInput.safeParse({
|
||||
name: "My Combo",
|
||||
description: "A test combo",
|
||||
strategy: "priority",
|
||||
models: [
|
||||
{ provider: "anthropic", model: "claude-sonnet" },
|
||||
{ provider: "google", model: "gemini-pro" },
|
||||
],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject a payload missing name", () => {
|
||||
const result = createComboInput.safeParse({
|
||||
models: [{ provider: "anthropic", model: "claude-sonnet" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject a payload with an empty models array", () => {
|
||||
const result = createComboInput.safeParse({ name: "My Combo", models: [] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject an unknown strategy value", () => {
|
||||
const result = createComboInput.safeParse({
|
||||
name: "My Combo",
|
||||
strategy: "not-a-real-strategy",
|
||||
models: [{ provider: "anthropic", model: "claude-sonnet" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("omniroute_create_combo handler (via MCP dispatch)", () => {
|
||||
let client: Client;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockFetch.mockReset();
|
||||
mockLogToolCall.mockClear();
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
const server = createMcpServer();
|
||||
await server.connect(serverTransport);
|
||||
client = new Client({ name: "create-combo-test", version: "1.0.0" });
|
||||
await client.connect(clientTransport);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await client.close();
|
||||
});
|
||||
|
||||
it("should appear in tools/list after registration", async () => {
|
||||
const { tools } = await client.listTools();
|
||||
const tool = tools.find((t) => t.name === "omniroute_create_combo");
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool?.description).toContain("Registers new combo");
|
||||
});
|
||||
|
||||
it("should POST to /api/combos and return the created combo on success", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
combo: { id: "combo-123", name: "My Combo", strategy: "priority", enabled: true },
|
||||
}),
|
||||
});
|
||||
|
||||
const args = {
|
||||
name: "My Combo",
|
||||
models: [{ provider: "anthropic", model: "claude-sonnet" }],
|
||||
};
|
||||
|
||||
const result = await client.callTool({ name: "omniroute_create_combo", arguments: args });
|
||||
|
||||
expect(result.isError).toBeFalsy();
|
||||
const content = result.content[0] as { type: string; text: string };
|
||||
const data = JSON.parse(content.text);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.combo.id).toBe("combo-123");
|
||||
expect(data.combo.name).toBe("My Combo");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/combos"),
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
const [, options] = mockFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body as string);
|
||||
expect(body.name).toBe("My Combo");
|
||||
expect(body.models).toHaveLength(1);
|
||||
|
||||
// Audit: the invocation must be logged to mcp_audit (via logToolCall).
|
||||
expect(mockLogToolCall).toHaveBeenCalledWith(
|
||||
"omniroute_create_combo",
|
||||
expect.objectContaining({ name: "My Combo" }),
|
||||
expect.objectContaining({ success: true }),
|
||||
expect.any(Number),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass through optional description and strategy fields", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
combo: { id: "combo-456", name: "Cost Saver", strategy: "cost-optimized", enabled: true },
|
||||
}),
|
||||
});
|
||||
|
||||
await client.callTool({
|
||||
name: "omniroute_create_combo",
|
||||
arguments: {
|
||||
name: "Cost Saver",
|
||||
description: "Prefers cheaper models",
|
||||
strategy: "cost-optimized",
|
||||
models: [
|
||||
{ provider: "anthropic", model: "claude-haiku" },
|
||||
{ provider: "google", model: "gemini-flash" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0];
|
||||
const body = JSON.parse(options.body as string);
|
||||
expect(body.description).toBe("Prefers cheaper models");
|
||||
expect(body.strategy).toBe("cost-optimized");
|
||||
expect(body.models).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return isError and log the failure when the backend rejects the combo (e.g. name collision)", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 409,
|
||||
text: async () => "Combo name already exists",
|
||||
});
|
||||
|
||||
const result = await client.callTool({
|
||||
name: "omniroute_create_combo",
|
||||
arguments: {
|
||||
name: "Duplicate Combo",
|
||||
models: [{ provider: "anthropic", model: "claude-sonnet" }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.isError).toBe(true);
|
||||
const content = result.content[0] as { type: string; text: string };
|
||||
expect(content.text).toContain("Error");
|
||||
|
||||
expect(mockLogToolCall).toHaveBeenCalledWith(
|
||||
"omniroute_create_combo",
|
||||
expect.objectContaining({ name: "Duplicate Combo" }),
|
||||
null,
|
||||
expect.any(Number),
|
||||
false,
|
||||
expect.stringContaining("Combo name already exists")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -192,53 +192,6 @@ export const switchComboTool: McpToolDefinition<typeof switchComboInput, typeof
|
||||
sourceEndpoints: ["/api/combos"],
|
||||
};
|
||||
|
||||
// --- Tool 4b: omniroute_create_combo ---
|
||||
export const createComboInput = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.describe("Unique combo name (letters, numbers, spaces, -, _, /, ., [ and ])"),
|
||||
description: z.string().max(2000).optional().describe("Optional human-readable description"),
|
||||
strategy: z
|
||||
.enum(ROUTING_STRATEGY_VALUES)
|
||||
.optional()
|
||||
.describe("Routing strategy (default: priority)"),
|
||||
models: z
|
||||
.array(
|
||||
z.object({
|
||||
provider: z.string().describe("Provider name (e.g., 'claude', 'gemini')"),
|
||||
model: z.string().describe("Model ID for that provider"),
|
||||
})
|
||||
)
|
||||
.min(1)
|
||||
.describe("Ordered model chain; order defines priority"),
|
||||
});
|
||||
|
||||
export const createComboOutput = z.object({
|
||||
success: z.boolean(),
|
||||
combo: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
strategy: z.string(),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const createComboTool: McpToolDefinition<typeof createComboInput, typeof createComboOutput> =
|
||||
{
|
||||
name: "omniroute_create_combo",
|
||||
description:
|
||||
"Registers a new combo (model chain) with a name, ordered model list, and optional routing strategy. Full validation (name collisions, nested-combo DAG, composite tiers) is enforced by the combos API.",
|
||||
inputSchema: createComboInput,
|
||||
outputSchema: createComboOutput,
|
||||
scopes: ["write:combos"],
|
||||
auditLevel: "full",
|
||||
phase: 1,
|
||||
sourceEndpoints: ["/api/combos"],
|
||||
};
|
||||
|
||||
// --- Tool 5: omniroute_check_quota ---
|
||||
export const checkQuotaInput = z.object({
|
||||
provider: z
|
||||
@@ -1507,7 +1460,6 @@ export const MCP_TOOLS = [
|
||||
listCombosTool,
|
||||
getComboMetricsTool,
|
||||
switchComboTool,
|
||||
createComboTool,
|
||||
checkQuotaTool,
|
||||
routeRequestTool,
|
||||
costReportTool,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
listCombosInput,
|
||||
getComboMetricsInput,
|
||||
switchComboInput,
|
||||
createComboInput,
|
||||
checkQuotaInput,
|
||||
routeRequestInput,
|
||||
costReportInput,
|
||||
@@ -394,27 +393,6 @@ async function handleSwitchCombo(args: { comboId: string; active: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateCombo(args: {
|
||||
name: string;
|
||||
description?: string;
|
||||
strategy?: string;
|
||||
models: { provider: string; model: string }[];
|
||||
}) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const result = await omniRouteFetch("/api/combos", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(args),
|
||||
});
|
||||
await logToolCall("omniroute_create_combo", args, result, Date.now() - start, true);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
await logToolCall("omniroute_create_combo", args, null, Date.now() - start, false, msg);
|
||||
return { content: [{ type: "text" as const, text: `Error: ${msg}` }], isError: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCheckQuota(args: { provider?: string; connectionId?: string }) {
|
||||
const start = Date.now();
|
||||
try {
|
||||
@@ -760,17 +738,6 @@ export function createMcpServer(): McpServer {
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_create_combo",
|
||||
{
|
||||
description: "Registers a new combo (model chain) with name, models, and strategy",
|
||||
inputSchema: createComboInput,
|
||||
},
|
||||
withScopeEnforcement("omniroute_create_combo", (args) =>
|
||||
handleCreateCombo(createComboInput.parse(args))
|
||||
)
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"omniroute_check_quota",
|
||||
{
|
||||
|
||||
@@ -259,7 +259,14 @@ async function captureViaCdp(opts: {
|
||||
if (capturedAccessToken) return;
|
||||
const request = params.request as
|
||||
{ url?: string; headers?: Record<string, string> } | undefined;
|
||||
if (!request?.url || !request.url.includes(FIREFLY_3P_HOST_SUFFIX)) return;
|
||||
if (!request?.url) return;
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(request.url).hostname.toLowerCase();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (host !== FIREFLY_3P_HOST_SUFFIX && !host.endsWith(`.${FIREFLY_3P_HOST_SUFFIX}`)) return;
|
||||
const headers = request.headers || {};
|
||||
const auth = headers.Authorization || headers.authorization || headers.AUTHORIZATION || "";
|
||||
const token = extractAdobeBearerTokenFromAuthorization(auth);
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
export type ComboRecord = Record<string, unknown>;
|
||||
|
||||
export interface ComboUpdateResult {
|
||||
combo: ComboRecord;
|
||||
previousName: string;
|
||||
currentName: string;
|
||||
modelsFieldProvided: boolean;
|
||||
}
|
||||
|
||||
export interface ComboReorderResult {
|
||||
combos: ComboRecord[];
|
||||
rowsReordered: number;
|
||||
}
|
||||
|
||||
export interface ComboRepository {
|
||||
list(limit?: number, offset?: number): Promise<ComboRecord[]>;
|
||||
count(): Promise<number>;
|
||||
findById(id: string): Promise<ComboRecord | null>;
|
||||
findByName(name: string): Promise<ComboRecord | null>;
|
||||
findByNameInsensitive(name: string): Promise<ComboRecord | null>;
|
||||
create(data: ComboRecord): Promise<ComboRecord>;
|
||||
update(id: string, data: ComboRecord): Promise<ComboUpdateResult | null>;
|
||||
reorder(comboIds: string[]): Promise<ComboReorderResult>;
|
||||
deleteById(id: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface ModelComboMapping {
|
||||
id: string;
|
||||
pattern: string;
|
||||
comboId: string;
|
||||
comboName?: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateModelComboMappingInput {
|
||||
pattern: string;
|
||||
comboId: string;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type UpdateModelComboMappingInput = Partial<CreateModelComboMappingInput>;
|
||||
|
||||
export interface ModelComboMappingPage {
|
||||
items: ModelComboMapping[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ModelComboMappingRepository {
|
||||
list(options?: { limit?: number; offset?: number }): Promise<ModelComboMappingPage>;
|
||||
findById(id: string): Promise<ModelComboMapping | null>;
|
||||
create(data: CreateModelComboMappingInput): Promise<ModelComboMapping>;
|
||||
update(id: string, data: UpdateModelComboMappingInput): Promise<ModelComboMapping | null>;
|
||||
deleteById(id: string): Promise<boolean>;
|
||||
resolveForModel(model: string): Promise<ComboRecord | null>;
|
||||
}
|
||||
@@ -1,91 +1,359 @@
|
||||
/**
|
||||
* Compatibility facade for combo persistence.
|
||||
*
|
||||
* Application code keeps the existing function-level API while persistence is
|
||||
* delegated through the domain repository contract. Cross-cutting write effects
|
||||
* remain here instead of becoming part of the portable repository surface.
|
||||
* db/combos.js — Combo CRUD operations.
|
||||
*/
|
||||
|
||||
import type { ComboRecord } from "@/domain/persistence/comboRepositories";
|
||||
import { backupDbFile } from "./backup";
|
||||
import { clearSessionModelHistoryForCombo } from "./contextHandoffs";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance } from "./core";
|
||||
import { backupDbFile } from "./backup";
|
||||
import { invalidateDbCache } from "./readCache";
|
||||
import { invalidateReasoningRoutingRuleCache } from "./reasoningRoutingRules";
|
||||
import { routingConfigRepositories } from "./repositories/routingConfigRepositories";
|
||||
import { normalizeComboRecord } from "@/lib/combos/steps";
|
||||
import { clearSessionModelHistoryForCombo } from "./contextHandoffs";
|
||||
import { validateComboInvariant } from "@/lib/combos/invariants";
|
||||
|
||||
const repository = routingConfigRepositories.combos;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export function getCombos(limit?: number, offset?: number): Promise<ComboRecord[]> {
|
||||
return repository.list(limit, offset);
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function getSerializedData(value: unknown): string | null {
|
||||
const row = asRecord(value);
|
||||
return typeof row.data === "string" ? row.data : null;
|
||||
}
|
||||
|
||||
function getSortOrder(value: unknown): number | null {
|
||||
const row = asRecord(value);
|
||||
return typeof row.sort_order === "number" ? row.sort_order : null;
|
||||
}
|
||||
|
||||
function withSortOrder(payload: string, sortOrder: number | null): JsonRecord {
|
||||
const parsed = JSON.parse(payload) as JsonRecord;
|
||||
if (typeof sortOrder === "number") {
|
||||
parsed.sortOrder = sortOrder;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getComboNameSet(
|
||||
db: ReturnType<typeof getDbInstance>,
|
||||
extraNames: string[] = []
|
||||
): Set<string> {
|
||||
const rows = db.prepare("SELECT name FROM combos").all();
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const record = asRecord(row);
|
||||
if (typeof record.name === "string" && record.name.trim().length > 0) {
|
||||
names.add(record.name.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of extraNames) {
|
||||
if (typeof name === "string" && name.trim().length > 0) {
|
||||
names.add(name.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function normalizeStoredCombo(
|
||||
combo: JsonRecord,
|
||||
db: ReturnType<typeof getDbInstance>,
|
||||
extraNames: string[] = []
|
||||
) {
|
||||
return normalizeComboRecord(combo, {
|
||||
allCombos: getComboNameSet(db, extraNames),
|
||||
});
|
||||
}
|
||||
|
||||
function parseComboRow(row: unknown): JsonRecord | null {
|
||||
const payload = getSerializedData(row);
|
||||
if (!payload) return null;
|
||||
const parsed = withSortOrder(payload, getSortOrder(row));
|
||||
// Merge deduplicated column values back into the record
|
||||
const record = asRecord(row);
|
||||
if (record.context_cache_protection !== undefined && record.context_cache_protection !== null) {
|
||||
// Column is authoritative when explicitly enabled (1).
|
||||
// When column is 0 (unset default) preserve the JSON blob value
|
||||
// to avoid silently disabling the feature on pre-migration rows.
|
||||
if (record.context_cache_protection === 1) {
|
||||
parsed.context_cache_protection = true;
|
||||
}
|
||||
// Column is 0 — keep existing JSON blob value
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getNextSortOrder() {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get();
|
||||
const sortOrder = getSortOrder(row);
|
||||
return (sortOrder ?? 0) + 1;
|
||||
}
|
||||
|
||||
export async function getCombos(limit?: number, offset?: number) {
|
||||
const db = getDbInstance();
|
||||
let sql =
|
||||
"SELECT id, data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC";
|
||||
const params: unknown[] = [];
|
||||
if (limit !== undefined) {
|
||||
sql += " LIMIT ? OFFSET ?";
|
||||
params.push(limit, offset ?? 0);
|
||||
}
|
||||
const rawCombos = db
|
||||
.prepare(sql)
|
||||
.all(...params)
|
||||
.map((row) => parseComboRow(row))
|
||||
.filter((row): row is JsonRecord => row !== null);
|
||||
|
||||
const comboNames = rawCombos
|
||||
.map((combo) => (typeof combo.name === "string" ? combo.name.trim() : ""))
|
||||
.filter((name): name is string => name.length > 0);
|
||||
|
||||
return rawCombos.map((combo) =>
|
||||
normalizeComboRecord(combo, {
|
||||
allCombos: comboNames,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/** Keep the existing synchronous facade contract while repository APIs become async. */
|
||||
export function getCombosCount(): number {
|
||||
return routingConfigRepositories.legacySync.getCombosCount();
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT count(*) as cnt FROM combos").get() as { cnt: number };
|
||||
return row.cnt;
|
||||
}
|
||||
|
||||
export function getComboById(id: string): Promise<ComboRecord | null> {
|
||||
return repository.findById(id);
|
||||
export async function getComboById(id: string) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?")
|
||||
.get(id);
|
||||
const combo = parseComboRow(row);
|
||||
if (!combo) return null;
|
||||
return normalizeStoredCombo(combo, db, typeof combo.name === "string" ? [combo.name] : []);
|
||||
}
|
||||
|
||||
export function getComboByName(name: string): Promise<ComboRecord | null> {
|
||||
return repository.findByName(name);
|
||||
export async function getComboByName(name: string) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ?")
|
||||
.get(name);
|
||||
const combo = parseComboRow(row);
|
||||
if (!combo) return null;
|
||||
return normalizeStoredCombo(combo, db, [name]);
|
||||
}
|
||||
|
||||
export function getComboByNameInsensitive(name: string): Promise<ComboRecord | null> {
|
||||
return repository.findByNameInsensitive(name);
|
||||
// #4446: case-insensitive name lookup. The opencode dispatch path forwards a
|
||||
// lowercased combo slug (e.g. "master-light") for a combo provisioned as
|
||||
// "MASTER-LIGHT"; the default BINARY collation of getComboByName misses it.
|
||||
// Used only as a fallback after the exact match fails, so it cannot change the
|
||||
// resolution of any combo that already resolves today.
|
||||
export async function getComboByNameInsensitive(name: string) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ? COLLATE NOCASE"
|
||||
)
|
||||
.get(name);
|
||||
const combo = parseComboRow(row);
|
||||
if (!combo) return null;
|
||||
const storedName = typeof combo.name === "string" ? combo.name : name;
|
||||
return normalizeStoredCombo(combo, db, [storedName]);
|
||||
}
|
||||
|
||||
export async function createCombo(data: ComboRecord): Promise<ComboRecord> {
|
||||
const combo = await repository.create(data);
|
||||
export async function createCombo(data: JsonRecord) {
|
||||
const db = getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder();
|
||||
const comboId = typeof data.id === "string" && data.id.trim().length > 0 ? data.id : uuidv4();
|
||||
const combo = normalizeStoredCombo(
|
||||
{
|
||||
...data,
|
||||
id: comboId,
|
||||
name: data.name,
|
||||
models: data.models || [],
|
||||
strategy: data.strategy || "priority",
|
||||
config: data.config || {},
|
||||
isHidden: Boolean(data.isHidden),
|
||||
sortOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
db,
|
||||
typeof data.name === "string" ? [data.name] : []
|
||||
);
|
||||
|
||||
validateComboInvariant(combo);
|
||||
const contextCache = data.context_cache_protection ? 1 : 0;
|
||||
db.prepare(
|
||||
"INSERT INTO combos (id, name, data, sort_order, created_at, updated_at, context_cache_protection) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now, contextCache);
|
||||
|
||||
invalidateDbCache("combos");
|
||||
backupDbFile("pre-write");
|
||||
return combo;
|
||||
}
|
||||
|
||||
export async function updateCombo(id: string, data: ComboRecord): Promise<ComboRecord | null> {
|
||||
const result = await repository.update(id, data);
|
||||
if (!result) return null;
|
||||
export async function updateCombo(id: string, data: JsonRecord) {
|
||||
const db = getDbInstance();
|
||||
const existing = db
|
||||
.prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?")
|
||||
.get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
if (result.modelsFieldProvided) {
|
||||
const cleared = clearSessionModelHistoryForCombo(result.previousName);
|
||||
if (cleared > 0 && result.currentName !== result.previousName) {
|
||||
clearSessionModelHistoryForCombo(result.currentName);
|
||||
const current = parseComboRow(existing);
|
||||
if (!current) return null;
|
||||
const sortOrder =
|
||||
typeof data.sortOrder === "number"
|
||||
? data.sortOrder
|
||||
: typeof current.sortOrder === "number"
|
||||
? current.sortOrder
|
||||
: getNextSortOrder();
|
||||
const merged: JsonRecord = {
|
||||
...current,
|
||||
...data,
|
||||
sortOrder,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// Remove fields explicitly set to null (for deletion support)
|
||||
for (const key of Object.keys(data)) {
|
||||
if (data[key] === null) {
|
||||
delete merged[key];
|
||||
}
|
||||
}
|
||||
const currentName = typeof current.name === "string" ? current.name : "";
|
||||
const nextName =
|
||||
typeof merged["name"] === "string" && merged["name"].trim().length > 0
|
||||
? merged["name"]
|
||||
: currentName;
|
||||
const normalizedMerged = normalizeStoredCombo({ ...merged, name: nextName }, db, [nextName]);
|
||||
validateComboInvariant({
|
||||
...normalizedMerged,
|
||||
...data,
|
||||
name: nextName,
|
||||
models: normalizedMerged.models,
|
||||
});
|
||||
const contextCacheProtection = normalizedMerged.context_cache_protection ? 1 : 0;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ?, context_cache_protection = ? WHERE id = ?"
|
||||
).run(
|
||||
nextName,
|
||||
JSON.stringify(normalizedMerged),
|
||||
sortOrder,
|
||||
normalizedMerged.updatedAt,
|
||||
contextCacheProtection,
|
||||
id
|
||||
);
|
||||
|
||||
// Invalidate stale context-cache pins when combo targets change.
|
||||
// Without this, sessions pinned to removed models keep routing there forever.
|
||||
if (data.models !== undefined) {
|
||||
const cleared = clearSessionModelHistoryForCombo(currentName);
|
||||
if (cleared > 0) {
|
||||
// Also clear under the new name if the combo was renamed
|
||||
if (nextName !== currentName) {
|
||||
clearSessionModelHistoryForCombo(nextName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateDbCache("combos");
|
||||
backupDbFile("pre-write");
|
||||
return result.combo;
|
||||
return normalizedMerged;
|
||||
}
|
||||
|
||||
export async function reorderCombos(comboIds: string[]): Promise<ComboRecord[]> {
|
||||
const result = await repository.reorder(comboIds);
|
||||
if (result.rowsReordered > 0) {
|
||||
invalidateDbCache("combos");
|
||||
backupDbFile("pre-write");
|
||||
}
|
||||
return result.combos;
|
||||
export async function reorderCombos(comboIds: string[]) {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"
|
||||
)
|
||||
.all();
|
||||
if (rows.length === 0) return [];
|
||||
|
||||
const existingIds = new Set(
|
||||
rows
|
||||
.map((row) => {
|
||||
const record = asRecord(row);
|
||||
return typeof record.id === "string" ? record.id : null;
|
||||
})
|
||||
.filter((id): id is string => id !== null)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const requestedIds = comboIds.filter((id) => {
|
||||
if (!existingIds.has(id) || seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const orderedIds = [
|
||||
...requestedIds,
|
||||
...rows
|
||||
.map((row) => {
|
||||
const record = asRecord(row);
|
||||
return typeof record.id === "string" ? record.id : null;
|
||||
})
|
||||
.filter((id): id is string => id !== null && !seen.has(id)),
|
||||
];
|
||||
|
||||
const update = db.prepare(
|
||||
"UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const rowById = new Map(
|
||||
rows.map((row) => {
|
||||
const record = asRecord(row);
|
||||
return [String(record.id), row];
|
||||
})
|
||||
);
|
||||
const comboNames = rows
|
||||
.map((row) => {
|
||||
const combo = parseComboRow(row);
|
||||
return combo && typeof combo.name === "string" ? combo.name.trim() : "";
|
||||
})
|
||||
.filter((name): name is string => name.length > 0);
|
||||
|
||||
const reorderTransaction = db.transaction(() => {
|
||||
orderedIds.forEach((id, index) => {
|
||||
const row = rowById.get(id);
|
||||
const combo = row ? parseComboRow(row) : null;
|
||||
if (!combo) return;
|
||||
const sortOrder = index + 1;
|
||||
const updatedCombo = normalizeComboRecord(
|
||||
{ ...combo, sortOrder, updatedAt: now },
|
||||
{ allCombos: comboNames }
|
||||
);
|
||||
update.run(JSON.stringify(updatedCombo), sortOrder, now, id);
|
||||
});
|
||||
});
|
||||
|
||||
reorderTransaction();
|
||||
invalidateDbCache("combos");
|
||||
backupDbFile("pre-write");
|
||||
return getCombos();
|
||||
}
|
||||
|
||||
export async function deleteCombo(id: string): Promise<boolean> {
|
||||
const deleted = await repository.deleteById(id);
|
||||
if (!deleted) return false;
|
||||
|
||||
export async function deleteCombo(id: string) {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id);
|
||||
if (result.changes === 0) return false;
|
||||
invalidateDbCache("combos");
|
||||
invalidateReasoningRoutingRuleCache();
|
||||
backupDbFile("pre-write");
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteComboByName(name: string): Promise<boolean> {
|
||||
const combo = await repository.findByName(name);
|
||||
export async function deleteComboByName(name: string) {
|
||||
const combo = await getComboByName(name);
|
||||
if (!combo || typeof combo.id !== "string") return false;
|
||||
return deleteCombo(combo.id);
|
||||
}
|
||||
|
||||
export function setActiveCombo(name: string, db = getDbInstance()): void {
|
||||
export function setActiveCombo(name: string, db = getDbInstance()) {
|
||||
db.prepare(
|
||||
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'activeCombo', ?)"
|
||||
).run(JSON.stringify(name));
|
||||
|
||||
@@ -1,47 +1,249 @@
|
||||
/**
|
||||
* Compatibility facade for model-to-combo mapping persistence.
|
||||
* db/modelComboMappings.ts — Per-model combo mapping CRUD + resolution.
|
||||
*
|
||||
* Maps model name patterns (glob-style wildcards) to specific combos.
|
||||
* When a request arrives for a model string like "claude-sonnet-4",
|
||||
* the resolver checks all enabled mappings (highest priority first)
|
||||
* and returns the first matching combo.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreateModelComboMappingInput,
|
||||
ModelComboMapping,
|
||||
ModelComboMappingPage,
|
||||
UpdateModelComboMappingInput,
|
||||
} from "@/domain/persistence/comboRepositories";
|
||||
import { routingConfigRepositories } from "./repositories/routingConfigRepositories";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { getDbInstance } from "./core";
|
||||
import { globToRegex } from "@/shared/utils/globPattern";
|
||||
|
||||
export type { ModelComboMapping } from "@/domain/persistence/comboRepositories";
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
const repository = routingConfigRepositories.modelComboMappings;
|
||||
export interface ModelComboMapping {
|
||||
id: string;
|
||||
pattern: string;
|
||||
comboId: string;
|
||||
comboName?: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function getModelComboMappings(options?: {
|
||||
interface MappingRow {
|
||||
id: string;
|
||||
pattern: string;
|
||||
combo_id: string;
|
||||
combo_name?: string;
|
||||
priority: number;
|
||||
enabled: number;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Row mapping
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
function rowToMapping(row: MappingRow): ModelComboMapping {
|
||||
return {
|
||||
id: row.id,
|
||||
pattern: row.pattern,
|
||||
comboId: row.combo_id,
|
||||
comboName: row.combo_name || undefined,
|
||||
priority: row.priority,
|
||||
enabled: row.enabled === 1,
|
||||
description: row.description || "",
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all model-combo mappings, joined with combo name.
|
||||
* Ordered by priority descending (highest first).
|
||||
*/
|
||||
export async function getModelComboMappings(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ModelComboMappingPage> {
|
||||
return repository.list(options);
|
||||
}): Promise<{ items: ModelComboMapping[]; total: number }> {
|
||||
const db = getDbInstance();
|
||||
const limit = options?.limit;
|
||||
const offset = options?.offset ?? 0;
|
||||
let sql = `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name,
|
||||
m.priority, m.enabled, m.description,
|
||||
m.created_at, m.updated_at
|
||||
FROM model_combo_mappings m
|
||||
LEFT JOIN combos c ON c.id = m.combo_id
|
||||
ORDER BY m.priority DESC, m.created_at ASC`;
|
||||
const params: unknown[] = [];
|
||||
if (limit !== undefined) {
|
||||
sql += " LIMIT ? OFFSET ?";
|
||||
params.push(limit, offset);
|
||||
}
|
||||
const rows = db.prepare(sql).all(...params) as MappingRow[];
|
||||
const totalRow = db.prepare("SELECT count(*) as cnt FROM model_combo_mappings").get() as {
|
||||
cnt: number;
|
||||
};
|
||||
return { items: rows.map(rowToMapping), total: totalRow.cnt };
|
||||
}
|
||||
|
||||
export function getModelComboMappingById(id: string): Promise<ModelComboMapping | null> {
|
||||
return repository.findById(id);
|
||||
/**
|
||||
* Get a single mapping by ID.
|
||||
*/
|
||||
export async function getModelComboMappingById(id: string): Promise<ModelComboMapping | null> {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name,
|
||||
m.priority, m.enabled, m.description,
|
||||
m.created_at, m.updated_at
|
||||
FROM model_combo_mappings m
|
||||
LEFT JOIN combos c ON c.id = m.combo_id
|
||||
WHERE m.id = ?`
|
||||
)
|
||||
.get(id) as MappingRow | undefined;
|
||||
return row ? rowToMapping(row) : null;
|
||||
}
|
||||
|
||||
export function createModelComboMapping(
|
||||
data: CreateModelComboMappingInput
|
||||
): Promise<ModelComboMapping> {
|
||||
return repository.create(data);
|
||||
/**
|
||||
* Create a new model-combo mapping.
|
||||
*/
|
||||
export async function createModelComboMapping(data: {
|
||||
pattern: string;
|
||||
comboId: string;
|
||||
priority?: number;
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
}): Promise<ModelComboMapping> {
|
||||
const db = getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const id = uuidv4();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO model_combo_mappings
|
||||
(id, pattern, combo_id, priority, enabled, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
data.pattern,
|
||||
data.comboId,
|
||||
data.priority ?? 0,
|
||||
data.enabled !== false ? 1 : 0,
|
||||
data.description || "",
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
pattern: data.pattern,
|
||||
comboId: data.comboId,
|
||||
priority: data.priority ?? 0,
|
||||
enabled: data.enabled !== false,
|
||||
description: data.description || "",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export function updateModelComboMapping(
|
||||
/**
|
||||
* Update an existing model-combo mapping.
|
||||
*/
|
||||
export async function updateModelComboMapping(
|
||||
id: string,
|
||||
data: UpdateModelComboMappingInput
|
||||
data: Partial<{
|
||||
pattern: string;
|
||||
comboId: string;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
description: string;
|
||||
}>
|
||||
): Promise<ModelComboMapping | null> {
|
||||
return repository.update(id, data);
|
||||
const existing = await getModelComboMappingById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const db = getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const updated = {
|
||||
pattern: data.pattern ?? existing.pattern,
|
||||
combo_id: data.comboId ?? existing.comboId,
|
||||
priority: data.priority ?? existing.priority,
|
||||
enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : existing.enabled ? 1 : 0,
|
||||
description: data.description ?? existing.description,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`UPDATE model_combo_mappings
|
||||
SET pattern = ?, combo_id = ?, priority = ?, enabled = ?,
|
||||
description = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
updated.pattern,
|
||||
updated.combo_id,
|
||||
updated.priority,
|
||||
updated.enabled,
|
||||
updated.description,
|
||||
now,
|
||||
id
|
||||
);
|
||||
|
||||
return getModelComboMappingById(id);
|
||||
}
|
||||
|
||||
export function deleteModelComboMapping(id: string): Promise<boolean> {
|
||||
return repository.deleteById(id);
|
||||
/**
|
||||
* Delete a model-combo mapping.
|
||||
*/
|
||||
export async function deleteModelComboMapping(id: string): Promise<boolean> {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM model_combo_mappings WHERE id = ?").run(id);
|
||||
return (result.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function resolveComboForModel(model: string): Promise<Record<string, unknown> | null> {
|
||||
return repository.resolveForModel(model);
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Core: Resolve combo for a model string
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a model string matches any enabled model-combo mapping.
|
||||
* Returns the full combo object if a match is found, null otherwise.
|
||||
*
|
||||
* Mappings are checked in priority order (highest first).
|
||||
* Uses glob-style pattern matching (* = any chars, ? = single char).
|
||||
*/
|
||||
export async function resolveComboForModel(
|
||||
modelStr: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Fetch enabled mappings, ordered by priority (highest first)
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT m.pattern, m.combo_id, c.data AS combo_data
|
||||
FROM model_combo_mappings m
|
||||
JOIN combos c ON c.id = m.combo_id
|
||||
WHERE m.enabled = 1
|
||||
ORDER BY m.priority DESC, m.created_at ASC`
|
||||
)
|
||||
.all() as Array<{ pattern: string; combo_id: string; combo_data: string }>;
|
||||
|
||||
for (const row of rows) {
|
||||
const regex = globToRegex(row.pattern);
|
||||
if (regex.test(modelStr)) {
|
||||
try {
|
||||
const combo = JSON.parse(row.combo_data) as Record<string, unknown>;
|
||||
if (combo.isActive === false) {
|
||||
continue;
|
||||
}
|
||||
return combo;
|
||||
} catch {
|
||||
// Corrupted combo data — skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import type {
|
||||
ComboRepository,
|
||||
ModelComboMappingRepository,
|
||||
} from "@/domain/persistence/comboRepositories";
|
||||
import {
|
||||
getCombosCount as getSqliteCombosCount,
|
||||
sqliteComboRepository,
|
||||
} from "./sqliteComboRepository";
|
||||
import { sqliteModelComboMappingRepository } from "./sqliteModelComboMappingRepository";
|
||||
|
||||
export interface RoutingConfigRepositories {
|
||||
combos: ComboRepository;
|
||||
modelComboMappings: ModelComboMappingRepository;
|
||||
legacySync: {
|
||||
getCombosCount(): number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SQLite-only composition root for the first repository slice.
|
||||
*
|
||||
* Backend selection deliberately does not exist yet. Keeping the binding in one
|
||||
* place prevents compatibility facades from constructing or reaching through a
|
||||
* concrete driver when a later, separately approved backend is introduced.
|
||||
*/
|
||||
export const routingConfigRepositories: RoutingConfigRepositories = {
|
||||
combos: sqliteComboRepository,
|
||||
modelComboMappings: sqliteModelComboMappingRepository,
|
||||
legacySync: {
|
||||
getCombosCount: getSqliteCombosCount,
|
||||
},
|
||||
};
|
||||
@@ -1,348 +0,0 @@
|
||||
/**
|
||||
* SQLite implementation of the combo repository contract.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
ComboReorderResult,
|
||||
ComboRepository,
|
||||
ComboUpdateResult,
|
||||
} from "@/domain/persistence/comboRepositories";
|
||||
import { normalizeComboRecord } from "@/lib/combos/steps";
|
||||
import { validateComboInvariant } from "@/lib/combos/invariants";
|
||||
import { getDbInstance } from "../core";
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
function asRecord(value: unknown): JsonRecord {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
|
||||
}
|
||||
|
||||
function getSerializedData(value: unknown): string | null {
|
||||
const row = asRecord(value);
|
||||
return typeof row.data === "string" ? row.data : null;
|
||||
}
|
||||
|
||||
function getSortOrder(value: unknown): number | null {
|
||||
const row = asRecord(value);
|
||||
return typeof row.sort_order === "number" ? row.sort_order : null;
|
||||
}
|
||||
|
||||
function withSortOrder(payload: string, sortOrder: number | null): JsonRecord {
|
||||
const parsed = JSON.parse(payload) as JsonRecord;
|
||||
if (typeof sortOrder === "number") {
|
||||
parsed.sortOrder = sortOrder;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getComboNameSet(
|
||||
db: ReturnType<typeof getDbInstance>,
|
||||
extraNames: string[] = []
|
||||
): Set<string> {
|
||||
const rows = db.prepare("SELECT name FROM combos").all();
|
||||
const names = new Set<string>();
|
||||
|
||||
for (const row of rows) {
|
||||
const record = asRecord(row);
|
||||
if (typeof record.name === "string" && record.name.trim().length > 0) {
|
||||
names.add(record.name.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of extraNames) {
|
||||
if (typeof name === "string" && name.trim().length > 0) {
|
||||
names.add(name.trim());
|
||||
}
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
function normalizeStoredCombo(
|
||||
combo: JsonRecord,
|
||||
db: ReturnType<typeof getDbInstance>,
|
||||
extraNames: string[] = []
|
||||
): JsonRecord {
|
||||
return normalizeComboRecord(combo, {
|
||||
allCombos: getComboNameSet(db, extraNames),
|
||||
}) as JsonRecord;
|
||||
}
|
||||
|
||||
function parseComboRow(row: unknown): JsonRecord | null {
|
||||
const payload = getSerializedData(row);
|
||||
if (!payload) return null;
|
||||
const parsed = withSortOrder(payload, getSortOrder(row));
|
||||
// Merge deduplicated column values back into the record
|
||||
const record = asRecord(row);
|
||||
if (record.context_cache_protection !== undefined && record.context_cache_protection !== null) {
|
||||
// Column is authoritative when explicitly enabled (1).
|
||||
// When column is 0 (unset default) preserve the JSON blob value
|
||||
// to avoid silently disabling the feature on pre-migration rows.
|
||||
if (record.context_cache_protection === 1) {
|
||||
parsed.context_cache_protection = true;
|
||||
}
|
||||
// Column is 0 — keep existing JSON blob value
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function getNextSortOrder() {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get();
|
||||
const sortOrder = getSortOrder(row);
|
||||
return (sortOrder ?? 0) + 1;
|
||||
}
|
||||
|
||||
export async function getCombos(limit?: number, offset?: number) {
|
||||
const db = getDbInstance();
|
||||
let sql =
|
||||
"SELECT data, sort_order, context_cache_protection FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC";
|
||||
const params: unknown[] = [];
|
||||
if (limit !== undefined) {
|
||||
sql += " LIMIT ? OFFSET ?";
|
||||
params.push(limit, offset ?? 0);
|
||||
}
|
||||
const rawCombos = db
|
||||
.prepare(sql)
|
||||
.all(...params)
|
||||
.map((row) => parseComboRow(row))
|
||||
.filter((row): row is JsonRecord => row !== null);
|
||||
|
||||
const comboNames = rawCombos
|
||||
.map((combo) => (typeof combo.name === "string" ? combo.name.trim() : ""))
|
||||
.filter((name): name is string => name.length > 0);
|
||||
|
||||
return rawCombos.map((combo) =>
|
||||
normalizeComboRecord(combo, {
|
||||
allCombos: comboNames,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getCombosCount(): number {
|
||||
const db = getDbInstance();
|
||||
const row = db.prepare("SELECT count(*) as cnt FROM combos").get() as { cnt: number };
|
||||
return row.cnt;
|
||||
}
|
||||
|
||||
export async function getComboById(id: string) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?")
|
||||
.get(id);
|
||||
const combo = parseComboRow(row);
|
||||
if (!combo) return null;
|
||||
return normalizeStoredCombo(combo, db, typeof combo.name === "string" ? [combo.name] : []);
|
||||
}
|
||||
|
||||
export async function getComboByName(name: string) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ?")
|
||||
.get(name);
|
||||
const combo = parseComboRow(row);
|
||||
if (!combo) return null;
|
||||
return normalizeStoredCombo(combo, db, [name]);
|
||||
}
|
||||
|
||||
// #4446: case-insensitive name lookup. The opencode dispatch path forwards a
|
||||
// lowercased combo slug (e.g. "master-light") for a combo provisioned as
|
||||
// "MASTER-LIGHT"; the default BINARY collation of getComboByName misses it.
|
||||
// Used only as a fallback after the exact match fails, so it cannot change the
|
||||
// resolution of any combo that already resolves today.
|
||||
export async function getComboByNameInsensitive(name: string) {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
"SELECT data, sort_order, context_cache_protection FROM combos WHERE name = ? COLLATE NOCASE"
|
||||
)
|
||||
.get(name);
|
||||
const combo = parseComboRow(row);
|
||||
if (!combo) return null;
|
||||
const storedName = typeof combo.name === "string" ? combo.name : name;
|
||||
return normalizeStoredCombo(combo, db, [storedName]);
|
||||
}
|
||||
|
||||
export async function createCombo(data: JsonRecord) {
|
||||
const db = getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder();
|
||||
const comboId = typeof data.id === "string" && data.id.trim().length > 0 ? data.id : uuidv4();
|
||||
const combo = normalizeStoredCombo(
|
||||
{
|
||||
...data,
|
||||
id: comboId,
|
||||
name: data.name,
|
||||
models: data.models || [],
|
||||
strategy: data.strategy || "priority",
|
||||
config: data.config || {},
|
||||
isHidden: Boolean(data.isHidden),
|
||||
sortOrder,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
db,
|
||||
typeof data.name === "string" ? [data.name] : []
|
||||
);
|
||||
|
||||
validateComboInvariant(combo);
|
||||
const contextCache = data.context_cache_protection ? 1 : 0;
|
||||
db.prepare(
|
||||
"INSERT INTO combos (id, name, data, sort_order, created_at, updated_at, context_cache_protection) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now, contextCache);
|
||||
|
||||
return combo;
|
||||
}
|
||||
|
||||
export async function updateCombo(id: string, data: JsonRecord): Promise<ComboUpdateResult | null> {
|
||||
const db = getDbInstance();
|
||||
const existing = db
|
||||
.prepare("SELECT data, sort_order, context_cache_protection FROM combos WHERE id = ?")
|
||||
.get(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const current = parseComboRow(existing);
|
||||
if (!current) return null;
|
||||
const sortOrder =
|
||||
typeof data.sortOrder === "number"
|
||||
? data.sortOrder
|
||||
: typeof current.sortOrder === "number"
|
||||
? current.sortOrder
|
||||
: getNextSortOrder();
|
||||
const merged: JsonRecord = {
|
||||
...current,
|
||||
...data,
|
||||
sortOrder,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
// Remove fields explicitly set to null (for deletion support)
|
||||
for (const key of Object.keys(data)) {
|
||||
if (data[key] === null) {
|
||||
delete merged[key];
|
||||
}
|
||||
}
|
||||
const currentName = typeof current.name === "string" ? current.name : "";
|
||||
const nextName =
|
||||
typeof merged["name"] === "string" && merged["name"].trim().length > 0
|
||||
? merged["name"]
|
||||
: currentName;
|
||||
const normalizedMerged = normalizeStoredCombo({ ...merged, name: nextName }, db, [nextName]);
|
||||
validateComboInvariant({
|
||||
...normalizedMerged,
|
||||
...data,
|
||||
name: nextName,
|
||||
models: normalizedMerged.models,
|
||||
});
|
||||
const contextCacheProtection = normalizedMerged.context_cache_protection ? 1 : 0;
|
||||
|
||||
db.prepare(
|
||||
"UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ?, context_cache_protection = ? WHERE id = ?"
|
||||
).run(
|
||||
nextName,
|
||||
JSON.stringify(normalizedMerged),
|
||||
sortOrder,
|
||||
normalizedMerged.updatedAt,
|
||||
contextCacheProtection,
|
||||
id
|
||||
);
|
||||
|
||||
return {
|
||||
combo: normalizedMerged,
|
||||
previousName: currentName,
|
||||
currentName: nextName,
|
||||
modelsFieldProvided: data.models !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function reorderCombos(comboIds: string[]): Promise<ComboReorderResult> {
|
||||
const db = getDbInstance();
|
||||
const rows = db
|
||||
.prepare(
|
||||
"SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"
|
||||
)
|
||||
.all();
|
||||
if (rows.length === 0) return { combos: [], rowsReordered: 0 };
|
||||
|
||||
const existingIds = new Set(
|
||||
rows
|
||||
.map((row) => {
|
||||
const record = asRecord(row);
|
||||
return typeof record.id === "string" ? record.id : null;
|
||||
})
|
||||
.filter((id): id is string => id !== null)
|
||||
);
|
||||
|
||||
const seen = new Set<string>();
|
||||
const requestedIds = comboIds.filter((id) => {
|
||||
if (!existingIds.has(id) || seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const orderedIds = [
|
||||
...requestedIds,
|
||||
...rows
|
||||
.map((row) => {
|
||||
const record = asRecord(row);
|
||||
return typeof record.id === "string" ? record.id : null;
|
||||
})
|
||||
.filter((id): id is string => id !== null && !seen.has(id)),
|
||||
];
|
||||
|
||||
const update = db.prepare(
|
||||
"UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
const rowById = new Map(
|
||||
rows.map((row) => {
|
||||
const record = asRecord(row);
|
||||
return [String(record.id), row];
|
||||
})
|
||||
);
|
||||
const comboNames = rows
|
||||
.map((row) => {
|
||||
const combo = parseComboRow(row);
|
||||
return combo && typeof combo.name === "string" ? combo.name.trim() : "";
|
||||
})
|
||||
.filter((name): name is string => name.length > 0);
|
||||
|
||||
const reorderTransaction = db.transaction(() => {
|
||||
orderedIds.forEach((id, index) => {
|
||||
const row = rowById.get(id);
|
||||
const combo = row ? parseComboRow(row) : null;
|
||||
if (!combo) return;
|
||||
const sortOrder = index + 1;
|
||||
const updatedCombo = normalizeComboRecord(
|
||||
{ ...combo, sortOrder, updatedAt: now },
|
||||
{ allCombos: comboNames }
|
||||
);
|
||||
update.run(JSON.stringify(updatedCombo), sortOrder, now, id);
|
||||
});
|
||||
});
|
||||
|
||||
reorderTransaction();
|
||||
return {
|
||||
combos: await getCombos(),
|
||||
rowsReordered: orderedIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteCombo(id: string) {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM combos WHERE id = ?").run(id);
|
||||
if (result.changes === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export const sqliteComboRepository: ComboRepository = {
|
||||
list: getCombos,
|
||||
count: async () => getCombosCount(),
|
||||
findById: getComboById,
|
||||
findByName: getComboByName,
|
||||
findByNameInsensitive: getComboByNameInsensitive,
|
||||
create: createCombo,
|
||||
update: updateCombo,
|
||||
reorder: reorderCombos,
|
||||
deleteById: deleteCombo,
|
||||
};
|
||||
@@ -1,244 +0,0 @@
|
||||
/**
|
||||
* SQLite implementation of per-model combo mapping persistence and resolution.
|
||||
*
|
||||
* Maps model name patterns (glob-style wildcards) to specific combos.
|
||||
* When a request arrives for a model string like "claude-sonnet-4",
|
||||
* the resolver checks all enabled mappings (highest priority first)
|
||||
* and returns the first matching combo.
|
||||
*/
|
||||
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type {
|
||||
CreateModelComboMappingInput,
|
||||
ModelComboMapping,
|
||||
ModelComboMappingRepository,
|
||||
UpdateModelComboMappingInput,
|
||||
} from "@/domain/persistence/comboRepositories";
|
||||
import { globToRegex } from "@/shared/utils/globPattern";
|
||||
import { getDbInstance } from "../core";
|
||||
|
||||
export type { ModelComboMapping } from "@/domain/persistence/comboRepositories";
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Types
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
interface MappingRow {
|
||||
id: string;
|
||||
pattern: string;
|
||||
combo_id: string;
|
||||
combo_name?: string;
|
||||
priority: number;
|
||||
enabled: number;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Row mapping
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
function rowToMapping(row: MappingRow): ModelComboMapping {
|
||||
return {
|
||||
id: row.id,
|
||||
pattern: row.pattern,
|
||||
comboId: row.combo_id,
|
||||
comboName: row.combo_name || undefined,
|
||||
priority: row.priority,
|
||||
enabled: row.enabled === 1,
|
||||
description: row.description || "",
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// CRUD
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all model-combo mappings, joined with combo name.
|
||||
* Ordered by priority descending (highest first).
|
||||
*/
|
||||
export async function getModelComboMappings(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ items: ModelComboMapping[]; total: number }> {
|
||||
const db = getDbInstance();
|
||||
const limit = options?.limit;
|
||||
const offset = options?.offset ?? 0;
|
||||
let sql = `SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name,
|
||||
m.priority, m.enabled, m.description,
|
||||
m.created_at, m.updated_at
|
||||
FROM model_combo_mappings m
|
||||
LEFT JOIN combos c ON c.id = m.combo_id
|
||||
ORDER BY m.priority DESC, m.created_at ASC`;
|
||||
const params: unknown[] = [];
|
||||
if (limit !== undefined) {
|
||||
sql += " LIMIT ? OFFSET ?";
|
||||
params.push(limit, offset);
|
||||
}
|
||||
const rows = db.prepare(sql).all(...params) as MappingRow[];
|
||||
const totalRow = db.prepare("SELECT count(*) as cnt FROM model_combo_mappings").get() as {
|
||||
cnt: number;
|
||||
};
|
||||
return { items: rows.map(rowToMapping), total: totalRow.cnt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single mapping by ID.
|
||||
*/
|
||||
export async function getModelComboMappingById(id: string): Promise<ModelComboMapping | null> {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT m.id, m.pattern, m.combo_id, c.name AS combo_name,
|
||||
m.priority, m.enabled, m.description,
|
||||
m.created_at, m.updated_at
|
||||
FROM model_combo_mappings m
|
||||
LEFT JOIN combos c ON c.id = m.combo_id
|
||||
WHERE m.id = ?`
|
||||
)
|
||||
.get(id) as MappingRow | undefined;
|
||||
return row ? rowToMapping(row) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new model-combo mapping.
|
||||
*/
|
||||
export async function createModelComboMapping(
|
||||
data: CreateModelComboMappingInput
|
||||
): Promise<ModelComboMapping> {
|
||||
const db = getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const id = uuidv4();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO model_combo_mappings
|
||||
(id, pattern, combo_id, priority, enabled, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
data.pattern,
|
||||
data.comboId,
|
||||
data.priority ?? 0,
|
||||
data.enabled !== false ? 1 : 0,
|
||||
data.description || "",
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
pattern: data.pattern,
|
||||
comboId: data.comboId,
|
||||
priority: data.priority ?? 0,
|
||||
enabled: data.enabled !== false,
|
||||
description: data.description || "",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing model-combo mapping.
|
||||
*/
|
||||
export async function updateModelComboMapping(
|
||||
id: string,
|
||||
data: UpdateModelComboMappingInput
|
||||
): Promise<ModelComboMapping | null> {
|
||||
const existing = await getModelComboMappingById(id);
|
||||
if (!existing) return null;
|
||||
|
||||
const db = getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const updated = {
|
||||
pattern: data.pattern ?? existing.pattern,
|
||||
combo_id: data.comboId ?? existing.comboId,
|
||||
priority: data.priority ?? existing.priority,
|
||||
enabled: data.enabled !== undefined ? (data.enabled ? 1 : 0) : existing.enabled ? 1 : 0,
|
||||
description: data.description ?? existing.description,
|
||||
};
|
||||
|
||||
db.prepare(
|
||||
`UPDATE model_combo_mappings
|
||||
SET pattern = ?, combo_id = ?, priority = ?, enabled = ?,
|
||||
description = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
updated.pattern,
|
||||
updated.combo_id,
|
||||
updated.priority,
|
||||
updated.enabled,
|
||||
updated.description,
|
||||
now,
|
||||
id
|
||||
);
|
||||
|
||||
return getModelComboMappingById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a model-combo mapping.
|
||||
*/
|
||||
export async function deleteModelComboMapping(id: string): Promise<boolean> {
|
||||
const db = getDbInstance();
|
||||
const result = db.prepare("DELETE FROM model_combo_mappings WHERE id = ?").run(id);
|
||||
return (result.changes ?? 0) > 0;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Core: Resolve combo for a model string
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a model string matches any enabled model-combo mapping.
|
||||
* Returns the full combo object if a match is found, null otherwise.
|
||||
*
|
||||
* Mappings are checked in priority order (highest first).
|
||||
* Uses glob-style pattern matching (* = any chars, ? = single char).
|
||||
*/
|
||||
export async function resolveComboForModel(
|
||||
modelStr: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Fetch enabled mappings, ordered by priority (highest first)
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT m.pattern, m.combo_id, c.data AS combo_data
|
||||
FROM model_combo_mappings m
|
||||
JOIN combos c ON c.id = m.combo_id
|
||||
WHERE m.enabled = 1
|
||||
ORDER BY m.priority DESC, m.created_at ASC`
|
||||
)
|
||||
.all() as Array<{ pattern: string; combo_id: string; combo_data: string }>;
|
||||
|
||||
for (const row of rows) {
|
||||
const regex = globToRegex(row.pattern);
|
||||
if (regex.test(modelStr)) {
|
||||
try {
|
||||
const combo = JSON.parse(row.combo_data) as Record<string, unknown>;
|
||||
if (combo.isActive === false) {
|
||||
continue;
|
||||
}
|
||||
return combo;
|
||||
} catch {
|
||||
// Corrupted combo data — skip
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export const sqliteModelComboMappingRepository: ModelComboMappingRepository = {
|
||||
list: getModelComboMappings,
|
||||
findById: getModelComboMappingById,
|
||||
create: createModelComboMapping,
|
||||
update: updateModelComboMapping,
|
||||
deleteById: deleteModelComboMapping,
|
||||
resolveForModel: resolveComboForModel,
|
||||
};
|
||||
@@ -1,240 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type {
|
||||
ComboRepository,
|
||||
ModelComboMappingRepository,
|
||||
} from "../../../src/domain/persistence/comboRepositories.ts";
|
||||
|
||||
export interface ComboRepositoryHarness {
|
||||
combos: ComboRepository;
|
||||
mappings: ModelComboMappingRepository;
|
||||
reset(): Promise<void>;
|
||||
corruptComboPayload(comboId: string): Promise<void>;
|
||||
}
|
||||
|
||||
export function registerComboRepositoryConformance(
|
||||
createHarness: () => Promise<ComboRepositoryHarness>
|
||||
): void {
|
||||
test("combo repository: CRUD, defaults, lookup, count, and pagination", async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.reset();
|
||||
|
||||
const zulu = await harness.combos.create({
|
||||
name: "Zulu",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
const alpha = await harness.combos.create({
|
||||
name: "Alpha",
|
||||
models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }],
|
||||
});
|
||||
|
||||
assert.equal(zulu.version, 2);
|
||||
assert.equal(zulu.strategy, "priority");
|
||||
assert.equal(zulu.sortOrder, 1);
|
||||
assert.equal(alpha.sortOrder, 2);
|
||||
assert.equal(await harness.combos.count(), 2);
|
||||
assert.deepEqual(await harness.combos.findById(String(zulu.id)), zulu);
|
||||
assert.deepEqual(await harness.combos.findByName("Zulu"), zulu);
|
||||
assert.equal(await harness.combos.findByName("zulu"), null);
|
||||
assert.deepEqual(await harness.combos.findByNameInsensitive("zulu"), zulu);
|
||||
|
||||
const page = await harness.combos.list(1, 1);
|
||||
assert.deepEqual(
|
||||
page.map((combo) => combo.name),
|
||||
["Alpha"]
|
||||
);
|
||||
});
|
||||
|
||||
test("combo repository: partial update, explicit null deletion, and missing rows", async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.reset();
|
||||
|
||||
const created = await harness.combos.create({
|
||||
name: "Mutable",
|
||||
description: "remove me",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
config: { retries: 1 },
|
||||
});
|
||||
|
||||
const updateResult = await harness.combos.update(String(created.id), {
|
||||
description: null,
|
||||
strategy: "round-robin",
|
||||
config: { retries: 3 },
|
||||
});
|
||||
|
||||
assert.ok(updateResult);
|
||||
const updated = updateResult.combo;
|
||||
assert.equal(updated.id, created.id);
|
||||
assert.equal(updated.name, "Mutable");
|
||||
assert.equal("description" in updated, false);
|
||||
assert.equal(updated.strategy, "round-robin");
|
||||
assert.deepEqual(updated.config, { retries: 3 });
|
||||
assert.equal(updateResult.previousName, "Mutable");
|
||||
assert.equal(updateResult.currentName, "Mutable");
|
||||
assert.equal(updateResult.modelsFieldProvided, false);
|
||||
assert.equal(await harness.combos.update("missing", { strategy: "priority" }), null);
|
||||
});
|
||||
|
||||
test("combo repository: reorder is atomic and delete reports affected-row semantics", async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.reset();
|
||||
|
||||
const alpha = await harness.combos.create({
|
||||
name: "Alpha",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
const bravo = await harness.combos.create({
|
||||
name: "Bravo",
|
||||
models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }],
|
||||
});
|
||||
const charlie = await harness.combos.create({
|
||||
name: "Charlie",
|
||||
models: [{ provider: "google", model: "gemini-2.5-pro" }],
|
||||
});
|
||||
|
||||
const reorderResult = await harness.combos.reorder([
|
||||
String(charlie.id),
|
||||
"unknown",
|
||||
String(charlie.id),
|
||||
String(alpha.id),
|
||||
]);
|
||||
const reordered = reorderResult.combos;
|
||||
assert.equal(reorderResult.rowsReordered, 3);
|
||||
assert.deepEqual(
|
||||
reordered.map((combo) => combo.name),
|
||||
["Charlie", "Alpha", "Bravo"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
reordered.map((combo) => combo.sortOrder),
|
||||
[1, 2, 3]
|
||||
);
|
||||
|
||||
assert.equal(await harness.combos.deleteById("missing"), false);
|
||||
assert.equal(await harness.combos.deleteById(String(bravo.id)), true);
|
||||
assert.equal(await harness.combos.deleteById(String(bravo.id)), false);
|
||||
|
||||
await harness.corruptComboPayload(String(alpha.id));
|
||||
await harness.corruptComboPayload(String(charlie.id));
|
||||
const corruptResult = await harness.combos.reorder([String(alpha.id), String(charlie.id)]);
|
||||
assert.equal(corruptResult.rowsReordered, 2);
|
||||
assert.deepEqual(corruptResult.combos, []);
|
||||
});
|
||||
|
||||
test("model mapping repository: CRUD, ordering, pagination, and atomic cascade", async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.reset();
|
||||
|
||||
const comboA = await harness.combos.create({
|
||||
name: "alpha",
|
||||
models: [{ provider: "openai", model: "gpt-4o" }],
|
||||
});
|
||||
const comboB = await harness.combos.create({
|
||||
name: "beta",
|
||||
models: [{ provider: "openai", model: "gpt-4o-mini" }],
|
||||
});
|
||||
|
||||
const first = await harness.mappings.create({
|
||||
pattern: "gpt-*",
|
||||
comboId: String(comboA.id),
|
||||
priority: 20,
|
||||
description: "primary",
|
||||
});
|
||||
const second = await harness.mappings.create({
|
||||
pattern: "claude-*",
|
||||
comboId: String(comboB.id),
|
||||
priority: 10,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const all = await harness.mappings.list();
|
||||
assert.equal(all.total, 2);
|
||||
assert.deepEqual(
|
||||
all.items.map((mapping) => mapping.id),
|
||||
[first.id, second.id]
|
||||
);
|
||||
assert.equal(all.items[0].comboName, "alpha");
|
||||
assert.equal(all.items[0].enabled, true);
|
||||
assert.equal(all.items[1].comboName, "beta");
|
||||
assert.equal(all.items[1].enabled, false);
|
||||
|
||||
const page = await harness.mappings.list({ limit: 1, offset: 1 });
|
||||
assert.equal(page.total, 2);
|
||||
assert.deepEqual(
|
||||
page.items.map((mapping) => mapping.id),
|
||||
[second.id]
|
||||
);
|
||||
|
||||
const updated = await harness.mappings.update(first.id, {
|
||||
pattern: "openai/*",
|
||||
comboId: String(comboB.id),
|
||||
enabled: false,
|
||||
description: "rerouted",
|
||||
});
|
||||
assert.ok(updated);
|
||||
assert.equal(updated.pattern, "openai/*");
|
||||
assert.equal(updated.comboId, comboB.id);
|
||||
assert.equal(updated.comboName, "beta");
|
||||
assert.equal(updated.enabled, false);
|
||||
assert.equal(updated.description, "rerouted");
|
||||
assert.equal(await harness.mappings.update("missing", { pattern: "*" }), null);
|
||||
|
||||
assert.equal(await harness.mappings.deleteById(first.id), true);
|
||||
assert.equal(await harness.mappings.deleteById(first.id), false);
|
||||
|
||||
// The SQLite foreign key performs the combo + related mapping removal in
|
||||
// one statement/transaction; portable backends must preserve that behavior.
|
||||
assert.equal(await harness.combos.deleteById(String(comboB.id)), true);
|
||||
assert.equal(await harness.mappings.findById(second.id), null);
|
||||
assert.equal((await harness.mappings.list()).total, 0);
|
||||
});
|
||||
|
||||
test("model mapping repository: resolution skips disabled, inactive, and corrupt combos", async () => {
|
||||
const harness = await createHarness();
|
||||
await harness.reset();
|
||||
|
||||
const broken = await harness.combos.create({
|
||||
name: "broken",
|
||||
models: [{ provider: "openai", model: "gpt-4o" }],
|
||||
});
|
||||
const inactive = await harness.combos.create({
|
||||
name: "inactive",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
isActive: false,
|
||||
});
|
||||
const selected = await harness.combos.create({
|
||||
name: "selected",
|
||||
models: [{ provider: "openai", model: "gpt-4o-mini" }],
|
||||
});
|
||||
|
||||
await harness.mappings.create({
|
||||
pattern: "gpt-*",
|
||||
comboId: String(broken.id),
|
||||
priority: 30,
|
||||
});
|
||||
await harness.mappings.create({
|
||||
pattern: "gpt-*",
|
||||
comboId: String(inactive.id),
|
||||
priority: 20,
|
||||
});
|
||||
await harness.mappings.create({
|
||||
pattern: "gpt-*",
|
||||
comboId: String(selected.id),
|
||||
priority: 10,
|
||||
});
|
||||
await harness.mappings.create({
|
||||
pattern: "gpt-*",
|
||||
comboId: String(selected.id),
|
||||
priority: 100,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
assert.ok(harness.corruptComboPayload);
|
||||
await harness.corruptComboPayload(String(broken.id));
|
||||
|
||||
const resolved = await harness.mappings.resolveForModel("gpt-4o");
|
||||
assert.ok(resolved);
|
||||
assert.equal(resolved.name, "selected");
|
||||
assert.equal(await harness.mappings.resolveForModel("claude-sonnet"), null);
|
||||
});
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { translateNonStreamingResponse } from "../../open-sse/handlers/responseTranslator.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
/**
|
||||
* Test for #9536: Usage misreported on OpenAI-shaped upstreams when translating
|
||||
* to Claude format (non-streaming path).
|
||||
*
|
||||
* Two defects:
|
||||
* 1. cache_read_input_tokens is always 0 (missing mapping)
|
||||
* 2. input_tokens is inflated by cached tokens (not subtracting prompt_tokens_details.cached_tokens)
|
||||
*
|
||||
* Plus regression guard for #8331 (buffer isolation via context_budget_* fields).
|
||||
*/
|
||||
|
||||
const DEEPSEEK_OPENAI_RESPONSE = {
|
||||
id: "chatcmpl-deepseek-abc123",
|
||||
object: "chat.completion",
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "I am an AI assistant." },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 4364,
|
||||
prompt_tokens_details: { cached_tokens: 4352 },
|
||||
prompt_cache_hit_tokens: 4352,
|
||||
prompt_cache_miss_tokens: 12,
|
||||
completion_tokens: 27,
|
||||
total_tokens: 4391,
|
||||
},
|
||||
};
|
||||
|
||||
const DEEPSEEK_OPENAI_RESPONSE_NO_CACHE = {
|
||||
id: "chatcmpl-deepseek-no-cache",
|
||||
object: "chat.completion",
|
||||
model: "deepseek/deepseek-v4-flash",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Hello." },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 125,
|
||||
prompt_tokens_details: {},
|
||||
completion_tokens: 5,
|
||||
total_tokens: 130,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* OpenAI response that (before #8331's context_budget_* fix) would have had
|
||||
* input_tokens += buffer. After #8331, the buffer values go into
|
||||
* context_budget_* fields that filterUsageForFormat strips.
|
||||
*/
|
||||
const RESPONSE_WITH_BUFFER = {
|
||||
id: "chatcmpl-buffer-test",
|
||||
object: "chat.completion",
|
||||
model: "gpt-4o",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "Hello." },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 50,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 60,
|
||||
},
|
||||
};
|
||||
|
||||
describe("9536 - usage misreporting OpenAI->Claude (non-streaming)", () => {
|
||||
it("Defect 1: cache_read_input_tokens should be present when cached_tokens > 0", () => {
|
||||
const result = translateNonStreamingResponse(
|
||||
DEEPSEEK_OPENAI_RESPONSE,
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE
|
||||
);
|
||||
|
||||
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
|
||||
|
||||
// cache_read_input_tokens should be mapped from prompt_tokens_details.cached_tokens
|
||||
assert.equal(
|
||||
usage.cache_read_input_tokens,
|
||||
4352,
|
||||
`cache_read_input_tokens = ${usage.cache_read_input_tokens} (expected 4352)`
|
||||
);
|
||||
});
|
||||
|
||||
it("Defect 2: input_tokens should be prompt_tokens minus cached tokens", () => {
|
||||
const result = translateNonStreamingResponse(
|
||||
DEEPSEEK_OPENAI_RESPONSE,
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE
|
||||
);
|
||||
|
||||
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
|
||||
|
||||
// input_tokens = prompt_tokens(4364) - cached_tokens(4352) = 12
|
||||
assert.equal(usage.input_tokens, 12, `input_tokens = ${usage.input_tokens} (expected 12)`);
|
||||
});
|
||||
|
||||
it("Regression guard #8331: buffer should NOT inflate input_tokens", () => {
|
||||
const result = translateNonStreamingResponse(
|
||||
RESPONSE_WITH_BUFFER,
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE
|
||||
);
|
||||
|
||||
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
|
||||
|
||||
// input_tokens should be exactly prompt_tokens (50), no buffer added
|
||||
assert.equal(usage.input_tokens, 50, `input_tokens = ${usage.input_tokens} (expected 50)`);
|
||||
|
||||
// No context_budget_* fields should leak into the translated response
|
||||
assert.equal(usage.context_budget_remaining, undefined);
|
||||
assert.equal(usage.context_budget_consume, undefined);
|
||||
assert.equal(usage.context_budget_add, undefined);
|
||||
});
|
||||
|
||||
it("No cache data: input_tokens unchanged, no cache_read_input_tokens", () => {
|
||||
const result = translateNonStreamingResponse(
|
||||
DEEPSEEK_OPENAI_RESPONSE_NO_CACHE,
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.CLAUDE
|
||||
);
|
||||
|
||||
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
|
||||
|
||||
// Without cached_tokens, input_tokens = prompt_tokens = 125
|
||||
assert.equal(usage.input_tokens, 125, `input_tokens = ${usage.input_tokens} (expected 125)`);
|
||||
|
||||
// cache_read_input_tokens should NOT be present when there's no caching
|
||||
assert.equal(usage.cache_read_input_tokens, undefined);
|
||||
});
|
||||
|
||||
it("Pass-through: same format returns usage unchanged", () => {
|
||||
// When source === target, the function returns the response as-is
|
||||
const result = translateNonStreamingResponse(
|
||||
DEEPSEEK_OPENAI_RESPONSE,
|
||||
FORMATS.OPENAI,
|
||||
FORMATS.OPENAI
|
||||
);
|
||||
|
||||
const usage = (result as Record<string, unknown>).usage as Record<string, unknown>;
|
||||
|
||||
// OpenAI format should preserve all fields, including cached_tokens
|
||||
assert.equal(usage.prompt_tokens, 4364);
|
||||
assert.equal(usage.completion_tokens, 27);
|
||||
assert.ok(usage.prompt_tokens_details, "prompt_tokens_details should be preserved");
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,6 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const contextHandoffsDb = await import("../../src/lib/db/contextHandoffs.ts");
|
||||
const readCache = await import("../../src/lib/db/readCache.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
@@ -39,9 +38,8 @@ async function resetStorage() {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error: unknown) {
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
if ((code === "EBUSY" || code === "EPERM") && attempt < 9) {
|
||||
} catch (error: any) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
@@ -115,7 +113,7 @@ test("editing a combo invalidates the nested-expansion cache within the 10s wind
|
||||
const freshVersion = readCache.getCombosCacheVersion();
|
||||
assert.equal(cacheStillValid(freshTs, freshVersion), true);
|
||||
|
||||
await combosDb.updateCombo(String(parent.id), { strategy: "round-robin" });
|
||||
await combosDb.updateCombo((parent as any).id, { strategy: "round-robin" });
|
||||
assert.equal(
|
||||
cacheStillValid(freshTs, freshVersion),
|
||||
false,
|
||||
@@ -135,77 +133,19 @@ test("deleteCombo and reorderCombos also invalidate the cache", async () => {
|
||||
|
||||
let ts = Date.now();
|
||||
let version = readCache.getCombosCacheVersion();
|
||||
await combosDb.reorderCombos([String(b.id), String(a.id)]);
|
||||
assert.equal(cacheStillValid(ts, version), false, "reorderCombos must invalidate the cache");
|
||||
await combosDb.reorderCombos([(b as any).id, (a as any).id]);
|
||||
assert.equal(
|
||||
cacheStillValid(ts, version),
|
||||
false,
|
||||
"reorderCombos must invalidate the cache"
|
||||
);
|
||||
|
||||
ts = Date.now();
|
||||
version = readCache.getCombosCacheVersion();
|
||||
await combosDb.deleteCombo(String(a.id));
|
||||
assert.equal(cacheStillValid(ts, version), false, "deleteCombo must invalidate the cache");
|
||||
});
|
||||
|
||||
test("reorderCombo side effects follow physical writes even when stored JSON is corrupt", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "Corrupt Payload",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
core.getDbInstance().prepare("UPDATE combos SET data = '' WHERE id = ?").run(String(combo.id));
|
||||
|
||||
const before = readCache.getCombosCacheVersion();
|
||||
const reordered = await combosDb.reorderCombos([String(combo.id)]);
|
||||
|
||||
assert.deepEqual(reordered, []);
|
||||
assert.notEqual(
|
||||
readCache.getCombosCacheVersion(),
|
||||
before,
|
||||
"a physical reorder write must preserve the legacy invalidation side effect"
|
||||
);
|
||||
});
|
||||
|
||||
test("updateCombo preserves the existing session-pin cleanup contract", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "Before Rename",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
|
||||
contextHandoffsDb.recordSessionModelUsage(
|
||||
"session-before",
|
||||
"Before Rename",
|
||||
"openai/gpt-4.1",
|
||||
"openai"
|
||||
);
|
||||
contextHandoffsDb.recordSessionModelUsage(
|
||||
"session-after",
|
||||
"After Rename",
|
||||
"openai/gpt-4.1-mini",
|
||||
"openai"
|
||||
);
|
||||
|
||||
await combosDb.updateCombo(String(combo.id), {
|
||||
name: "After Rename",
|
||||
models: [{ provider: "openai", model: "gpt-4.1-mini" }],
|
||||
});
|
||||
|
||||
assert.equal(contextHandoffsDb.getLastSessionModel("session-before", "Before Rename"), null);
|
||||
assert.equal(contextHandoffsDb.getLastSessionModel("session-after", "After Rename"), null);
|
||||
});
|
||||
|
||||
test("updateCombo does not clear session pins when models are omitted", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "Metadata Only",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
contextHandoffsDb.recordSessionModelUsage(
|
||||
"session-metadata",
|
||||
"Metadata Only",
|
||||
"openai/gpt-4.1",
|
||||
"openai"
|
||||
);
|
||||
|
||||
await combosDb.updateCombo(String(combo.id), { description: "metadata change" });
|
||||
|
||||
await combosDb.deleteCombo((a as any).id);
|
||||
assert.equal(
|
||||
contextHandoffsDb.getLastSessionModel("session-metadata", "Metadata Only"),
|
||||
"openai/gpt-4.1"
|
||||
cacheStillValid(ts, version),
|
||||
false,
|
||||
"deleteCombo must invalidate the cache"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { registerComboRepositoryConformance } from "../../../helpers/persistence/comboRepositoryConformance.ts";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-repository-contract-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../../../src/lib/db/core.ts");
|
||||
const { sqliteComboRepository } =
|
||||
await import("../../../../src/lib/db/repositories/sqliteComboRepository.ts");
|
||||
const { sqliteModelComboMappingRepository } =
|
||||
await import("../../../../src/lib/db/repositories/sqliteModelComboMappingRepository.ts");
|
||||
const combosDb = await import("../../../../src/lib/db/combos.ts");
|
||||
|
||||
async function resetStorage(): Promise<void> {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
registerComboRepositoryConformance(async () => ({
|
||||
combos: sqliteComboRepository,
|
||||
mappings: sqliteModelComboMappingRepository,
|
||||
reset: resetStorage,
|
||||
async corruptComboPayload(comboId: string): Promise<void> {
|
||||
core.getDbInstance().prepare("UPDATE combos SET data = ? WHERE id = ?").run("", comboId);
|
||||
},
|
||||
}));
|
||||
|
||||
test("legacy combo count facade remains synchronous", async () => {
|
||||
await resetStorage();
|
||||
|
||||
assert.equal(typeof combosDb.getCombosCount(), "number");
|
||||
assert.equal(combosDb.getCombosCount(), 0);
|
||||
await sqliteComboRepository.create({ name: "Counted", models: [] });
|
||||
assert.equal(combosDb.getCombosCount(), 1);
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
Reference in New Issue
Block a user