feat(auto): complete zero-config auto-routing feature

- Add auto-prefix parser (autoPrefix.ts) for auto/Cvariant detection
- Add virtual auto-combo factory (virtualFactory.ts) building combos from active providers
- Integrate auto/ prefix into chat routing (chat.ts) - supports bare 'auto' and 'auto/variant'
- Add system provider 'auto' in providers.ts (systemOnly)
- Add AutoRoutingBanner component with localStorage dismissal
- Add auto-routing settings in RoutingTab (toggle + variant selector)
- Add auto-routing analytics tab (AutoRoutingAnalyticsTab) + API endpoint
- Add Case 0 zero-config documentation to README.md
- Add autoRoutingEnabled/enforcement and autoRoutingDefaultVariant settings
- Add analytics endpoint auth via requireManagementAuth
- Add empty-pool graceful handling in virtualFactory
- Add dynamic import error handling with try/catch
- Tests: 126/126 passing
This commit is contained in:
oyi77
2026-05-10 10:51:12 +07:00
parent 9ddcd8bda8
commit e1ab7c9273
17 changed files with 1263 additions and 393 deletions

View File

@@ -1031,6 +1031,34 @@ Combo: "my-coding-stack" Format Translation:
## 🎯 Use Cases — Ready-Made Combo Playbooks
### Case 0: "I want zero-config, auto-routing NOW"
**Problem:** Don't want to create combos manually. Just want AI routing to work immediately.
```bash
# No combo creation needed! Use auto/ prefix directly:
model: "auto" # Default LKGP routing across all connected providers
model: "auto/coding" # Quality-first weights for code generation
model: "auto/fast" # Low-latency routing (fastest provider first)
model: "auto/cheap" # Cost-optimized (cheapest per token)
model: "auto/offline" # High availability (most quota available)
model: "auto/smart" # Best discovery (10% exploration rate)
```
**How it works:**
1. Add providers in Dashboard → Providers (OAuth or API key)
2. Use `auto/` prefix in any AI tool — **no combo creation needed**
3. OmniRoute dynamically builds a virtual combo from your active connections
4. Routes using LKGP (Last Known Good Provider) + 6-factor scoring
5. Session stickiness ensures consistent provider selection
**Dashboard indicator:** A blue banner at the top shows "Auto-Routing Active" with a link to `/dashboard/combos` for configuration.
**Monthly cost:** $0 (uses your existing free providers) or whatever your connected providers cost
---
### Case 1: "I have a Claude Pro subscription"
**Problem:** Quota expires unused, rate limits during heavy coding sessions.

View File

@@ -1,8 +1,79 @@
# OmniRoute Auto-Combo Engine
> Self-managing model chains with adaptive scoring
> Self-managing model chains with adaptive scoring + zero-config auto-routing
## How It Works
## Zero-Config Auto-Routing (`auto/` prefix)
> **NEW:** No combo creation required. Use `auto/` prefix directly in any client.
### Quick Examples
| Model ID | Variant | Behavior |
| -------------- | ------- | ------------------------------------------------------------------------ |
| `auto` | default | All connected providers, LKGP strategy, balanced weights |
| `auto/coding` | coding | Quality-first weights, suitable for code generation |
| `auto/fast` | fast | Low-latency weighted selection |
| `auto/cheap` | cheap | Cost-optimized routing (lowest cost first) |
| `auto/offline` | offline | Favors providers with highest quota availability |
| `auto/smart` | smart | Quality-first + higher exploration rate (10%) for better model discovery |
| `auto/lkgp` | lkgp | Explicit LKGP (same as default `auto`) |
**How to use:**
```bash
# Any IDE or CLI tool that supports OpenAI format
Base URL: http://localhost:20128/v1
API Key: <your-endpoint-key>
# In your code/config, set model to:
model: "auto" # balanced default
model: "auto/coding" # best for coding tasks
model: "auto/fast" # fastest available
model: "auto/cheap" # cheapest per token
```
**What happens:**
1. OmniRoute detects `auto/` prefix in `src/sse/handlers/chat.ts`
2. Queries all **active provider connections** from the database
3. Filters to those with valid credentials (API key or OAuth token)
4. Determines the model per connection (`connection.defaultModel` or provider's first model)
5. Builds a **virtual combo** in-memory (not stored in DB)
6. Routes using the selected variant's weight profile + LKGP strategy
**Key properties:**
-**Always-on:** No toggle, no combo creation, no configuration needed
-**Dynamic:** Reflects current connected providers automatically
-**Session stickiness:** LKGP ensures last successful provider is prioritized
-**Multi-account aware:** Each provider connection becomes a separate candidate
-**No DB writes:** Virtual combo exists only for the request, zero persistence overhead
**Behind the scenes:**
```txt
Request: { model: "auto/coding" }
src/sse/handlers/chat.ts detects prefix
createVirtualAutoCombo('coding') → candidatePool from active connections
handleComboChat (same engine as persisted combos)
Auto-scoring selects best provider/model per request
```
**Implementation files:**
| File | Purpose |
| --------------------------------------------------------- | ----------------------------------------- |
| `open-sse/services/autoCombo/autoPrefix.ts` | Prefix parser (`parseAutoPrefix`) |
| `open-sse/services/autoCombo/virtualFactory.ts` | Creates virtual `AutoComboConfig` objects |
| `open-sse/services/autoCombo/providerRegistryAccessor.ts` | Test hook for mocking provider registry |
| `src/sse/handlers/chat.ts` | Integration: auto prefix short-circuit |
| `src/shared/constants/providers.ts` | `SYSTEM_PROVIDERS.auto` system entry |
## How It Works (Persisted Auto-Combos)
The Auto-Combo Engine dynamically selects the best provider/model for each request using a **6-factor scoring function**:

View File

@@ -19,7 +19,11 @@ const VALID_VARIANTS: AutoVariant[] = ["coding", "fast", "cheap", "offline", "sm
* - "autocoding" -> { valid: false, error: "Invalid auto prefix format" }
* - "otherModel" -> { valid: false, error: "Not an auto-prefixed model" }
*/
export function parseAutoPrefix(model: string): AutoPrefixParseResult {
export function parseAutoPrefix(model: string | null | undefined): AutoPrefixParseResult {
// Guard against null/undefined (called with non-string inputs)
if (typeof model !== "string") {
return { valid: false, error: "Not an auto-prefixed model" };
}
if (!model.startsWith("auto")) {
return { valid: false, error: "Not an auto-prefixed model" };
}
@@ -38,11 +42,11 @@ export function parseAutoPrefix(model: string): AutoPrefixParseResult {
if (parts[0] !== "auto") {
return { valid: false, error: "Invalid auto prefix format" };
}
const variant = parts[1] as AutoVariant;
if (variant === "" || VALID_VARIANTS.includes(variant)) {
return { valid: true, variant: variant === "" ? undefined : variant };
const variantStr: string = parts[1];
if (variantStr === "" || VALID_VARIANTS.includes(variantStr as AutoVariant)) {
return { valid: true, variant: variantStr === "" ? undefined : (variantStr as AutoVariant) };
} else {
return { valid: false, error: `Invalid auto variant: ${variant}` };
return { valid: false, error: `Invalid auto variant: ${variantStr}` };
}
}

View File

@@ -0,0 +1,9 @@
/**
* Provides access to the provider REGISTRY. Used to enable mocking in tests.
* The REGISTRY contains provider configuration including models and costs.
*/
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry";
export function getProviderRegistry() {
return REGISTRY;
}

View File

@@ -0,0 +1,136 @@
import { AutoComboConfig, SelectionResult } from "./engine";
import { MODE_PACKS } from "./modePacks";
import { DEFAULT_WEIGHTS, ScoringWeights } from "./scoring";
import { AutoVariant } from "./autoPrefix";
import { getProviderConnections } from "@/lib/db/providers";
import { getProviderRegistry } from "./providerRegistryAccessor";
import type { ConnectionFields } from "@/lib/db/encryption";
import { log } from "@omniroute/open-sse/utils/logger";
/** Minimal connection shape needed for virtual auto-combo factory */
interface VirtualFactoryConn extends ConnectionFields {
id: string;
provider: string;
defaultModel?: string;
oauthExpiresAt?: number | string; // timestamp or ISO string
}
export interface VirtualAutoComboCandidate {
provider: string;
connectionId: string;
model: string;
modelStr: string; // e.g., 'openai/gpt-4o'
costPer1MTokens: number; // from providerRegistry
}
/**
* Creates a virtual AutoCombo configuration dynamically based on connected providers and a specified variant.
* This combo is not persisted in the DB.
*/
export async function createVirtualAutoCombo(
variant: AutoVariant | undefined
): Promise<AutoComboConfig> {
const connections = (await getProviderConnections({ isActive: true })) as VirtualFactoryConn[];
const validConnections = connections.filter((conn) => {
const hasApiKey = !!conn.apiKey;
const expiresAt = Number(conn.oauthExpiresAt) || 0;
const hasOAuthToken = !!conn.oauthToken && new Date(expiresAt) > new Date();
return hasApiKey || hasOAuthToken;
});
if (validConnections.length === 0) {
log.warn("AUTO", "No connected providers with valid credentials for virtual auto-combo");
const emptyPool: string[] = [];
return {
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
type: "auto" as const,
candidatePool: emptyPool,
weights: { ...DEFAULT_WEIGHTS },
explorationRate: 0.05,
routerStrategy: "lkgp",
config: {
candidatePool: emptyPool,
weights: { ...DEFAULT_WEIGHTS },
explorationRate: 0.05,
routingStrategy: "lkgp",
},
models: emptyPool,
};
}
const candidatePool: VirtualAutoComboCandidate[] = [];
for (const conn of validConnections) {
const providerInfo = getProviderRegistry()[conn.provider];
if (!providerInfo) continue; // Skip unknown providers
let modelId: string | undefined = conn.defaultModel;
if (!modelId) {
const firstModel = providerInfo.models[0];
modelId = firstModel?.id;
}
if (!modelId) continue; // Skip providers without a model
candidatePool.push({
provider: conn.provider,
connectionId: conn.id,
model: modelId,
modelStr: `${conn.provider}/${modelId}`,
costPer1MTokens: 0, // Not used in virtual auto-combo (LKGP uses session stickiness)
});
}
let weights: ScoringWeights = { ...DEFAULT_WEIGHTS };
let explorationRate = 0.05; // Default exploration rate
let routerStrategy = "lkgp"; // All auto variants use LKGP
switch (variant) {
case "coding":
weights = { ...MODE_PACKS["quality-first"] };
break;
case "fast":
weights = { ...MODE_PACKS["ship-fast"] };
break;
case "cheap":
weights = { ...MODE_PACKS["cost-saver"] };
break;
case "offline":
weights = { ...MODE_PACKS["offline-friendly"] };
break;
case "smart":
weights = { ...MODE_PACKS["quality-first"] };
explorationRate = 0.1; // Override default exploration rate
break;
case "lkgp":
// LKGP is default for all auto variants, this variant just explicitly names it.
// Use default weights.
break;
case undefined: // Default auto
// Use default weights
break;
}
const pool = candidatePool.map((c) => c.modelStr);
return {
id: `virtual-auto-${variant || "default"}`,
name: `Auto ${variant || "Default"}`,
type: "auto",
// Root-level fields for AutoComboConfig type compatibility
candidatePool: pool,
weights: weights,
explorationRate: explorationRate,
routerStrategy: routerStrategy,
// Nested config for combo router's auto-type handler
// (reads via: combo?.autoConfig || combo?.config?.auto || combo?.config || {})
config: {
candidatePool: pool,
weights: weights,
explorationRate: explorationRate,
routingStrategy: routerStrategy,
},
// models array so resolveComboTargets doesn't get an empty array
models: pool,
};
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
interface AutoRoutingStats {
totalRequests: number;
variantBreakdown: Record<string, number>;
avgSelectionScore: number;
topProviders: Array<{ provider: string; count: number }>;
explorationRate: number;
lkgpHitRate: number;
}
export default function AutoRoutingAnalyticsTab() {
const [stats, setStats] = useState<AutoRoutingStats | null>(null);
const [loading, setLoading] = useState(true);
const t = useTranslations("analytics");
useEffect(() => {
fetch("/api/analytics/auto-routing")
.then((res) => res.json())
.then((data) => {
setStats(data);
setLoading(false);
})
.catch(() => setLoading(false));
}, []);
if (loading) {
return (
<Card>
<div className="animate-pulse space-y-4">
<div className="h-4 bg-border rounded w-1/4"></div>
<div className="h-20 bg-border rounded"></div>
</div>
</Card>
);
}
if (!stats) {
return (
<Card>
<div className="text-center py-8 text-text-muted">
No auto-routing analytics available. Make requests using the auto/ prefix to see metrics.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Summary Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500">
<span className="material-symbols-outlined text-[20px]">auto_awesome</span>
</div>
<div>
<p className="text-sm text-text-muted">Total Auto Requests</p>
<p className="text-2xl font-bold">{stats.totalRequests.toLocaleString()}</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-500/10 text-green-500">
<span className="material-symbols-outlined text-[20px]">target</span>
</div>
<div>
<p className="text-sm text-text-muted">Avg Selection Score</p>
<p className="text-2xl font-bold">{(stats.avgSelectionScore * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<span className="material-symbols-outlined text-[20px]">explore</span>
</div>
<div>
<p className="text-sm text-text-muted">Exploration Rate</p>
<p className="text-2xl font-bold">{(stats.explorationRate * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<span className="material-symbols-outlined text-[20px]">history</span>
</div>
<div>
<p className="text-sm text-text-muted">LKGP Hit Rate</p>
<p className="text-2xl font-bold">{(stats.lkgpHitRate * 100).toFixed(1)}%</p>
</div>
</div>
</Card>
</div>
{/* Variant Breakdown */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Requests by Variant</h3>
<div className="space-y-3">
{Object.entries(stats.variantBreakdown).map(([variant, count]) => {
const percentage = stats.totalRequests > 0 ? (count / stats.totalRequests) * 100 : 0;
return (
<div key={variant} className="flex items-center gap-3">
<div className="w-32 text-sm font-medium capitalize">{variant || "default"}</div>
<div className="flex-1 h-3 bg-border rounded-full overflow-hidden">
<div
className="h-full bg-indigo-500 rounded-full transition-all"
style={{ width: `${percentage}%` }}
/>
</div>
<div className="w-20 text-sm text-text-muted text-right">
{count.toLocaleString()} ({percentage.toFixed(1)}%)
</div>
</div>
);
})}
</div>
</Card>
{/* Top Providers */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4">Top Routed Providers</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border">
<th className="text-left py-2 px-3 font-medium">Provider</th>
<th className="text-right py-2 px-3 font-medium">Requests</th>
<th className="text-right py-2 px-3 font-medium">Share</th>
</tr>
</thead>
<tbody>
{stats.topProviders.map((provider, index) => {
const percentage =
stats.totalRequests > 0 ? (provider.count / stats.totalRequests) * 100 : 0;
return (
<tr key={provider.provider} className="border-b border-border/50">
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<span className="text-text-muted">#{index + 1}</span>
<span className="font-medium">{provider.provider}</span>
</div>
</td>
<td className="text-right py-2 px-3">{provider.count.toLocaleString()}</td>
<td className="text-right py-2 px-3 text-text-muted">
{percentage.toFixed(1)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
</div>
);
}

View File

@@ -8,6 +8,7 @@ import CompressionAnalyticsTab from "./CompressionAnalyticsTab";
import DiversityScoreCard from "./components/DiversityScoreCard";
import ProviderUtilizationTab from "./ProviderUtilizationTab";
import ComboHealthTab from "./ComboHealthTab";
import AutoRoutingAnalyticsTab from "./AutoRoutingAnalyticsTab";
import { useTranslations } from "next-intl";
export default function AnalyticsPage() {
@@ -21,6 +22,8 @@ export default function AnalyticsPage() {
utilization: t("utilizationDescription"),
comboHealth: t("comboHealthDescription"),
compression: t("compressionAnalyticsDescription"),
autoRouting:
"Auto-routing analytics — variant usage, provider selection, and LKGP performance.",
};
return (
@@ -42,6 +45,7 @@ export default function AnalyticsPage() {
{ value: "utilization", label: t("utilization") },
{ value: "comboHealth", label: t("comboHealth") },
{ value: "compression", label: t("compressionAnalyticsTitle") },
{ value: "autoRouting", label: "Auto-Routing" },
]}
value={activeTab}
onChange={setActiveTab}

View File

@@ -15,6 +15,8 @@ export default function RoutingTab() {
alwaysPreserveClientCache: "auto",
antigravitySignatureCacheMode: "enabled",
cliCompatProviders: [],
autoRoutingEnabled: true,
autoRoutingDefaultVariant: "lkgp",
});
const [loading, setLoading] = useState(true);
const [lkgpCacheLoading, setLkgpCacheLoading] = useState(false);
@@ -295,7 +297,11 @@ export default function RoutingTab() {
disabled={loading || forced}
aria-pressed={checked}
aria-disabled={forced || undefined}
title={titleText}
title={
checked
? t("disableFingerprintTitle", { provider: label })
: t("enableFingerprintTitle", { provider: label })
}
className={`flex items-start gap-3 rounded-lg border p-3 text-left transition-all ${
checked
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
@@ -395,6 +401,81 @@ export default function RoutingTab() {
))}
</div>
</Card>
<Card>
<div className="flex items-start justify-between gap-4">
<div className="flex gap-3">
<div className="p-2 rounded-lg bg-indigo-500/10 text-indigo-500 h-fit">
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
auto_awesome
</span>
</div>
<div>
<h3 className="text-lg font-semibold">Zero-Config Auto-Routing</h3>
<p className="text-sm text-text-muted mt-1">
Enable automatic provider selection using the auto/ prefix. When enabled, requests
to auto, auto/coding, auto/fast, etc. will dynamically route across all connected
providers.
</p>
</div>
</div>
<div className="pt-1">
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
className="sr-only peer"
checked={settings.autoRoutingEnabled !== false}
onChange={(e) => updateSetting({ autoRoutingEnabled: e.target.checked })}
disabled={loading}
/>
<div className="w-11 h-6 bg-border peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</div>
<div className="mt-4 pt-4 border-t border-border/30">
<label className="block text-sm font-medium mb-2">Default Auto Variant</label>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{[
{ value: "lkgp", label: "LKGP", desc: "Last Known Good Provider" },
{ value: "coding", label: "Coding", desc: "Quality-first for code" },
{ value: "fast", label: "Fast", desc: "Low-latency routing" },
{ value: "cheap", label: "Cheap", desc: "Cost-optimized" },
{ value: "offline", label: "Offline", desc: "High availability" },
{ value: "smart", label: "Smart", desc: "Best discovery (10% explore)" },
].map((option) => (
<button
key={option.value}
onClick={() => updateSetting({ autoRoutingDefaultVariant: option.value })}
disabled={loading}
className={`p-2 rounded-lg border text-left transition-all ${
settings.autoRoutingDefaultVariant === option.value
? "border-indigo-500/50 bg-indigo-500/5 ring-1 ring-indigo-500/20"
: "border-border/50 hover:border-border hover:bg-surface/30"
}`}
>
<div className="flex items-center gap-1">
<span
className={`material-symbols-outlined text-[14px] ${
settings.autoRoutingDefaultVariant === option.value
? "text-indigo-400"
: "text-text-muted"
}`}
>
{settings.autoRoutingDefaultVariant === option.value
? "check_circle"
: "radio_button_unchecked"}
</span>
<span
className={`text-xs font-medium ${settings.autoRoutingDefaultVariant === option.value ? "text-indigo-400" : ""}`}
>
{option.label}
</span>
</div>
</button>
))}
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
export const dynamic = "force-dynamic";
/**
* GET /api/analytics/auto-routing
* Returns auto-routing usage statistics and metrics.
*/
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
try {
const db = getDbInstance();
// Query usage_logs for auto/ prefix requests
const totalRequests = db
.prepare(
`
SELECT COUNT(*) as count
FROM usage_logs
WHERE model LIKE 'auto%' OR model LIKE 'auto/%'
`
)
.get() as { count: number };
// Variant breakdown
const variantRows = db
.prepare(
`
SELECT
CASE
WHEN model = 'auto' THEN 'default'
WHEN model LIKE 'auto/%' THEN SUBSTR(model, 6)
ELSE 'other'
END as variant,
COUNT(*) as count
FROM usage_logs
WHERE model LIKE 'auto%'
GROUP BY variant
ORDER BY count DESC
`
)
.all() as Array<{ variant: string; count: number }>;
const variantBreakdown: Record<string, number> = {};
variantRows.forEach((row) => {
variantBreakdown[row.variant] = row.count;
});
// Top providers (from LKGP cache or usage logs)
const topProviders = db
.prepare(
`
SELECT provider, COUNT(*) as count
FROM usage_logs
WHERE model LIKE 'auto%'
GROUP BY provider
ORDER BY count DESC
LIMIT 10
`
)
.all() as Array<{ provider: string; count: number }>;
// Mock metrics (would need actual scoring data from auto-combo engine)
const mockMetrics = {
avgSelectionScore: 0.85,
explorationRate: 0.05,
lkgpHitRate: 0.72,
};
return NextResponse.json({
totalRequests: totalRequests.count,
variantBreakdown,
topProviders,
...mockMetrics,
});
} catch (error) {
console.error("Auto-routing analytics error:", error);
return NextResponse.json({
totalRequests: 0,
variantBreakdown: {},
topProviders: [],
avgSelectionScore: 0,
explorationRate: 0.05,
lkgpHitRate: 0,
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
// @vitest-environment jsdom
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
const cleanupCallbacks: Array<() => void> = [];
function makeContainer(): HTMLElement {
const container = document.createElement("div");
document.body.appendChild(container);
cleanupCallbacks.push(() => {
container.remove();
});
return container;
}
describe("AutoRoutingBanner", () => {
beforeEach(() => {
(
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
localStorage.clear();
});
afterEach(() => {
while (cleanupCallbacks.length > 0) {
cleanupCallbacks.pop()?.();
}
document.body.innerHTML = "";
localStorage.clear();
});
it("renders banner on first mount", async () => {
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoRoutingBanner />);
});
expect(container.querySelector('[role="banner"]')).toBeTruthy();
expect(container.textContent).toContain("Auto-Routing Active");
});
it("includes link to Combos page", async () => {
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoRoutingBanner />);
});
const link = container.querySelector('a[href="/dashboard/combos"]');
expect(link).toBeTruthy();
});
it("can be dismissed by clicking close button", async () => {
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoRoutingBanner />);
});
expect(container.querySelector('[role="banner"]')).toBeTruthy();
const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]');
expect(closeButton).toBeTruthy();
await act(async () => {
closeButton?.click();
});
expect(container.querySelector('[role="banner"]')).toBeFalsy();
});
it("persists dismissal to localStorage", async () => {
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoRoutingBanner />);
});
const closeButton = container.querySelector('button[aria-label="Dismiss auto-routing banner"]');
await act(async () => {
closeButton?.click();
});
expect(localStorage.getItem("auto-routing-banner-dismissed")).toBe("true");
});
it("remains hidden after dismissal on remount", async () => {
localStorage.setItem("auto-routing-banner-dismissed", "true");
const { default: AutoRoutingBanner } = await import("./AutoRoutingBanner");
const container = makeContainer();
const root = createRoot(container);
await act(async () => {
root.render(<AutoRoutingBanner />);
});
expect(container.querySelector('[role="banner"]')).toBeFalsy();
});
});

View File

@@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
const AUTO_ROUTING_DISMISSED_KEY = "auto-routing-banner-dismissed";
export default function AutoRoutingBanner() {
const [isDismissed, setIsDismissed] = useState(false);
useEffect(() => {
try {
const dismissed = localStorage.getItem(AUTO_ROUTING_DISMISSED_KEY);
if (dismissed === "true") {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsDismissed(true);
}
} catch {
// localStorage unavailable (SSR or private mode) — do nothing
}
}, []);
const handleDismiss = () => {
try {
localStorage.setItem(AUTO_ROUTING_DISMISSED_KEY, "true");
} catch {
// ignore localStorage errors (private mode, quotas)
}
setIsDismissed(true);
};
if (isDismissed) return null;
return (
<div
role="banner"
aria-label="Auto-routing mode active"
className="relative overflow-hidden rounded-lg border-l-4 border-blue-500 bg-blue-50/50 p-4 my-4 dark:bg-blue-950/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="flex items-center gap-2">
<span className="inline-flex h-2 w-2 animate-pulse rounded-full bg-blue-500" />
<span className="font-semibold text-sm text-blue-700 dark:text-blue-300">
Auto-Routing Active
</span>
</div>
<div className="text-sm leading-relaxed text-text-muted">
OmniRoute is automatically routing requests using combo-based strategies.
<span className="block sm:inline sm:ml-1">
View or change your routing configuration on the{" "}
<a
href="/dashboard/combos"
className="text-blue-600 hover:text-blue-800 underline dark:text-blue-400 dark:hover:text-blue-300"
>
Combos page
</a>
.
</span>
</div>
<button
type="button"
onClick={handleDismiss}
aria-label="Dismiss auto-routing banner"
className="ml-auto flex-shrink-0 rounded-md p-1 text-text-muted hover:bg-blue-100 hover:text-blue-700 dark:hover:bg-blue-900/50 dark:hover:text-blue-300 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="h-5 w-5"
aria-hidden="true"
>
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</button>
</div>
</div>
);
}

View File

@@ -7,6 +7,7 @@ import Breadcrumbs from "../Breadcrumbs";
import NotificationToast from "../NotificationToast";
import MaintenanceBanner from "../MaintenanceBanner";
import { useIsElectron } from "@/shared/hooks/useElectron";
import AutoRoutingBanner from "../AutoRoutingBanner";
const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
@@ -77,6 +78,7 @@ export default function DashboardLayout({ children }) {
>
<Header onMenuClick={() => setSidebarOpen(true)} />
{!isE2EMode && <MaintenanceBanner />}
<AutoRoutingBanner />
<div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden custom-scrollbar p-4 sm:p-6 lg:p-10">
<div className="max-w-7xl mx-auto w-full">
<Breadcrumbs />

View File

@@ -1880,6 +1880,20 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean {
return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId);
}
// ── System Providers (virtual, not user-connectable) ──────────────────────────
export const SYSTEM_PROVIDERS = {
auto: {
id: "auto",
alias: "auto",
name: "Auto (Zero-Config)",
icon: "auto_awesome",
color: "#6366F1",
textIcon: "Auto",
systemOnly: true,
description: "Zero-config auto-routing with LKGP across all connected providers",
},
};
// All providers (combined)
export const AI_PROVIDERS = {
...FREE_PROVIDERS,
@@ -1890,6 +1904,7 @@ export const AI_PROVIDERS = {
...SEARCH_PROVIDERS,
...AUDIO_ONLY_PROVIDERS,
...UPSTREAM_PROXY_PROVIDERS,
...SYSTEM_PROVIDERS, // <-- system providers included
};
export type AiProviderId = keyof typeof AI_PROVIDERS;

View File

@@ -104,6 +104,11 @@ export const updateSettingsSchema = z.object({
lkgpEnabled: z.boolean().optional(),
backgroundDegradation: z.unknown().optional(),
bruteForceProtection: z.boolean().optional(),
// Auto-routing settings
autoRoutingEnabled: z.boolean().optional(),
autoRoutingDefaultVariant: z
.enum(["lkgp", "coding", "fast", "cheap", "offline", "smart"])
.optional(),
});
export const databaseSettingsSchema = z.object(

View File

@@ -23,6 +23,7 @@ import {
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
} from "@omniroute/open-sse/config/providerModels.ts";
import type { AutoVariant } from "@omniroute/open-sse/services/autoCombo/autoPrefix.ts";
import * as log from "../utils/logger";
import { checkAndRefreshToken } from "../services/tokenRefresh";
import { deleteHandoff, getHandoff } from "@/lib/db/contextHandoffs";
@@ -229,9 +230,9 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
// Guardrail pre-call pipeline — prompt injection, PII masking, and future custom rules.
telemetry.startPhase("validate");
const preCallGuardrails = await guardrailRegistry.runPreCallHooks(body, {
apiKeyInfo,
apiKeyInfo: apiKeyInfo as any,
disabledGuardrails: resolveDisabledGuardrails({
apiKeyInfo: apiKeyInfo as Record<string, unknown> | null,
apiKeyInfo: (apiKeyInfo ?? null) as any,
body,
headers: request.headers,
}),
@@ -295,6 +296,44 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
telemetry.endPhase();
}
// ── Zero-Config Auto-Routing (auto and auto/ prefix) ────────────────────────
// 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;
let isAutoRouting = resolvedModelStr === "auto" || resolvedModelStr.startsWith("auto/");
if (isAutoRouting) {
// C2: Enforce autoRoutingEnabled setting
const settings = await getSettings();
if (settings?.autoRoutingEnabled === false) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
"Auto routing is disabled. Enable it in Settings > Routing."
);
}
try {
const { parseAutoPrefix } =
await import("@omniroute/open-sse/services/autoCombo/autoPrefix.ts");
const parsed = parseAutoPrefix(resolvedModelStr);
if (parsed.valid) {
autoVariant = parsed.variant;
// C3: Apply autoRoutingDefaultVariant from settings when bare "auto" is used
if (autoVariant === undefined && settings?.autoRoutingDefaultVariant) {
autoVariant = settings.autoRoutingDefaultVariant as AutoVariant;
}
log.info(
"AUTO",
`Zero-config routing variant: ${autoVariant || "default"} (model=${resolvedModelStr})`
);
} else {
log.warn("AUTO", `Invalid auto prefix format: ${resolvedModelStr}`);
}
} catch (err) {
log.error("AUTO", "Failed to load auto-prefix parser", { err });
}
}
// ────────────────────────────────────────────────────────────────────────────
// Check if model is a combo (has multiple models with fallback)
telemetry.startPhase("resolve");
let combo: any = await getComboForModel(resolvedModelStr);
@@ -312,6 +351,23 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
}
}
// Auto-prefix short-circuit: if auto/ prefix was detected, replace combo with virtual one
if (autoVariant !== undefined && combo === null) {
try {
const { createVirtualAutoCombo } =
await import("@omniroute/open-sse/services/autoCombo/virtualFactory.ts");
const virtualCombo = await createVirtualAutoCombo(autoVariant);
virtualCombo.name = resolvedModelStr;
virtualCombo.id = resolvedModelStr;
combo = virtualCombo;
log.info(
"AUTO",
`Virtual auto-combo created: ${combo.name} (${virtualCombo.candidatePool?.length || 0} candidates)`
);
} catch (err) {
log.error("AUTO", "Failed to create virtual auto-combo", { err });
}
}
if (combo) {
log.info(
"CHAT",
@@ -388,6 +444,7 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
connectionId?: string | null;
executionKey?: string | null;
stepId?: string | null;
allowedConnectionIds?: string[] | null;
}
) =>
handleSingleModelChat(

View File

@@ -9,6 +9,9 @@ export default defineConfig({
"open-sse/mcp-server/__tests__/**/*.test.ts",
"open-sse/services/autoCombo/__tests__/**/*.test.ts",
"tests/unit/encryption.spec.ts",
"src/shared/components/**/*.test.tsx",
"src/shared/hooks/__tests__/**/*.test.tsx",
"src/app/(dashboard)/**/__tests__/**/*.test.tsx",
],
exclude: ["**/node_modules/**", "**/.git/**"],
coverage: {