mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
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:
28
README.md
28
README.md
@@ -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.
|
||||
|
||||
@@ -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**:
|
||||
|
||||
|
||||
@@ -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}` };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
9
open-sse/services/autoCombo/providerRegistryAccessor.ts
Normal file
9
open-sse/services/autoCombo/providerRegistryAccessor.ts
Normal 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;
|
||||
}
|
||||
136
open-sse/services/autoCombo/virtualFactory.ts
Normal file
136
open-sse/services/autoCombo/virtualFactory.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
90
src/app/api/analytics/auto-routing/route.ts
Normal file
90
src/app/api/analytics/auto-routing/route.ts
Normal 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
96
src/shared/components/AutoRoutingBanner.test.tsx
Normal file
96
src/shared/components/AutoRoutingBanner.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
79
src/shared/components/AutoRoutingBanner.tsx
Normal file
79
src/shared/components/AutoRoutingBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user