From ebd341edf3202453a1f7d0e0d968da1710bbf5fd Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 19 Jun 2026 01:22:56 -0300 Subject: [PATCH] feat(routing): OpenRouter-style auto/: combos (#4235) (#4248) --- CHANGELOG.md | 1 + config/quality/file-size-baseline.json | 5 +- .../autoCombo/__tests__/autoCombo.test.ts | 12 +- open-sse/services/autoCombo/builtinCatalog.ts | 55 ++++++- open-sse/services/autoCombo/modePacks.ts | 16 ++ .../services/autoCombo/suffixComposition.ts | 140 ++++++++++++++++++ open-sse/services/autoCombo/virtualFactory.ts | 58 +++++++- src/app/api/v1/models/catalog.ts | 4 +- src/sse/handlers/chat.ts | 21 ++- tests/unit/auto-combos-suffixes-4235.test.ts | 120 +++++++++++++++ 10 files changed, 413 insertions(+), 19 deletions(-) create mode 100644 open-sse/services/autoCombo/suffixComposition.ts create mode 100644 tests/unit/auto-combos-suffixes-4235.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b60f4c20b9..e6df2f8119 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._ ### ✨ New Features +- **feat(routing): OpenRouter-style `auto/:` combos** — auto-routing now understands suffixed combos that separate the *category* (what kind of route) from the *tier* (how to optimize): `auto/coding:fast`, `auto/coding:cheap` (alias `:floor`), `auto/coding:free`, `auto/coding:pro`, `auto/coding:reliable`, plus the new category roots `auto/reasoning`, `auto/vision`, `auto/multimodal`. The **tier** picks the scoring weights — `:fast` → ship-fast, `:cheap`/`:floor` → cost-saver, `:reliable` → a new reliability-first pack (circuit-breaker health + latency stability) — while `:free`/`:pro` filter the candidate pool by model tier (`classifyTier`: free-tier vs. premium models). The **category** filters the pool by capability (`vision`/`multimodal` → vision-capable models, `reasoning` → reasoning/thinking models). Any valid `auto/:` resolves on demand; a curated set is advertised in `/v1/models` and the dashboard. Filtering is fail-open — if a constraint matches no connected models the full pool is used so routing never breaks. All composition lives in the new `open-sse/services/autoCombo/suffixComposition.ts`; the core combo scorer (`combo.ts`) is untouched. Second slice of #4235 (premium account-tier weighting is a later follow-up). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821) - **feat(routing): advertise the `auto/cheap`, `auto/offline`, `auto/smart` combos (catalog ↔ README sync)** — the README lists `auto/cheap` (cheapest-per-token first), `auto/offline` (most quota/rate-limit headroom first) and `auto/smart` (quality-first + 10% exploration), and they already resolved at request time via `parseAutoPrefix` → `createVirtualAutoCombo`. But they were missing from `AUTO_TEMPLATE_VARIANTS`, so `/v1/models` and the dashboard combos list (which iterate that catalog) never showed them — the catalog drifted from the docs (visible in the issue's screenshots). Added the three entries so they're advertised everywhere alongside the other built-in `auto/*` combos. First slice of #4235 (OpenRouter-style `auto/:` suffixes + new categories follow). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821) ### 🐛 Fixed diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 884e6c1bd7..8c921c065e 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -1,5 +1,6 @@ { "_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.", + "_rebaseline_2026_06_19_4235_auto_suffix_combos": "#4235 Phase B own growth: chat.ts 1471->1486 (+15 = auto/: recognition at the existing zero-config auto-routing chokepoint — parseAutoSuffix wiring + autoSpec thread-through to createVirtualAutoCombo) and catalog.ts 1463->1465 (+2 = AUTO_SUFFIX_VARIANTS import + advertise the curated suffix combos in the existing /v1/models auto loop). All real logic (the parser, tier->weights, candidate filter) lives in the new open-sse/services/autoCombo/suffixComposition.ts (5102 (+7 = wire normalizeClaudeAdaptiveThinking(translatedBody, finalModelToUpstream) at the existing post-translation thinking-normalization chokepoint, right after normalizeThinkingForModel — its import line + a 5-line explanatory comment + the call). Opus 4.7+/Fable 5 removed manual extended thinking: `thinking.type:\"enabled\"` or any `thinking.budget_tokens` is a hard 400, so this collapses any manual thinking that reached dispatch (passthrough legacy shape, reasoning_effort buckets, per-model defaults) to `{type:\"adaptive\"}`, keyed on the resolved upstream model. The reusable guard lives in the new pure leaf open-sse/services/claudeAdaptiveThinking.ts (1468 (+10 at the existing registerTool override in createMcpServer = F4.3 opt-in tool-cardinality wiring). Reads readMcpToolProfileFromEnv(process.env) once (MCP_TOOL_DENY/MCP_TOOL_ALLOW; null = no filter, the default), and when a registered tool is denied by the profile calls registered.disable() so it is not announced in tools/list (token savings). The default (null) profile never enters the branch — existing behavior byte-identical. The reusable parser + reduceToolManifest decision live in the (non-frozen) toolCardinality.ts. Cohesive opt-in feature at the registration chokepoint; not extractable without hiding the register boundary.", "_rebaseline_2026_06_18_4217_compression_step_streaming": "PR #4217 own growth: chatCore.ts 5063->5086 (+23 at the existing compression-apply chokepoint = the best-effort onEngineStep callback threaded into applyCompressionAsync). The callback builds a compression.step payload and fires emit(\"compression.step\", …) + forwardDashboardEventToLiveWs(…) once per stacked engine as it completes (F3.3 live per-engine streaming), wrapped in try/catch so it never fails the request. It closes over the same emit/traceId/mode locals as the compression.completed emit right below it (line 1749); the reusable per-engine emission lives in strategySelector.ts (reportEngineStep + StackedCompressionStep) and the studio reducers in compressionFlowModel.ts (both { }); describe("Mode Packs", () => { - it("should have 4 mode packs", () => { - expect(getModePackNames()).toHaveLength(4); + it("should have 5 mode packs", () => { + // #4235 Phase B added reliability-first (for the `:reliable` tier). + expect(getModePackNames()).toHaveLength(5); + expect(getModePackNames()).toContain("reliability-first"); + }); + + it("reliability-first should prioritize health and stability", () => { + const pack = MODE_PACKS["reliability-first"]; + expect(pack.health).toBeGreaterThan(pack.latencyInv); + expect(pack.stability).toBeGreaterThan(pack.costInv); }); it("all mode pack weights should sum to 1.0", () => { diff --git a/open-sse/services/autoCombo/builtinCatalog.ts b/open-sse/services/autoCombo/builtinCatalog.ts index 2874b3bea0..107b257930 100644 --- a/open-sse/services/autoCombo/builtinCatalog.ts +++ b/open-sse/services/autoCombo/builtinCatalog.ts @@ -1,5 +1,6 @@ import type { AutoVariant } from "./autoPrefix"; import { VALID_VARIANTS } from "./autoPrefix"; +import { parseAutoSuffix } from "./suffixComposition"; /** * Built-in `auto/*` catalog → AutoVariant resolution. @@ -38,6 +39,24 @@ export const AUTO_TEMPLATE_VARIANTS: Record = { "auto/claude-sonnet": "coding", }; +/** + * #4235 Phase B — curated `auto/[:]` combos advertised in `/v1/models` + * and the dashboard. ANY valid `auto/:` resolves on demand (so clients + * can ask for combinations not listed here); this curated set keeps the advertised + * catalog from exploding into the full category × tier matrix. + */ +export const AUTO_SUFFIX_VARIANTS: string[] = [ + "auto/coding:fast", + "auto/coding:cheap", + "auto/coding:free", + "auto/coding:pro", + "auto/coding:reliable", + "auto/reasoning", + "auto/reasoning:pro", + "auto/vision", + "auto/multimodal", +]; + type ResolvedAutoVariant = | { recognized: true; variant: AutoVariant | undefined } | { recognized: false }; @@ -52,15 +71,37 @@ export function resolveAutoVariant(modelStr: string, suffix: string): ResolvedAu return { recognized: false }; } +/** + * Recognize any built-in `auto/*` id: a flat-variant template (legacy) OR a + * `auto/[:]` suffix (#4235 Phase B). Used by the chat handler to + * decide whether an `auto/` model is a valid built-in before materializing it. + */ +export function isRecognizedBuiltinAuto(modelStr: string, suffix: string): boolean { + return resolveAutoVariant(modelStr, suffix).recognized || parseAutoSuffix(suffix).valid; +} + export async function createBuiltinAutoCombo(modelStr: string, suffix: string) { + const { createVirtualAutoCombo } = await import("./virtualFactory.ts"); + const resolved = resolveAutoVariant(modelStr, suffix); - if (!resolved.recognized) { - throw new Error(`Unknown built-in auto combo: ${modelStr}`); + if (resolved.recognized) { + const virtualCombo = await createVirtualAutoCombo(resolved.variant); + virtualCombo.name = modelStr; + virtualCombo.id = modelStr; + return virtualCombo; } - const { createVirtualAutoCombo } = await import("./virtualFactory.ts"); - const virtualCombo = await createVirtualAutoCombo(resolved.variant); - virtualCombo.name = modelStr; - virtualCombo.id = modelStr; - return virtualCombo; + // #4235 Phase B: `auto/[:]` (e.g. auto/coding:fast, auto/vision). + const parsed = parseAutoSuffix(suffix); + if (parsed.valid) { + const virtualCombo = await createVirtualAutoCombo(undefined, { + category: parsed.category, + tier: parsed.tier, + }); + virtualCombo.name = modelStr; + virtualCombo.id = modelStr; + return virtualCombo; + } + + throw new Error(`Unknown built-in auto combo: ${modelStr}`); } diff --git a/open-sse/services/autoCombo/modePacks.ts b/open-sse/services/autoCombo/modePacks.ts index a912fdc389..e5607bd3eb 100644 --- a/open-sse/services/autoCombo/modePacks.ts +++ b/open-sse/services/autoCombo/modePacks.ts @@ -72,6 +72,22 @@ export const MODE_PACKS: Record = { resetWindowAffinity: 0, connectionDensity: 0.05, }, + // #4235 `:reliable` — prioritize healthy, low-variance providers (high availability). + // health (circuit-breaker) + stability (latency std-dev) dominate; weights sum to 1.0. + "reliability-first": { + quota: 0.14, + health: 0.37, + costInv: 0.04, + latencyInv: 0.05, + taskFit: 0.1, + stability: 0.2, + tierPriority: 0.05, + tierAffinity: 0, + specificityMatch: 0, + contextAffinity: 0.0, + resetWindowAffinity: 0, + connectionDensity: 0.05, + }, }; /** diff --git a/open-sse/services/autoCombo/suffixComposition.ts b/open-sse/services/autoCombo/suffixComposition.ts new file mode 100644 index 0000000000..076fae1542 --- /dev/null +++ b/open-sse/services/autoCombo/suffixComposition.ts @@ -0,0 +1,140 @@ +/** + * #4235 Phase B — OpenRouter-style `auto/:` composition. + * + * The built-in catalog (builtinCatalog.ts) historically mapped each `auto/*` id to + * a single flat `AutoVariant` (coding/fast/cheap/offline/smart/lkgp). That conflates + * two orthogonal axes the issue asks to separate: + * + * - **category** (what kind of route): coding · reasoning · vision · chat · multimodal + * → a *candidate filter* (vision/reasoning/multimodal keep only capable models). + * - **tier** (how to optimize): fast · cheap/floor · free · reliable · pro + * → scoring *weights* (a mode pack) and, for free/pro, a model-tier *filter* + * via `classifyTier` (free → free models only, pro → premium models only). + * + * This module parses `auto/[:]` and turns it into a weight variant + + * an optional candidate filter, all without touching the core combo scorer + * (`combo.ts::buildAutoCandidates`) — the filter is applied to the candidate pool in + * `virtualFactory.createVirtualAutoCombo`, and the weights reuse the existing mode packs. + */ +import type { AutoVariant } from "./autoPrefix"; +import { classifyTier } from "../tierResolver"; +import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { isVisionModelId } from "@/shared/constants/visionModels"; + +export type AutoCategory = "coding" | "reasoning" | "vision" | "chat" | "multimodal"; +export type AutoTier = "fast" | "cheap" | "floor" | "free" | "reliable" | "pro"; + +export const AUTO_CATEGORIES: readonly AutoCategory[] = [ + "coding", + "reasoning", + "vision", + "chat", + "multimodal", +]; +export const AUTO_TIERS: readonly AutoTier[] = ["fast", "cheap", "floor", "free", "reliable", "pro"]; + +const CATEGORY_SET = new Set(AUTO_CATEGORIES); +const TIER_SET = new Set(AUTO_TIERS); + +export interface ParsedAutoSuffix { + valid: boolean; + category?: AutoCategory; + tier?: AutoTier; +} + +/** + * Parse the suffix after `auto/`. Recognizes: + * - `` → `{ category }` (e.g. `vision`) + * - `:` → `{ category, tier }` (e.g. `coding:fast`) + * + * Tier-only flat variants (`fast`, `cheap`, `smart`, …) are NOT handled here — they + * stay with the legacy `parseAutoPrefix` path so existing behavior is unchanged. + */ +export function parseAutoSuffix(suffix: string | null | undefined): ParsedAutoSuffix { + if (typeof suffix !== "string" || suffix.length === 0) return { valid: false }; + const parts = suffix.split(":"); + if (parts.length > 2) return { valid: false }; + const [head, tail] = parts; + + if (tail !== undefined) { + if (!CATEGORY_SET.has(head) || !TIER_SET.has(tail)) return { valid: false }; + return { valid: true, category: head as AutoCategory, tier: tail as AutoTier }; + } + if (CATEGORY_SET.has(head)) return { valid: true, category: head as AutoCategory }; + return { valid: false }; +} + +/** + * Map a tier to the mode-pack variant used for scoring weights. + * `floor` is an alias of `cheap`. `free`/`pro` carry no weight bias (they default to + * the quality-first pack) — their effect is the candidate filter below. + * `reliable` resolves to the dedicated reliability pack handled in the factory. + */ +export function tierToWeightVariant(tier?: AutoTier): AutoVariant | "reliability" | undefined { + switch (tier) { + case "fast": + return "fast"; + case "cheap": + case "floor": + return "cheap"; + case "reliable": + return "reliability"; + default: + return undefined; + } +} + +interface PoolCandidate { + provider: string; + model: string; +} + +/** + * Build a candidate filter from category + tier. Returns `null` when no filtering is + * needed (coding/chat with no model-tier constraint), so the caller can skip the pass. + * Category and tier checks are AND-combined. + */ +export function buildAutoCandidateFilter( + category?: AutoCategory, + tier?: AutoTier +): ((candidate: PoolCandidate) => boolean) | null { + const checks: Array<(c: PoolCandidate) => boolean> = []; + + if (category === "vision" || category === "multimodal") { + checks.push((c) => { + try { + const caps = getResolvedModelCapabilities({ provider: c.provider, model: c.model }); + return caps.supportsVision === true || isVisionModelId(c.model); + } catch { + return isVisionModelId(c.model); + } + }); + } + if (category === "reasoning") { + checks.push((c) => { + try { + const caps = getResolvedModelCapabilities({ provider: c.provider, model: c.model }); + return caps.reasoning === true || caps.supportsThinking === true; + } catch { + return false; + } + }); + } + if (tier === "free") { + checks.push((c) => safeClassifyTier(c) === "free"); + } + if (tier === "pro") { + checks.push((c) => safeClassifyTier(c) === "premium"); + } + + if (checks.length === 0) return null; + return (candidate: PoolCandidate) => checks.every((fn) => fn(candidate)); +} + +function safeClassifyTier(c: PoolCandidate): string { + try { + return classifyTier(c.provider, c.model).tier; + } catch { + return "cheap"; + } +} diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index 49e1c7b9b0..1891396ac8 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -11,6 +11,18 @@ import { hasUsableWebSessionCredential } from "@/shared/providers/webSessionCred import { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; import { getTokenLimit } from "../contextManager"; import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { + buildAutoCandidateFilter, + tierToWeightVariant, + type AutoCategory, + type AutoTier, +} from "./suffixComposition"; + +/** #4235 Phase B: optional category/tier overlay for `auto/:` combos. */ +export interface AutoComboSpec { + category?: AutoCategory; + tier?: AutoTier; +} /** Minimal connection shape needed for virtual auto-combo factory */ interface VirtualFactoryConn extends ConnectionFields { @@ -213,7 +225,8 @@ export function computeAdvertisedLimits(candidates: Array<{ provider: string; mo } export async function createVirtualAutoCombo( - variant: AutoVariant | undefined + variant: AutoVariant | undefined, + spec?: AutoComboSpec ): Promise { const [connections, settings] = await Promise.all([ getProviderConnections({ isActive: true }) as Promise, @@ -276,6 +289,25 @@ export async function createVirtualAutoCombo( }; } + // #4235 Phase B: narrow the pool by the `auto/:` overlay + // (vision/reasoning capability, free/premium model tier). Fall back to the full + // pool if the filter would empty it — never break routing, just lose the bias. + let effectivePool = candidatePool; + const candidateFilter = spec ? buildAutoCandidateFilter(spec.category, spec.tier) : null; + if (candidateFilter) { + const narrowed = candidatePool.filter((c) => + candidateFilter({ provider: c.provider, model: c.model }) + ); + if (narrowed.length > 0) { + effectivePool = narrowed; + } else { + log.warn( + "AUTO", + `auto/${spec?.category ?? ""}${spec?.tier ? `:${spec.tier}` : ""} matched no connected models; using the full pool` + ); + } + } + let weights: ScoringWeights = { ...DEFAULT_WEIGHTS }; let explorationRate = 0.05; // Default exploration rate let routerStrategy = "lkgp"; // All auto variants use LKGP @@ -306,8 +338,26 @@ export async function createVirtualAutoCombo( break; } - const providerPool = [...new Set(candidatePool.map((c) => c.provider))]; - const models = candidatePool.map((candidate, index) => ({ + // #4235 Phase B: category/tier weight overlay. A non-chat category leans + // quality-first; the tier then refines toward latency (fast), cost (cheap/floor) + // or availability (reliable). free/pro keep the base weights — their bias is the + // candidate filter above (free → free-tier models, pro → premium models). + if (spec) { + if (spec.category && spec.category !== "chat") { + weights = { ...MODE_PACKS["quality-first"] }; + } + const weightVariant = tierToWeightVariant(spec.tier); + if (weightVariant === "fast") { + weights = { ...MODE_PACKS["ship-fast"] }; + } else if (weightVariant === "cheap") { + weights = { ...MODE_PACKS["cost-saver"] }; + } else if (weightVariant === "reliability") { + weights = { ...MODE_PACKS["reliability-first"] }; + } + } + + const providerPool = [...new Set(effectivePool.map((c) => c.provider))]; + const models = effectivePool.map((candidate, index) => ({ id: `virtual-auto-${variant || "default"}-${index + 1}-${candidate.provider}`, kind: "model" as const, model: candidate.modelStr, @@ -323,7 +373,7 @@ export async function createVirtualAutoCombo( routerStrategy, }; - const advertisedLimits = computeAdvertisedLimits(candidatePool); + const advertisedLimits = computeAdvertisedLimits(effectivePool); return { id: `virtual-auto-${variant || "default"}`, diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index d747e48c07..b0bbe7784f 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -22,6 +22,7 @@ import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/mod import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo"; import { AUTO_TEMPLATE_VARIANTS, + AUTO_SUFFIX_VARIANTS, createBuiltinAutoCombo, } from "@omniroute/open-sse/services/autoCombo/builtinCatalog"; import { getAllSyncedAvailableModels, type SyncedAvailableModel } from "@/lib/db/models"; @@ -665,7 +666,8 @@ export async function getUnifiedModelsResponse( // receive token metadata before the first request instead of a bare entry. If the // combo cannot be materialized (e.g. no eligible connections yet) the minimal // #4164 entry is emitted instead, so the id is never dropped. - for (const autoId of Object.keys(AUTO_TEMPLATE_VARIANTS)) { + // #4235 Phase B: also advertise the curated `auto/[:]` combos. + for (const autoId of [...Object.keys(AUTO_TEMPLATE_VARIANTS), ...AUTO_SUFFIX_VARIANTS]) { if (listedIds.has(autoId)) continue; listedIds.add(autoId); const baseAutoEntry = { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 9fc29806cd..7c0d0c0ebc 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -34,6 +34,11 @@ import { AUTO_TEMPLATE_VARIANTS, VALID_AUTO_VARIANTS, } from "@omniroute/open-sse/services/autoCombo/builtinCatalog.ts"; +import { + parseAutoSuffix, + type AutoCategory, + type AutoTier, +} from "@omniroute/open-sse/services/autoCombo/suffixComposition.ts"; import * as log from "../utils/logger"; import { checkAndRefreshToken } from "../services/tokenRefresh"; import { createHookContext, runHooks, initPreRequestRegistry } from "@/lib/middleware/registry"; @@ -395,6 +400,8 @@ export async function handleChat(request: any, clientRawRequest: any = null) { // If the model ID is "auto" or starts with "auto/", bypass DB combo lookup // entirely and generate a virtual auto-combo on-the-fly from connected providers. let autoVariant: AutoVariant | undefined; + // #4235 Phase B: `auto/:` overlay (e.g. auto/coding:fast, auto/vision). + let autoSpec: { category?: AutoCategory; tier?: AutoTier } | undefined; let isAutoRouting = resolvedModelStr === "auto" || resolvedModelStr.startsWith("auto/"); let recognizedBuiltInAuto = resolvedModelStr === "auto"; if (Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, resolvedModelStr)) { @@ -402,7 +409,15 @@ export async function handleChat(request: any, clientRawRequest: any = null) { autoVariant = AUTO_TEMPLATE_VARIANTS[resolvedModelStr]; } else if (resolvedModelStr.startsWith("auto/")) { const suffix = resolvedModelStr.slice(5); - recognizedBuiltInAuto = VALID_AUTO_VARIANTS.has(suffix as AutoVariant); + if (VALID_AUTO_VARIANTS.has(suffix as AutoVariant)) { + recognizedBuiltInAuto = true; + } else { + const parsedSuffix = parseAutoSuffix(suffix); + if (parsedSuffix.valid) { + recognizedBuiltInAuto = true; + autoSpec = { category: parsedSuffix.category, tier: parsedSuffix.tier }; + } + } } if (isAutoRouting) { @@ -439,7 +454,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) { "AUTO", `Zero-config routing variant: ${autoVariant || "default"} (model=${resolvedModelStr})` ); - } else { + } else if (!autoSpec) { log.warn("AUTO", `Invalid auto prefix format: ${resolvedModelStr}`); } } catch (err) { @@ -477,7 +492,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) { try { const { createVirtualAutoCombo } = await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts"); - const virtualCombo = await createVirtualAutoCombo(autoVariant); + const virtualCombo = await createVirtualAutoCombo(autoVariant, autoSpec); virtualCombo.name = resolvedModelStr; virtualCombo.id = resolvedModelStr; combo = virtualCombo; diff --git a/tests/unit/auto-combos-suffixes-4235.test.ts b/tests/unit/auto-combos-suffixes-4235.test.ts new file mode 100644 index 0000000000..6a2eaad834 --- /dev/null +++ b/tests/unit/auto-combos-suffixes-4235.test.ts @@ -0,0 +1,120 @@ +/** + * #4235 Phase B — OpenRouter-style `auto/:` suffix combos. + * + * Covers the pure composition layer (parse + tier→weights + candidate filter) and + * the end-to-end advertisement of the curated suffix combos in `/v1/models`. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-4235b-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "auto-4235b-test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const suffix = await import("../../open-sse/services/autoCombo/suffixComposition.ts"); +const modePacks = await import("../../open-sse/services/autoCombo/modePacks.ts"); +const builtinCatalog = await import("../../open-sse/services/autoCombo/builtinCatalog.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); + +function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(() => resetStorage()); +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("#4235 parseAutoSuffix parses category and category:tier", () => { + assert.deepEqual(suffix.parseAutoSuffix("coding:fast"), { + valid: true, + category: "coding", + tier: "fast", + }); + assert.deepEqual(suffix.parseAutoSuffix("vision"), { valid: true, category: "vision" }); + assert.deepEqual(suffix.parseAutoSuffix("reasoning:pro"), { + valid: true, + category: "reasoning", + tier: "pro", + }); + // floor is an accepted tier alias of cheap + assert.equal(suffix.parseAutoSuffix("coding:floor").valid, true); +}); + +test("#4235 parseAutoSuffix rejects unknown categories/tiers and malformed input", () => { + assert.equal(suffix.parseAutoSuffix("coding:bogus").valid, false); + assert.equal(suffix.parseAutoSuffix("bogus:fast").valid, false); + assert.equal(suffix.parseAutoSuffix("coding:fast:extra").valid, false); + // a bare flat-variant token (fast/cheap/smart) is NOT a category — left to parseAutoPrefix + assert.equal(suffix.parseAutoSuffix("fast").valid, false); + assert.equal(suffix.parseAutoSuffix("").valid, false); +}); + +test("#4235 tierToWeightVariant maps tiers to scoring profiles", () => { + assert.equal(suffix.tierToWeightVariant("fast"), "fast"); + assert.equal(suffix.tierToWeightVariant("cheap"), "cheap"); + assert.equal(suffix.tierToWeightVariant("floor"), "cheap"); + assert.equal(suffix.tierToWeightVariant("reliable"), "reliability"); + // free/pro carry no weight bias — the candidate filter does the work + assert.equal(suffix.tierToWeightVariant("free"), undefined); + assert.equal(suffix.tierToWeightVariant("pro"), undefined); + assert.equal(suffix.tierToWeightVariant(undefined), undefined); +}); + +test("#4235 buildAutoCandidateFilter only filters when a constraint applies", () => { + // coding/chat with no tier → no narrowing + assert.equal(suffix.buildAutoCandidateFilter("coding", undefined), null); + assert.equal(suffix.buildAutoCandidateFilter("chat", undefined), null); + // category constraints (vision/reasoning/multimodal) and tier constraints (free/pro) → a filter fn + assert.equal(typeof suffix.buildAutoCandidateFilter("vision", undefined), "function"); + assert.equal(typeof suffix.buildAutoCandidateFilter("reasoning", undefined), "function"); + assert.equal(typeof suffix.buildAutoCandidateFilter(undefined, "free"), "function"); + assert.equal(typeof suffix.buildAutoCandidateFilter("coding", "pro"), "function"); +}); + +test("#4235 reliability-first mode pack exists and is normalized", () => { + const pack = modePacks.MODE_PACKS["reliability-first"]; + assert.ok(pack, "reliability-first pack registered"); + const sum = Object.values(pack).reduce((a, b) => a + b, 0); + assert.ok(Math.abs(sum - 1.0) < 0.01, `reliability-first weights sum to ~1.0 (got ${sum})`); + // health + stability should dominate + assert.ok(pack.health >= 0.3, "reliability-first leans on circuit-breaker health"); +}); + +test("#4235 createBuiltinAutoCombo composes tier weights for auto/coding:fast", async () => { + const combo = await builtinCatalog.createBuiltinAutoCombo("auto/coding:fast", "coding:fast"); + assert.equal(combo.id, "auto/coding:fast"); + assert.equal(combo.strategy, "auto"); + // :fast → ship-fast weights (latency-dominant) + assert.deepEqual(combo.weights, modePacks.MODE_PACKS["ship-fast"]); +}); + +test("#4235 createBuiltinAutoCombo composes reliability weights for auto/coding:reliable", async () => { + const combo = await builtinCatalog.createBuiltinAutoCombo( + "auto/coding:reliable", + "coding:reliable" + ); + assert.deepEqual(combo.weights, modePacks.MODE_PACKS["reliability-first"]); +}); + +test("#4235 /v1/models advertises the curated auto/: combos", async () => { + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + assert.equal(response.status, 200); + const body = (await response.json()) as { data: Array<{ id: string; owned_by?: string }> }; + const ids = new Set(body.data.map((m) => m.id)); + + for (const autoId of builtinCatalog.AUTO_SUFFIX_VARIANTS) { + assert.ok(ids.has(autoId), `expected /v1/models to advertise ${autoId}`); + } + const codingFast = body.data.find((m) => m.id === "auto/coding:fast"); + assert.equal(codingFast?.owned_by, "combo"); +});