From 0d3728efa4d378e5740b73e2f495a2c5a01cdf22 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 5 Mar 2026 14:38:03 -0300 Subject: [PATCH] feat: Introduce combo readiness checks and strategy recommendations, updating i18n messages and e2e tests. --- src/app/(dashboard)/dashboard/combos/page.tsx | 329 +++++++++++++++++- src/i18n/messages/ar.json | 63 +++- src/i18n/messages/bg.json | 63 +++- src/i18n/messages/da.json | 63 +++- src/i18n/messages/de.json | 63 +++- src/i18n/messages/en.json | 63 +++- src/i18n/messages/es.json | 63 +++- src/i18n/messages/fi.json | 63 +++- src/i18n/messages/fr.json | 63 +++- src/i18n/messages/he.json | 63 +++- src/i18n/messages/hu.json | 63 +++- src/i18n/messages/id.json | 63 +++- src/i18n/messages/in.json | 63 +++- src/i18n/messages/it.json | 63 +++- src/i18n/messages/ja.json | 63 +++- src/i18n/messages/ko.json | 63 +++- src/i18n/messages/ms.json | 63 +++- src/i18n/messages/nl.json | 63 +++- src/i18n/messages/no.json | 63 +++- src/i18n/messages/phi.json | 189 ++++++---- src/i18n/messages/pl.json | 63 +++- src/i18n/messages/pt-BR.json | 63 +++- src/i18n/messages/pt.json | 63 +++- src/i18n/messages/ro.json | 63 +++- src/i18n/messages/ru.json | 63 +++- src/i18n/messages/sk.json | 63 +++- src/i18n/messages/sv.json | 63 +++- src/i18n/messages/th.json | 63 +++- src/i18n/messages/uk-UA.json | 63 +++- src/i18n/messages/vi.json | 63 +++- src/i18n/messages/zh-CN.json | 63 +++- tests/e2e/combos-flow.spec.ts | 26 +- 32 files changed, 2273 insertions(+), 98 deletions(-) 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) => ( +
+ check +

{tip}

+
+ ))} +
+ + {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 */}