diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx
index 9f021c6a04..9f5c1a292c 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 } from "react";
+import { useState, useEffect, useCallback, useRef } from "react";
import {
Card,
Button,
@@ -71,6 +71,63 @@ const ADVANCED_FIELD_HELP_FALLBACK = {
queueTimeout: "How long a request can wait in queue before timeout in round-robin.",
};
+const STRATEGY_RECOMMENDATIONS_FALLBACK = {
+ priority: {
+ title: "Fail-safe baseline",
+ description: "Use one primary model and keep fallback chain short and reliable.",
+ tips: [
+ "Put your most reliable model first.",
+ "Keep 1-2 backup models with similar quality.",
+ "Use safe retries to absorb transient provider failures.",
+ ],
+ },
+ weighted: {
+ title: "Controlled traffic split",
+ description: "Great for canary rollouts and gradual migration between models.",
+ tips: [
+ "Start with conservative split like 90/10.",
+ "Keep the total at 100% and auto-balance after changes.",
+ "Monitor success and latency before increasing canary weight.",
+ ],
+ },
+ "round-robin": {
+ title: "Predictable load sharing",
+ description: "Best when models are equivalent and you need smooth distribution.",
+ tips: [
+ "Use at least 2 models.",
+ "Set concurrency limits to avoid burst overload.",
+ "Use queue timeout to fail fast under saturation.",
+ ],
+ },
+ random: {
+ title: "Quick spread with low setup",
+ description: "Use when you need simple distribution without strict guarantees.",
+ tips: [
+ "Use models with similar latency profiles.",
+ "Keep retries enabled to absorb random misses.",
+ "Prefer this for experimentation, not strict SLAs.",
+ ],
+ },
+ "least-used": {
+ title: "Adaptive balancing",
+ description: "Routes to less-used models to reduce hotspots over time.",
+ tips: [
+ "Works better under continuous traffic.",
+ "Combine with health checks for safer balancing.",
+ "Track per-model usage to validate distribution gains.",
+ ],
+ },
+ "cost-optimized": {
+ title: "Budget-first routing",
+ description: "Routes to lower-cost models when pricing metadata is available.",
+ tips: [
+ "Ensure pricing coverage for all selected models.",
+ "Keep a quality fallback for hard prompts.",
+ "Use for batch/background jobs where cost is the main KPI.",
+ ],
+ },
+};
+
const COMBO_USAGE_GUIDE_STORAGE_KEY = "omniroute:combos:hide-usage-guide";
const COMBO_TEMPLATE_FALLBACK = {
@@ -166,6 +223,23 @@ function getStrategyGuideText(t, strategy, field) {
return getI18nOrFallback(t, key, strategyFallback[field]);
}
+function getStrategyRecommendationText(t, strategy, field) {
+ const strategyFallback =
+ STRATEGY_RECOMMENDATIONS_FALLBACK[strategy] || STRATEGY_RECOMMENDATIONS_FALLBACK.priority;
+
+ if (field === "tips") {
+ return strategyFallback.tips.map((tip, index) =>
+ getI18nOrFallback(t, `strategyRecommendations.${strategy}.tip${index + 1}`, tip)
+ );
+ }
+
+ return getI18nOrFallback(
+ t,
+ `strategyRecommendations.${strategy}.${field}`,
+ strategyFallback[field]
+ );
+}
+
// ─────────────────────────────────────────────
// Helper: normalize model entry (legacy string ↔ new object)
// ─────────────────────────────────────────────
@@ -621,6 +695,58 @@ function StrategyGuidanceCard({ strategy }) {
);
}
+function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
+ const t = useTranslations("combos");
+ const strategyLabel = getStrategyLabel(t, strategy);
+ const title = getStrategyRecommendationText(t, strategy, "title");
+ const description = getStrategyRecommendationText(t, strategy, "description");
+ const tips = getStrategyRecommendationText(t, strategy, "tips");
+
+ return (
+
+
+
+
+ {getI18nOrFallback(t, "recommendationsLabel", "Recommended setup")}
+
+
+ {title} · {strategyLabel}
+
+
{description}
+
+
+
+
+
+ {tips.map((tip, index) => (
+
+ ))}
+
+
+ {showNudge && (
+
+ {getI18nOrFallback(
+ t,
+ "recommendationsUpdated",
+ "Recommendations updated for {strategy}."
+ ).replace("{strategy}", strategyLabel)}
+
+ )}
+
+ );
+}
+
function FieldLabelWithHelp({ label, help }) {
return (
@@ -634,6 +760,88 @@ function FieldLabelWithHelp({ label, help }) {
);
}
+function ComboReadinessPanel({ checks, blockers }) {
+ const t = useTranslations("combos");
+ const hasBlockers = blockers.length > 0;
+
+ return (
+
+
+
+ {hasBlockers ? "rule" : "check_circle"}
+
+
+ {getI18nOrFallback(t, "readinessTitle", "Ready to save?")}
+
+
+
+
+ {getI18nOrFallback(
+ t,
+ "readinessDescription",
+ "Review the checklist before creating or updating this combo."
+ )}
+
+
+
+ {checks.map((check) => (
+
+
+ {check.ok ? "task_alt" : "pending"}
+
+ {check.label}
+
+ ))}
+
+
+ {hasBlockers && (
+
+
+ {getI18nOrFallback(
+ t,
+ "saveBlockedTitle",
+ "Save is blocked until the following items are fixed:"
+ )}
+
+
+ {blockers.map((blocker, index) => (
+
+ • {blocker}
+
+ ))}
+
+
+ )}
+
+ );
+}
+
// ─────────────────────────────────────────────
// Combo Card
// ─────────────────────────────────────────────
@@ -885,6 +1093,7 @@ function TestResultsView({ results }) {
function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const t = useTranslations("combos");
const tc = useTranslations("common");
+ const notify = useNotificationStore();
const [name, setName] = useState(combo?.name || "");
const [models, setModels] = useState(() => {
return (combo?.models || []).map((m) => normalizeModelEntry(m));
@@ -898,6 +1107,8 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
const [providerNodes, setProviderNodes] = useState([]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [config, setConfig] = useState(combo?.config || {});
+ const [showStrategyNudge, setShowStrategyNudge] = useState(false);
+ const strategyChangeMountedRef = useRef(false);
// DnD state
const hasPricingForModel = useCallback(
@@ -946,6 +1157,59 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
hasNoModels ||
hasInvalidWeightedTotal ||
hasCostOptimizedWithoutPricing;
+ const readinessChecks = [
+ {
+ id: "name",
+ ok: !!name.trim() && !nameError,
+ label: getI18nOrFallback(t, "readinessCheckName", "Combo name is valid"),
+ },
+ {
+ id: "models",
+ ok: !hasNoModels,
+ label: getI18nOrFallback(t, "readinessCheckModels", "At least one model is selected"),
+ },
+ {
+ id: "weights",
+ ok: strategy === "weighted" ? !hasInvalidWeightedTotal : true,
+ label:
+ strategy === "weighted"
+ ? getI18nOrFallback(t, "readinessCheckWeights", "Weighted total is 100%")
+ : getI18nOrFallback(t, "readinessCheckWeightsOptional", "Weight rule not required"),
+ },
+ {
+ id: "pricing",
+ ok: strategy === "cost-optimized" ? !hasCostOptimizedWithoutPricing : true,
+ label:
+ strategy === "cost-optimized"
+ ? getI18nOrFallback(t, "readinessCheckPricing", "Pricing data is available")
+ : getI18nOrFallback(t, "readinessCheckPricingOptional", "Pricing rule not required"),
+ },
+ ];
+ const saveBlockers = [];
+ if (!name.trim()) {
+ saveBlockers.push(getI18nOrFallback(t, "saveBlockName", "Define a combo name."));
+ } else if (nameError) {
+ saveBlockers.push(nameError);
+ }
+ if (hasNoModels) {
+ saveBlockers.push(getI18nOrFallback(t, "saveBlockModels", "Add at least one model."));
+ }
+ if (hasInvalidWeightedTotal) {
+ saveBlockers.push(
+ typeof t.has === "function" && t.has("saveBlockWeighted")
+ ? t("saveBlockWeighted", { total: weightTotal })
+ : `Set weights to 100% (current: ${weightTotal}%).`
+ );
+ }
+ if (hasCostOptimizedWithoutPricing) {
+ saveBlockers.push(
+ getI18nOrFallback(
+ t,
+ "saveBlockPricing",
+ "Add pricing for at least one model or choose a different strategy."
+ )
+ );
+ }
const fetchModalData = async () => {
try {
@@ -979,6 +1243,17 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
if (isOpen) fetchModalData();
}, [isOpen]);
+ useEffect(() => {
+ if (!strategyChangeMountedRef.current) {
+ strategyChangeMountedRef.current = true;
+ return;
+ }
+
+ setShowStrategyNudge(true);
+ const timeoutId = setTimeout(() => setShowStrategyNudge(false), 2600);
+ return () => clearTimeout(timeoutId);
+ }, [strategy]);
+
const validateName = (value) => {
if (!value.trim()) {
setNameError(t("nameRequired"));
@@ -1031,6 +1306,46 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
);
};
+ const applyStrategyRecommendations = () => {
+ const strategyDefaults = {
+ priority: { maxRetries: 2, retryDelayMs: 1500, healthCheckEnabled: true },
+ weighted: { maxRetries: 1, retryDelayMs: 1000, healthCheckEnabled: true },
+ "round-robin": {
+ maxRetries: 1,
+ retryDelayMs: 750,
+ healthCheckEnabled: true,
+ concurrencyPerModel: 3,
+ queueTimeoutMs: 30000,
+ },
+ random: { maxRetries: 1, retryDelayMs: 1000, healthCheckEnabled: true },
+ "least-used": { maxRetries: 1, retryDelayMs: 1000, healthCheckEnabled: true },
+ "cost-optimized": { maxRetries: 1, retryDelayMs: 500, healthCheckEnabled: true },
+ };
+
+ const defaults = strategyDefaults[strategy] || strategyDefaults.priority;
+ setConfig((prev) => {
+ const next = { ...prev };
+ for (const [key, value] of Object.entries(defaults)) {
+ if (next[key] === undefined || next[key] === null || next[key] === "") {
+ next[key] = value;
+ }
+ }
+ return next;
+ });
+
+ if (strategy === "weighted" && models.length > 1) {
+ handleAutoBalance();
+ }
+
+ if (strategy === "round-robin") {
+ setShowAdvanced(true);
+ }
+
+ notify.success(
+ getI18nOrFallback(t, "recommendationsApplied", "Recommendations applied to this combo.")
+ );
+ };
+
const applyTemplate = (template) => {
setStrategy(template.strategy);
setConfig((prev) => ({ ...prev, ...template.config }));
@@ -1210,6 +1525,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
{/* Models */}
@@ -1449,6 +1772,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
)}
+
+
+
+
{/* Add Model button */}