(sortMethod);
+ const resetSortGenerationRef = useRef(0);
+ useEffect(() => {
+ modelsRef.current = models;
+ }, [models]);
+ useEffect(() => {
+ sortMethodRef.current = sortMethod;
+ }, [sortMethod]);
const [showStrategyNudge, setShowStrategyNudge] = useState(false);
const strategyChangeMountedRef = useRef(false);
// Agent features (#399 / #401 / #454)
@@ -2060,9 +2089,34 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
Object.fromEntries(Object.entries(nextDefaults).filter(([key]) => key !== "strategy"))
);
+ // Validate persisted enum; tolerate hand-edited DB values.
+ const loadedMethod = normalizeSortMethod(nextCombo?.config?.modelSort?.method);
+ // Generation guard so a stale score fetch can't clobber the next combo.
+ const myGen = ++resetSortGenerationRef.current;
+ setSortMethod(loadedMethod);
+ sortMethodRef.current = loadedMethod;
setName(nextCombo?.name || "");
setDescription(nextCombo?.description || "");
- setModels((nextCombo?.models || []).map((m) => normalizeModelEntry(m)));
+ // Branch so only one setModels runs (no raw-then-sorted double set).
+ // Score branch is async; guard with generation + cancelled from the caller's effect.
+ if (loadedMethod === "manual") {
+ setModels((nextCombo?.models || []).map((m) => normalizeModelEntry(m)));
+ } else if (loadedMethod === "score") {
+ const base = (nextCombo?.models || []).map((mm) => normalizeModelEntry(mm)) as ComboStep[];
+ fetchProviderRankings()
+ .then((rk) => sortComboStepsByScore(base, rk))
+ .then((sorted) => {
+ if (resetSortGenerationRef.current !== myGen) return;
+ setModels(sorted as typeof base);
+ })
+ .catch(() => {
+ if (resetSortGenerationRef.current !== myGen) return;
+ setModels(base);
+ });
+ } else {
+ const base = (nextCombo?.models || []).map((mm) => normalizeModelEntry(mm)) as ComboStep[];
+ setModels(sortComboStepsSync(base, loadedMethod));
+ }
setStrategy(nextCombo?.strategy || comboDefaults?.strategy || "priority");
setConfig(nextConfig);
setShowAdvanced(isExpertMode);
@@ -2597,7 +2651,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
setBuilderError("");
};
- const handleAddModel = (model) => {
+ const handleAddModel = async (model) => {
+ // Use refs to avoid stale closure when awaiting a score fetch.
+ const currentModels = (modelsRef.current ?? models) as typeof models;
+ const currentMethod = sortMethodRef.current;
const qualifiedModel = typeof model?.value === "string" ? model.value : "";
const parsedModel = parseQualifiedModel(qualifiedModel);
const resolvedProviderId =
@@ -2611,7 +2668,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
...(resolvedProviderId ? { providerId: resolvedProviderId } : {}),
weight: 0,
};
- if (hasExactModelStepDuplicate(models, nextEntry)) {
+ if (hasExactModelStepDuplicate(currentModels, nextEntry)) {
setBuilderError(
getI18nOrFallback(
t,
@@ -2621,7 +2678,21 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
);
return;
}
- setModels([...models, nextEntry]);
+ const added = [...currentModels, nextEntry];
+ if (currentMethod === "manual") {
+ setModels(added);
+ } else if (currentMethod === "score") {
+ try {
+ const rankings = await fetchProviderRankings();
+ // Single-user modal; rapid double-add while fetch is in flight is low-probability.
+ const sorted = await sortComboStepsByScore(added, rankings);
+ setModels(sorted);
+ } catch {
+ setModels(added);
+ }
+ } else {
+ setModels(sortComboStepsSync(added, currentMethod as "provider" | "name"));
+ }
setBuilderError("");
};
@@ -2649,10 +2720,27 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
// would each close over the same stale `models` snapshot and keep only the
// last entry. Extracted so tests exercise this real implementation instead
// of a hand-maintained mirror (#8526).
- const handleAddModels = (selected) => {
- const { next, addedAny } = computeBatchAddModelSteps(models, selected, builderProviders);
+ const handleAddModels = async (selected) => {
+ // Same ref discipline as handleAddModel — don't rely on closed-over render snapshot.
+ const currentModels = (modelsRef.current ?? models) as typeof models;
+ const currentMethod = sortMethodRef.current;
+ const { next, addedAny } = computeBatchAddModelSteps(currentModels, selected, builderProviders);
if (!addedAny) return;
- setModels(next);
+ if (currentMethod === "manual") {
+ setModels(next);
+ } else if (currentMethod === "score") {
+ try {
+ const rankings = await fetchProviderRankings();
+ // Functional note: `next` is the post-batch snapshot. Concurrent single-add
+ // racing this batch is low-probability single-user; last write wins.
+ const sorted = await sortComboStepsByScore(next, rankings);
+ setModels(sorted);
+ } catch {
+ setModels(next);
+ }
+ } else {
+ setModels(sortComboStepsSync(next, currentMethod as "provider" | "name"));
+ }
setBuilderError("");
};
@@ -2785,6 +2873,30 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
setModels(newModels);
};
+ const handleSortChange = async (next: SortMethod) => {
+ if (!isValidSortMethod(next)) return;
+ setSortMethod(next);
+ sortMethodRef.current = next;
+ setConfig((prev) => ({ ...prev, modelSort: { method: next } }));
+ if (next === "manual") return;
+ if (next === "score") {
+ try {
+ const rankings = await fetchProviderRankings();
+ // Capture snapshot; if a concurrent add lands while rankings fetch
+ // is in flight, modelsRef has the freshest value — prefer it at
+ // sort time. Single-user UI, low-probability race; fallback keeps
+ // previous models if the rankings fetch fails (mirrors load path).
+ const snapshot = (modelsRef.current ?? models) as ComboStep[];
+ const sorted = await sortComboStepsByScore(snapshot, rankings);
+ setModels(sorted as typeof models);
+ } catch {
+ // Keep previous models; same silent-fallback precedent as load path.
+ }
+ return;
+ }
+ setModels((prev) => sortComboStepsSync(prev as ComboStep[], next) as typeof prev);
+ };
+
// Drag and Drop handlers
const handleDragStart = (e, index) => {
setDragIndex(index);
@@ -3484,6 +3596,10 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
)}
+
+
+
+
{models.length === 0 ? (
diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json
index aac36f9279..fb967dadef 100644
--- a/src/i18n/messages/en.json
+++ b/src/i18n/messages/en.json
@@ -12918,6 +12918,18 @@
}
}
},
+ "combo": {
+ "sort": {
+ "label": "Sort by",
+ "method": {
+ "manual": "Manual",
+ "provider": "Provider",
+ "score": "Score (free models)",
+ "name": "Name"
+ },
+ "scoreHint": "Score ranking applies to free providers only; others stay in place."
+ }
+ },
"comboControl": {
"title": "Combo Control Center",
"unavailable": "Combo Control Center unavailable",
diff --git a/src/lib/combos/comboSort.ts b/src/lib/combos/comboSort.ts
new file mode 100644
index 0000000000..977c45b93c
--- /dev/null
+++ b/src/lib/combos/comboSort.ts
@@ -0,0 +1,118 @@
+// src/lib/combos/comboSort.ts
+import { OAUTH_PROVIDERS, NOAUTH_PROVIDERS, APIKEY_PROVIDERS } from "@/shared/constants/providers";
+import type { ComboStep } from "@/lib/combos/steps";
+
+export type { ComboStep };
+
+export type SortMethod = "manual" | "provider" | "score" | "name";
+
+export const SORT_METHODS: readonly SortMethod[] = ["manual", "provider", "score", "name"] as const;
+const VALID_SORT_METHODS = new Set(SORT_METHODS as readonly string[]);
+
+export function normalizeSortMethod(raw: unknown): SortMethod {
+ return VALID_SORT_METHODS.has(raw as string) ? (raw as SortMethod) : "manual";
+}
+
+export function isValidSortMethod(raw: unknown): raw is SortMethod {
+ return VALID_SORT_METHODS.has(raw as string);
+}
+
+// Mirrors CANONICAL_PROVIDER_ORDER from src/app/api/v1/models/catalogOrder.ts — keep in sync.
+// Both are derived from OAUTH+NOAUTH+APIKEY keys; drift would silently diverge catalog vs combo order.
+export const PROVIDER_ORDER: readonly string[] = [
+ ...Object.keys(OAUTH_PROVIDERS),
+ ...Object.keys(NOAUTH_PROVIDERS),
+ ...Object.keys(APIKEY_PROVIDERS),
+];
+
+const REFERENCE_SENTINEL = " combo-ref"; // sorts after any real provider id
+
+function providerKey(step: ComboStep): string {
+ if (step.kind === "model" || step.kind === "provider-wildcard") {
+ return step.providerId ?? REFERENCE_SENTINEL;
+ }
+ return REFERENCE_SENTINEL; // combo-ref: no providerId
+}
+
+function nameKey(step: ComboStep): string {
+ if (step.kind === "model") return step.model;
+ if (step.kind === "provider-wildcard") return `${step.providerId}/${step.modelPattern}`;
+ return step.comboName; // combo-ref
+}
+
+/** Stable index for provider ordering; unknown providers go after the known list. */
+function providerRank(providerId: string): number {
+ const idx = PROVIDER_ORDER.indexOf(providerId);
+ return idx === -1 ? PROVIDER_ORDER.length : idx;
+}
+
+/** Synchronous sorts: manual (noop), provider, name. Stable. */
+export function sortComboStepsSync(
+ steps: ComboStep[],
+ method: "manual" | "provider" | "name"
+): ComboStep[] {
+ if (method === "manual") return steps;
+ const indexed = steps.map((step, i) => ({ step, i }));
+ indexed.sort((a, b) => {
+ if (method === "provider") {
+ const ra = providerRank(providerKey(a.step));
+ const rb = providerRank(providerKey(b.step));
+ if (ra !== rb) return ra - rb;
+ } else {
+ const na = nameKey(a.step);
+ const nb = nameKey(b.step);
+ if (na !== nb) return na < nb ? -1 : 1;
+ }
+ return a.i - b.i; // stable tiebreak preserves original order
+ });
+ return indexed.map((x) => x.step);
+}
+
+export type Rankings = Map | Record;
+
+function toMap(rankings: Rankings): Map {
+ return rankings instanceof Map ? rankings : new Map(Object.entries(rankings));
+}
+
+/** Steps with a ranking sort descending by score; steps without a score stay
+ * stable at the end (including combo-ref, which has no providerId). */
+export async function sortComboStepsByScore(
+ steps: ComboStep[],
+ rankings: Rankings
+): Promise {
+ const map = toMap(rankings);
+ const indexed = steps.map((step, i) => {
+ const pid =
+ step.kind === "model" || step.kind === "provider-wildcard" ? step.providerId : undefined;
+ const score = pid ? map.get(pid) : undefined;
+ return { step, i, score: score ?? -1 };
+ });
+ indexed.sort((a, b) => {
+ if (a.score !== b.score) return b.score - a.score; // desc, -1 (unscored) last
+ return a.i - b.i;
+ });
+ return indexed.map((x) => x.step);
+}
+
+/** Client-side rankings source for the dashboard (provider-level averageScore). */
+export async function fetchProviderRankings(): Promise