From 76de4aba8b3e67590b47e6ca28587b1947581de0 Mon Sep 17 00:00:00 2001 From: Automation Date: Sun, 24 May 2026 20:38:58 +0200 Subject: [PATCH] feat(playground): combo routing visual simulator - Interactive combo routing simulation with visual cascade display - Simulation API: tests routing paths with cost/latency/status estimates - Visual UI shows each target hop with status (available/error/unknown) - Strategy-specific indicators (fallback arrows for priority, weights for weighted) - Warnings and errors section for misconfigured combos --- .../playground/ComboPlaygroundClient.tsx | 315 ++++++++++++++++++ .../dashboard/combos/playground/page.tsx | 11 + .../api/playground/simulate-route/route.ts | 184 ++++++++++ 3 files changed, 510 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx create mode 100644 src/app/(dashboard)/dashboard/combos/playground/page.tsx create mode 100644 src/app/api/playground/simulate-route/route.ts diff --git a/src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx b/src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx new file mode 100644 index 000000000000..12639d7db7ea --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/playground/ComboPlaygroundClient.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import Button from "@/shared/components/Button"; +import Card from "@/shared/components/Card"; +import Badge from "@/shared/components/Badge"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface TargetSimulation { + provider: string; + model: string; + strategy: string; + rank: number; + estimatedCost: number; + estimatedLatencyMs: number; + status: "available" | "no_quota" | "degraded" | "error" | "unknown"; + maxTokens?: number; + contextWindow?: number; +} + +interface SimulateResponse { + comboId?: string; + comboName: string; + strategy: string; + targets: TargetSimulation[]; + totalEstimatedCost: number; + totalEstimatedLatencyMs: number; + warnings: string[]; + errors: string[]; +} + +interface Combo { + id: string; + name: string; + strategy: string; + targets: string; + isActive: boolean; +} + +// ── Status helpers ─────────────────────────────────────────────────────────── + +function StatusTag({ status }: { status: TargetSimulation["status"] }) { + const map: Record = { + available: { label: "Available", variant: "success" }, + no_quota: { label: "No Quota", variant: "error" }, + degraded: { label: "Degraded", variant: "warning" }, + error: { label: "Error", variant: "error" }, + unknown: { label: "Unknown", variant: "info" }, + }; + return {map[status].label}; +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export default function ComboPlaygroundClient() { + const [combos, setCombos] = useState([]); + const [selectedComboId, setSelectedComboId] = useState(""); + const [promptTokens, setPromptTokens] = useState(500); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + // Fetch combos on mount + useEffect(() => { + fetch("/api/combos") + .then((r) => r.json()) + .then((data) => { + const list: Combo[] = Array.isArray(data) ? data : data?.combos ?? data?.data ?? []; + setCombos(list); + if (list.length > 0) setSelectedComboId(list[0].id); + }) + .catch(() => {}); + }, []); + + const simulate = useCallback(async () => { + if (!selectedComboId) return; + setLoading(true); + setResult(null); + + try { + const res = await fetch("/api/playground/simulate-route", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + comboId: selectedComboId, + promptTokens, + }), + }); + const data = await res.json(); + if (res.ok) setResult(data); + else setResult(data); + } catch { + setResult({ + comboName: "Error", + strategy: "-", + targets: [], + totalEstimatedCost: 0, + totalEstimatedLatencyMs: 0, + warnings: [], + errors: ["Network error during simulation"], + }); + } finally { + setLoading(false); + } + }, [selectedComboId, promptTokens]); + + return ( +
+
+
+

Combo Playground

+

+ Simulate how requests will be routed through your combos +

+
+
+ + {/* Configuration Panel */} + +
+

Configuration

+ +
+ {/* Combo Selector */} +
+ + +
+ + {/* Prompt Tokens */} +
+ + setPromptTokens(Number(e.target.value))} + className="w-full" + /> +
+ 100 + 100K +
+
+
+ + +
+
+ + {/* Results */} + {result && ( + <> + {/* Combo Overview */} + +
+
+

Routing Path

+
+ + Strategy: {result.strategy} + + + Est. Cost: ${result.totalEstimatedCost.toFixed(6)} + + + Est. Latency: {result.totalEstimatedLatencyMs.toFixed(0)}ms + +
+
+ + {/* Visual Cascade */} +
+ {result.targets.map((t, i) => ( +
+ {/* Arrow between targets */} + {i > 0 && ( +
+
+ + + + {result.strategy === "priority" && ( + fallback + )} + {result.strategy === "weighted" && ( + weight {t.rank} + )} +
+
+ )} + + {/* Target Card */} +
+
+
+
+ {t.rank} +
+
+
{t.provider}
+
{t.model}
+
+
+
+ + + ${t.estimatedCost.toFixed(6)} + + + {t.estimatedLatencyMs}ms + + {t.contextWindow && ( + + {(t.contextWindow / 1000).toFixed(0)}K ctx + + )} +
+
+
+
+ ))} +
+
+
+ + {/* Warnings & Errors */} + {result.warnings.length > 0 && ( + +
+

+ Warnings ({result.warnings.length}) +

+ {result.warnings.map((w, i) => ( +

+ ⚠️ + {w} +

+ ))} +
+
+ )} + + {result.errors.length > 0 && ( + +
+

+ Errors ({result.errors.length}) +

+ {result.errors.map((e, i) => ( +

+ 🚫 + {e} +

+ ))} +
+
+ )} + + )} + + {/* Empty State */} + {!result && combos.length > 0 && ( + +
+

+ Select a combo and click Simulate Route to see the routing path. +

+
+
+ )} + + {combos.length === 0 && ( + +
+

+ No combos configured yet.{' '} + + Create one first + + . +

+
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/combos/playground/page.tsx b/src/app/(dashboard)/dashboard/combos/playground/page.tsx new file mode 100644 index 000000000000..373aa4f5339a --- /dev/null +++ b/src/app/(dashboard)/dashboard/combos/playground/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from "next"; +import ComboPlaygroundClient from "./ComboPlaygroundClient"; + +export const metadata: Metadata = { + title: "OmniRoute — Combo Playground", + description: "Simulate combo routing paths visually", +}; + +export default function ComboPlaygroundPage() { + return ; +} diff --git a/src/app/api/playground/simulate-route/route.ts b/src/app/api/playground/simulate-route/route.ts new file mode 100644 index 000000000000..04c2f64e7fdd --- /dev/null +++ b/src/app/api/playground/simulate-route/route.ts @@ -0,0 +1,184 @@ +/** + * POST /api/playground/simulate-route + * + * Simulates combo routing without executing the actual request. + * Returns which targets would be tried, in which order, and with what expected outcome. + */ + +import { NextResponse } from "next/server"; +import { getAllCombos } from "@/lib/db/combos"; +import { + getProviderConnections, +} from "@/lib/db/providers"; + +interface SimulateRequest { + /** Combo ID to simulate */ + comboId?: string; + /** Combo config inline (mutually exclusive with comboId) */ + combo?: { + name: string; + strategy: string; + targets: Array<{ provider: string; model: string; weight?: number }>; + }; + /** Estimated prompt tokens */ + promptTokens?: number; + /** Task type hint */ + taskType?: string; +} + +interface TargetSimulation { + provider: string; + model: string; + strategy: string; + rank: number; + estimatedCost: number; + estimatedLatencyMs: number; + status: "available" | "no_quota" | "degraded" | "error" | "unknown"; + maxTokens?: number; + contextWindow?: number; +} + +interface SimulateResponse { + comboId?: string; + comboName: string; + strategy: string; + targets: TargetSimulation[]; + totalEstimatedCost: number; + totalEstimatedLatencyMs: number; + warnings: string[]; + errors: string[]; +} + +function estimateCost(model: string, promptTokens: number): number { + // Rough cost estimates per model family + const modelLower = model.toLowerCase(); + const rate = modelLower.includes("sonnet") ? 3 : + modelLower.includes("haiku") ? 0.25 : + modelLower.includes("opus") ? 15 : + modelLower.includes("gpt-4") ? 10 : + modelLower.includes("gpt-3.5") ? 0.5 : + modelLower.includes("gemini") ? 0.15 : + modelLower.includes("claude") ? 3 : + modelLower.includes("deepseek") ? 0.5 : + modelLower.includes("mistral") ? 0.6 : + modelLower.includes("llama") ? 0.3 : + modelLower.includes("qwen") ? 0.4 : + 1; + return (rate * promptTokens) / 1_000_000; +} + +function estimateLatency(model: string): number { + const modelLower = model.toLowerCase(); + if (modelLower.includes("haiku")) return 800; + if (modelLower.includes("sonnet")) return 1500; + if (modelLower.includes("opus")) return 4000; + if (modelLower.includes("gpt-4")) return 2000; + if (modelLower.includes("gpt-3.5")) return 500; + if (modelLower.includes("gemini")) return 1200; + if (modelLower.includes("deepseek")) return 3000; + if (modelLower.includes("mistral")) return 1000; + if (modelLower.includes("llama")) return 2500; + if (modelLower.includes("qwen")) return 1800; + if (modelLower.includes("claude")) return 1500; + return 2000; +} + +function estimateContextWindow(model: string): number { + const modelLower = model.toLowerCase(); + if (modelLower.includes("128k") || modelLower.includes("128")) return 128000; + if (modelLower.includes("200k") || modelLower.includes("200")) return 200000; + if (modelLower.includes("1m") || modelLower.includes("1m")) return 1000000; + if (modelLower.includes("2m") || modelLower.includes("2m")) return 2000000; + return 128000; +} + +export async function POST(request: Request) { + try { + const body: SimulateRequest = await request.json(); + const warnings: string[] = []; + const errors: string[] = []; + let comboInfo: { name: string; strategy: string; targets: Array<{ provider: string; model: string; weight?: number }> } | null = null; + + // Resolve combo + if (body.comboId) { + const combos = (await getAllCombos()) as any[]; + const combo = combos.find((c) => c.id === body.comboId || c.name === body.comboId); + if (!combo) { + errors.push(`Combo "${body.comboId}" not found.`); + return NextResponse.json({ error: "Combo not found" }, { status: 404 }); + } + const targets = typeof combo.targets === "string" ? JSON.parse(combo.targets) : combo.targets; + comboInfo = { name: combo.name, strategy: combo.strategy, targets }; + } else if (body.combo) { + comboInfo = body.combo; + } else { + errors.push("Either comboId or combo config is required."); + return NextResponse.json({ error: "Missing combo configuration" }, { status: 400 }); + } + + const promptTokens = body.promptTokens || 500; + const targets: TargetSimulation[] = []; + let totalCost = 0; + let totalLatency = 0; + + // Get provider connections for status + const connections = (await getProviderConnections({})) as any[]; + + for (let i = 0; i < comboInfo.targets.length; i++) { + const t = comboInfo.targets[i]; + const conn = connections.find( + (c: any) => c.id === t.provider || c.name === t.provider || c.displayName === t.provider, + ); + const cost = estimateCost(t.model, promptTokens); + const latency = estimateLatency(t.model); + totalCost += cost; + totalLatency += latency; + + // Determine provider status + let status: TargetSimulation["status"] = "available"; + if (!conn) { + status = "unknown"; + warnings.push(`Provider "${t.provider}" not configured — will likely fail.`); + } else if (!conn.isActive) { + status = "error"; + warnings.push(`Provider "${t.provider}" is inactive.`); + } + + targets.push({ + provider: t.provider, + model: t.model, + strategy: comboInfo.strategy, + rank: i + 1, + estimatedCost: cost, + estimatedLatencyMs: latency, + status, + contextWindow: estimateContextWindow(t.model), + }); + } + + // Add strategy-specific warnings + if (comboInfo.strategy === "priority" && targets.length > 1) { + warnings.push("Priority strategy: targets after rank 1 only used if earlier targets fail."); + } + + // Build response + const response: SimulateResponse = { + comboId: body.comboId, + comboName: comboInfo.name, + strategy: comboInfo.strategy, + targets, + totalEstimatedCost: totalCost, + totalEstimatedLatencyMs: totalLatency, + warnings, + errors, + }; + + return NextResponse.json(response, { status: 200 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + return NextResponse.json( + { error: `Simulation error: ${message}` }, + { status: 500 }, + ); + } +}