diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 921fbcb01f..9f021c6a04 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Card, Button, @@ -29,6 +29,110 @@ const STRATEGY_OPTIONS = [ { value: "cost-optimized", labelKey: "costOpt", descKey: "costOptimizedDesc", icon: "savings" }, ]; +const STRATEGY_GUIDANCE_FALLBACK = { + priority: { + when: "Use when you have one preferred model and only want fallback on failure.", + avoid: "Avoid when you need balanced load between models.", + example: "Example: Primary coding model with cheaper backup for outages.", + }, + weighted: { + when: "Use when you need controlled traffic split across models.", + avoid: "Avoid when weights are not maintained or you need strict fairness.", + example: "Example: 80% stable model and 20% canary model for safe rollout.", + }, + "round-robin": { + when: "Use when you need predictable, even request distribution.", + avoid: "Avoid when model latency/cost differs significantly.", + example: "Example: Same model across multiple accounts to spread throughput.", + }, + random: { + when: "Use when you want a simple spread with low configuration effort.", + avoid: "Avoid when requests must be distributed with strict guarantees.", + example: "Example: Prototyping with equivalent models and no traffic policy.", + }, + "least-used": { + when: "Use when you want adaptive balancing based on recent demand.", + avoid: "Avoid when your traffic is too low to benefit from usage balancing.", + example: "Example: Mixed workloads where one model tends to get overloaded.", + }, + "cost-optimized": { + when: "Use when minimizing cost is the top priority.", + avoid: "Avoid when pricing data is missing or outdated.", + example: "Example: Batch or background jobs where lower cost matters most.", + }, +}; + +const ADVANCED_FIELD_HELP_FALLBACK = { + maxRetries: "How many retries are attempted before failing the request.", + retryDelay: "Initial delay between retries. Higher values reduce burst pressure.", + timeout: "Maximum request time before aborting. Set higher for long generations.", + healthcheck: "Skips unhealthy models/providers from routing decisions when enabled.", + concurrencyPerModel: "Max simultaneous requests sent to each model in round-robin.", + queueTimeout: "How long a request can wait in queue before timeout in round-robin.", +}; + +const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide"; + +const COMBO_TEMPLATE_FALLBACK = { + title: "Quick templates", + description: "Apply a starting profile, then adjust models and config.", + apply: "Apply template", + highAvailabilityTitle: "High availability", + highAvailabilityDesc: "Priority routing with health checks and safe retries.", + costSaverTitle: "Cost saver", + costSaverDesc: "Cost-optimized routing for budget-first workloads.", + balancedTitle: "Balanced load", + balancedDesc: "Least-used routing to spread demand over time.", +}; + +const COMBO_TEMPLATES = [ + { + id: "high-availability", + icon: "shield", + titleKey: "templateHighAvailability", + descKey: "templateHighAvailabilityDesc", + fallbackTitle: COMBO_TEMPLATE_FALLBACK.highAvailabilityTitle, + fallbackDesc: COMBO_TEMPLATE_FALLBACK.highAvailabilityDesc, + strategy: "priority", + suggestedName: "high-availability", + config: { + maxRetries: 2, + retryDelayMs: 1500, + healthCheckEnabled: true, + }, + }, + { + id: "cost-saver", + icon: "savings", + titleKey: "templateCostSaver", + descKey: "templateCostSaverDesc", + fallbackTitle: COMBO_TEMPLATE_FALLBACK.costSaverTitle, + fallbackDesc: COMBO_TEMPLATE_FALLBACK.costSaverDesc, + strategy: "cost-optimized", + suggestedName: "cost-saver", + config: { + maxRetries: 1, + retryDelayMs: 500, + healthCheckEnabled: true, + }, + }, + { + id: "balanced", + icon: "balance", + titleKey: "templateBalanced", + descKey: "templateBalancedDesc", + fallbackTitle: COMBO_TEMPLATE_FALLBACK.balancedTitle, + fallbackDesc: COMBO_TEMPLATE_FALLBACK.balancedDesc, + strategy: "least-used", + suggestedName: "balanced-load", + config: { + maxRetries: 1, + retryDelayMs: 1000, + healthCheckEnabled: true, + }, + }, +]; + function getStrategyMeta(strategy) { return STRATEGY_OPTIONS.find((s) => s.value === strategy) || STRATEGY_OPTIONS[0]; } @@ -50,6 +154,18 @@ function getStrategyBadgeClass(strategy) { return "bg-blue-500/15 text-blue-600 dark:text-blue-400"; } +function getI18nOrFallback(t, key, fallback) { + if (typeof t.has === "function" && t.has(key)) return t(key); + return fallback; +} + +function getStrategyGuideText(t, strategy, field) { + const strategyFallback = + STRATEGY_GUIDANCE_FALLBACK[strategy] || STRATEGY_GUIDANCE_FALLBACK.priority; + const key = `strategyGuide.${strategy}.${field}`; + return getI18nOrFallback(t, key, strategyFallback[field]); +} + // ───────────────────────────────────────────── // Helper: normalize model entry (legacy string ↔ new object) // ───────────────────────────────────────────── @@ -81,6 +197,8 @@ export default function CombosPage() { const [proxyTargetCombo, setProxyTargetCombo] = useState(null); const [proxyConfig, setProxyConfig] = useState(null); const [providerNodes, setProviderNodes] = useState([]); + const [showUsageGuide, setShowUsageGuide] = useState(true); + const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState(""); useEffect(() => { fetchData(); @@ -90,6 +208,16 @@ export default function CombosPage() { .catch(() => {}); }, []); + useEffect(() => { + try { + if (globalThis.localStorage?.getItem(COMBO_USAGE_GUIDE_STORAGE_KEY) === "1") { + setShowUsageGuide(false); + } + } catch { + // Ignore storage access errors (privacy mode / restricted environments) + } + }, []); + const fetchData = async () => { try { const [combosRes, providersRes, metricsRes, nodesRes] = await Promise.all([ @@ -129,6 +257,7 @@ export default function CombosPage() { if (res.ok) { await fetchData(); setShowCreateModal(false); + setRecentlyCreatedCombo(data.name?.trim() || ""); notify.success(t("comboCreated")); } else { const err = await res.json(); @@ -228,6 +357,20 @@ export default function CombosPage() { } }; + const handleHideUsageGuideForever = () => { + setShowUsageGuide(false); + try { + globalThis.localStorage?.setItem(COMBO_USAGE_GUIDE_STORAGE_KEY, "1"); + } catch {} + }; + + const handleShowUsageGuide = () => { + setShowUsageGuide(true); + try { + globalThis.localStorage?.removeItem(COMBO_USAGE_GUIDE_STORAGE_KEY); + } catch {} + }; + if (loading) { return (
@@ -245,12 +388,69 @@ export default function CombosPage() {

{t("title")}

{t("description")}

- +
+ {!showUsageGuide && ( + + )} + +
- + {showUsageGuide && ( + setShowUsageGuide(false)} + onHideForever={handleHideUsageGuideForever} + /> + )} + + {recentlyCreatedCombo && ( + +
+
+

+ {getI18nOrFallback( + t, + "quickTestTitle", + `Combo "${recentlyCreatedCombo}" ready to validate` + )} +

+ + {recentlyCreatedCombo} + +

+ {getI18nOrFallback( + t, + "quickTestDescription", + "Run a test now to confirm fallback and latency behavior." + )} +

+
+
+ + +
+
+
+ )} {/* Combos List */} {combos.length === 0 ? ( @@ -332,21 +532,36 @@ export default function CombosPage() { ); } -function ComboUsageGuide() { +function ComboUsageGuide({ onHide, onHideForever }) { const t = useTranslations("combos"); const guideStrategies = ["priority", "cost-optimized", "least-used"]; return ( -
-
- - tips_and_updates - +
+
+
+ + tips_and_updates + +
+
+

{t("routingStrategy")}

+

{t("description")}

+
-
-

{t("routingStrategy")}

-

{t("description")}

+
+ +
@@ -375,6 +590,50 @@ function ComboUsageGuide() { ); } +function StrategyGuidanceCard({ strategy }) { + const t = useTranslations("combos"); + return ( +
+
+ {getI18nOrFallback(t, "strategyGuideTitle", "How to use this strategy")} +
+
+

+ + {getI18nOrFallback(t, "strategyGuideWhen", "When to use")}: + {" "} + {getStrategyGuideText(t, strategy, "when")} +

+

+ + {getI18nOrFallback(t, "strategyGuideAvoid", "Avoid when")}: + {" "} + {getStrategyGuideText(t, strategy, "avoid")} +

+

+ + {getI18nOrFallback(t, "strategyGuideExample", "Example")}: + {" "} + {getStrategyGuideText(t, strategy, "example")} +

+
+
+ ); +} + +function FieldLabelWithHelp({ label, help }) { + return ( +
+ + + + help + + +
+ ); +} + // ───────────────────────────────────────────── // Combo Card // ───────────────────────────────────────────── @@ -634,26 +893,66 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { const [showModelSelect, setShowModelSelect] = useState(false); const [saving, setSaving] = useState(false); const [nameError, setNameError] = useState(""); + const [pricingByProvider, setPricingByProvider] = useState({}); const [modelAliases, setModelAliases] = useState({}); const [providerNodes, setProviderNodes] = useState([]); const [showAdvanced, setShowAdvanced] = useState(false); const [config, setConfig] = useState(combo?.config || {}); // DnD state + const hasPricingForModel = useCallback( + (modelValue) => { + const parts = modelValue.split("/"); + if (parts.length !== 2) return false; + + const [providerIdentifier, modelId] = parts; + const matchedNode = providerNodes.find( + (node) => node.id === providerIdentifier || node.prefix === providerIdentifier + ); + + const providerCandidates = [providerIdentifier]; + if (matchedNode?.apiType) providerCandidates.push(matchedNode.apiType); + if (matchedNode?.name) providerCandidates.push(String(matchedNode.name).toLowerCase()); + + return providerCandidates.some((candidate) => !!pricingByProvider?.[candidate]?.[modelId]); + }, + [pricingByProvider, providerNodes] + ); + const [dragIndex, setDragIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); const weightTotal = models.reduce((sum, modelEntry) => sum + (modelEntry.weight || 0), 0); + const pricedModelCount = models.reduce( + (count, modelEntry) => count + (hasPricingForModel(modelEntry.model) ? 1 : 0), + 0 + ); + const pricingCoveragePercent = + models.length > 0 ? Math.round((pricedModelCount / models.length) * 100) : 0; const hasNoModels = models.length === 0; + const hasRoundRobinSingleModel = strategy === "round-robin" && models.length === 1; + const hasCostOptimizedWithoutPricing = + strategy === "cost-optimized" && models.length > 0 && pricedModelCount === 0; + const hasCostOptimizedPartialPricing = + strategy === "cost-optimized" && + models.length > 0 && + pricedModelCount > 0 && + pricedModelCount < models.length; const hasInvalidWeightedTotal = strategy === "weighted" && models.length > 0 && weightTotal !== 100; const saveBlocked = - !name.trim() || !!nameError || saving || hasNoModels || hasInvalidWeightedTotal; + !name.trim() || + !!nameError || + saving || + hasNoModels || + hasInvalidWeightedTotal || + hasCostOptimizedWithoutPricing; const fetchModalData = async () => { try { - const [aliasesRes, nodesRes] = await Promise.all([ + const [aliasesRes, nodesRes, pricingRes] = await Promise.all([ fetch("/api/models/alias"), fetch("/api/provider-nodes"), + fetch("/api/pricing"), ]); if (!aliasesRes.ok || !nodesRes.ok) { @@ -661,8 +960,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { `Failed to fetch data: aliases=${aliasesRes.status}, nodes=${nodesRes.status}` ); } + const pricingData = pricingRes.ok ? await pricingRes.json() : {}; const [aliasesData, nodesData] = await Promise.all([aliasesRes.json(), nodesRes.json()]); + setPricingByProvider( + pricingData && typeof pricingData === "object" && !Array.isArray(pricingData) + ? pricingData + : {} + ); setModelAliases(aliasesData.aliases || {}); setProviderNodes(nodesData.nodes || []); } catch (error) { @@ -726,6 +1031,12 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { ); }; + const applyTemplate = (template) => { + setStrategy(template.strategy); + setConfig((prev) => ({ ...prev, ...template.config })); + if (!name.trim()) setName(template.suggestedName); + }; + // Format model display name with readable provider name const formatModelDisplay = useCallback( (modelValue) => { @@ -799,7 +1110,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { const handleSave = async () => { if (!validateName(name)) return; - if (hasNoModels || hasInvalidWeightedTotal) return; + if (hasNoModels || hasInvalidWeightedTotal || hasCostOptimizedWithoutPricing) return; setSaving(true); const saveData: any = { @@ -842,6 +1153,48 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {

{t("nameHint")}

+ {!isEdit && ( +
+
+

+ {getI18nOrFallback(t, "templatesTitle", COMBO_TEMPLATE_FALLBACK.title)} +

+

+ {getI18nOrFallback( + t, + "templatesDescription", + COMBO_TEMPLATE_FALLBACK.description + )} +

+
+
+ {COMBO_TEMPLATES.map((template) => ( + + ))} +
+
+ )} + {/* Strategy Toggle */}
@@ -875,6 +1228,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {

{getStrategyDescription(t, strategy)}

+
+ +
{/* Models */} @@ -929,6 +1285,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { {formatModelDisplay(entry.model)}
+ {strategy === "cost-optimized" && ( + + {hasPricingForModel(entry.model) + ? getI18nOrFallback(t, "pricingAvailableShort", "priced") + : getI18nOrFallback(t, "pricingMissingShort", "no-price")} + + )} + {/* Weight input (weighted mode only) */} {strategy === "weighted" && (
@@ -986,6 +1361,38 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) { {/* Weight total indicator */} {strategy === "weighted" && models.length > 0 && } + {strategy === "cost-optimized" && models.length > 0 && ( +
+
+ + {getI18nOrFallback(t, "pricingCoverage", "Pricing coverage")} + + + {pricedModelCount}/{models.length} ({pricingCoveragePercent}%) + +
+
+
0 + ? "bg-amber-500" + : "bg-red-500" + }`} + style={{ width: `${pricingCoveragePercent}%` }} + /> +
+

+ {getI18nOrFallback( + t, + "pricingCoverageHint", + "Cost-optimized works best when all combo models have pricing." + )} +

+
+ )} + {hasNoModels && (
warning @@ -1002,6 +1409,46 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
)} + {hasRoundRobinSingleModel && ( +
+ info + + {getI18nOrFallback( + t, + "warningRoundRobinSingleModel", + "Round-robin is most useful with at least 2 models." + )} + +
+ )} + + {hasCostOptimizedPartialPricing && ( +
+ warning + + {typeof t.has === "function" && t.has("warningCostOptimizedPartialPricing") + ? t("warningCostOptimizedPartialPricing", { + priced: pricedModelCount, + total: models.length, + }) + : `Only ${pricedModelCount} of ${models.length} models have pricing. Routing may be partially cost-aware.`} + +
+ )} + + {hasCostOptimizedWithoutPricing && ( +
+ warning + + {getI18nOrFallback( + t, + "warningCostOptimizedNoPricing", + "No pricing data found for this combo. Cost-optimized may route unexpectedly." + )} + +
+ )} + {/* Add Model button */}