mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
This commit is contained in:
committed by
GitHub
parent
92eac63f1f
commit
ebd341edf3
@@ -10,6 +10,7 @@ _In development — bullets added per PR; finalized at release._
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **feat(routing): OpenRouter-style `auto/<category>:<tier>` 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/<category>:<tier>` 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/<category>:<tier>` suffixes + new categories follow). ([#4235](https://github.com/diegosouzapw/OmniRoute/issues/4235) — thanks @MRDGH2821)
|
||||
|
||||
### 🐛 Fixed
|
||||
|
||||
@@ -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/<category>:<tier> 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 (<cap); both edits are thin cohesive wiring at the single correct integration point, not extractable.",
|
||||
"_rebaseline_2026_06_18_claude_adaptive_thinking": "PR (Claude Opus 4.7+ adaptive-thinking flow) own growth: chatCore.ts 5095->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 (<cap), mirroring normalizeMimoThinking (#4224). Cohesive at the existing thinking-normalization chokepoint, next to normalizeThinkingForModel/normalizeMimoThinking; not extractable without hiding the normalization boundary. Structural shrink of chatCore.ts tracked in #3501.",
|
||||
"_rebaseline_2026_06_18_4221_tool_cardinality": "PR #4221 own growth: server.ts 1458->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 <cap). Not extractable without hiding the emit boundary, mirroring the prior compression rebaselines (#4210/#4004). Structural shrink of chatCore.ts tracked in #3501.",
|
||||
@@ -132,7 +133,7 @@
|
||||
"src/app/api/providers/[id]/models/route.ts": 2534,
|
||||
"src/app/api/providers/[id]/test/route.ts": 842,
|
||||
"src/app/api/usage/analytics/route.ts": 941,
|
||||
"src/app/api/v1/models/catalog.ts": 1463,
|
||||
"src/app/api/v1/models/catalog.ts": 1465,
|
||||
"src/lib/cloudflaredTunnel.ts": 934,
|
||||
"src/lib/db/apiKeys.ts": 1661,
|
||||
"src/lib/db/core.ts": 1820,
|
||||
@@ -159,7 +160,7 @@
|
||||
"src/shared/constants/sidebarVisibility.ts": 1100,
|
||||
"src/shared/services/cliRuntime.ts": 1090,
|
||||
"src/shared/validation/schemas.ts": 2523,
|
||||
"src/sse/handlers/chat.ts": 1471,
|
||||
"src/sse/handlers/chat.ts": 1486,
|
||||
"src/sse/services/auth.ts": 2219
|
||||
},
|
||||
"_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores — proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.",
|
||||
|
||||
@@ -129,8 +129,16 @@ describe("Self-Healing", () => {
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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<string, AutoVariant | undefined> = {
|
||||
"auto/claude-sonnet": "coding",
|
||||
};
|
||||
|
||||
/**
|
||||
* #4235 Phase B — curated `auto/<category>[:<tier>]` combos advertised in `/v1/models`
|
||||
* and the dashboard. ANY valid `auto/<category>:<tier>` 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/<category>[:<tier>]` 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/<category>[:<tier>]` (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}`);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,22 @@ export const MODE_PACKS: Record<string, ScoringWeights> = {
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
140
open-sse/services/autoCombo/suffixComposition.ts
Normal file
140
open-sse/services/autoCombo/suffixComposition.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* #4235 Phase B — OpenRouter-style `auto/<category>:<tier>` 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/<category>[:<tier>]` 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<string>(AUTO_CATEGORIES);
|
||||
const TIER_SET = new Set<string>(AUTO_TIERS);
|
||||
|
||||
export interface ParsedAutoSuffix {
|
||||
valid: boolean;
|
||||
category?: AutoCategory;
|
||||
tier?: AutoTier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the suffix after `auto/`. Recognizes:
|
||||
* - `<category>` → `{ category }` (e.g. `vision`)
|
||||
* - `<category>:<tier>` → `{ 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";
|
||||
}
|
||||
}
|
||||
@@ -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/<category>:<tier>` 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<VirtualAutoCombo> {
|
||||
const [connections, settings] = await Promise.all([
|
||||
getProviderConnections({ isActive: true }) as Promise<VirtualFactoryConn[]>,
|
||||
@@ -276,6 +289,25 @@ export async function createVirtualAutoCombo(
|
||||
};
|
||||
}
|
||||
|
||||
// #4235 Phase B: narrow the pool by the `auto/<category>:<tier>` 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"}`,
|
||||
|
||||
@@ -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/<category>[:<tier>]` combos.
|
||||
for (const autoId of [...Object.keys(AUTO_TEMPLATE_VARIANTS), ...AUTO_SUFFIX_VARIANTS]) {
|
||||
if (listedIds.has(autoId)) continue;
|
||||
listedIds.add(autoId);
|
||||
const baseAutoEntry = {
|
||||
|
||||
@@ -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/<category>:<tier>` 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;
|
||||
|
||||
120
tests/unit/auto-combos-suffixes-4235.test.ts
Normal file
120
tests/unit/auto-combos-suffixes-4235.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* #4235 Phase B — OpenRouter-style `auto/<category>:<tier>` 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/<category>:<tier> 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");
|
||||
});
|
||||
Reference in New Issue
Block a user