feat(combo): choose sort method for combo models (manual/provider/score/name) (#11812)

Lets the combo dashboard builder order models manually/by-provider/by-score/by-name — the choice is stored in config.modelSort and re-applied on load and after adding models. Score-based ordering fetches provider rankings from the existing /api/free-provider-rankings endpoint; the field is inert on execution (client-side hint only). 9/9 focused tests passing (schema, sort logic, and rendered component). Thanks!
This commit is contained in:
Dizzle
2026-08-28 17:37:58 +02:00
committed by GitHub
parent 51ca7edd97
commit d5dfcfff58
10 changed files with 538 additions and 7 deletions

View File

@@ -0,0 +1 @@
- **feat(combo):** choose how combo models are ordered — manual, provider, score, or name — via a sort control in the dashboard builder, persisted in `config.modelSort` and re-applied on load and after add ([#11812](https://github.com/diegosouzapw/OmniRoute/pull/11812)) — thanks @maxmad64bis

View File

@@ -0,0 +1,60 @@
"use client";
import type { SortMethod } from "@/lib/combos/comboSort";
function getI18nOrFallback(
t: ((k: string, f: string) => string) & { has?: (k: string) => boolean },
key: string,
fallback: string
): string {
try {
if (typeof t.has === "function" && !t.has(key)) return fallback;
} catch {}
const out = t(key, fallback);
return typeof out === "string" && out.length > 0 ? out : fallback;
}
const OPTIONS: { value: SortMethod; key: string; fallback: string }[] = [
{ value: "manual", key: "combo.sort.method.manual", fallback: "Manual" },
{ value: "provider", key: "combo.sort.method.provider", fallback: "Provider" },
{ value: "score", key: "combo.sort.method.score", fallback: "Score" },
{ value: "name", key: "combo.sort.method.name", fallback: "Name" },
];
export function ComboSortSelect({
value,
onChange,
t,
}: {
value: SortMethod;
onChange: (m: SortMethod) => void;
t: ((k: string, f: string) => string) & { has?: (k: string) => boolean };
}) {
const scoreHint = getI18nOrFallback(
t,
"combo.sort.scoreHint",
"Score ranking applies to free providers only; others stay in place."
);
const isScore = value === "score";
return (
<label>
{getI18nOrFallback(t, "combo.sort.label", "Sort by")}
<select
aria-label={getI18nOrFallback(t, "combo.sort.label", "Sort by")}
{...(isScore ? { "aria-describedby": "combo-sort-score-hint", title: scoreHint } : {})}
value={value}
onChange={(e) => onChange(e.target.value as SortMethod)}
>
{OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{getI18nOrFallback(t, o.key, o.fallback)}
</option>
))}
</select>
{isScore ? (
<p id="combo-sort-score-hint" className="mt-1 text-[10px] text-text-muted">
{scoreHint}
</p>
) : null}
</label>
);
}

View File

@@ -54,6 +54,16 @@ import KimiComboPresetCard from "./KimiComboPresetCard";
import { KIMI_CODING_PRESET, hasKimiCodingPreset } from "./kimiComboPreset";
import BuilderIntelligentStep from "./BuilderIntelligentStep";
import IntelligentComboPanel from "./IntelligentComboPanel";
import { ComboSortSelect } from "./ComboSortSelect";
import {
sortComboStepsSync,
sortComboStepsByScore,
fetchProviderRankings,
normalizeSortMethod,
isValidSortMethod,
type SortMethod,
} from "@/lib/combos/comboSort";
import type { ComboStep } from "@/lib/combos/steps";
import {
filterCombosByStrategyCategory,
getStrategyCategory,
@@ -2026,6 +2036,25 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
const [builderStage, setBuilderStage] = useState<string>(COMBO_BUILDER_STAGES[0]);
const [showAdvanced, setShowAdvanced] = useState(false);
const [config, setConfig] = useState(sanitizeComboRuntimeConfig(combo?.config));
// Validate persisted enum; ensure reset on combo change not just first mount.
const initialSortMethod = normalizeSortMethod(config.modelSort?.method);
const [sortMethod, setSortMethod] = useState<SortMethod>(initialSortMethod);
useEffect(() => {
// Sync point: when the combo identity changes, re-derive sort method.
// Manual edits via handleSortChange already set sortMethod inside resetFormForCombo,
// but this guards the case where the modal is reused (edit-A→close→edit-B without unmount).
setSortMethod(normalizeSortMethod(combo?.config?.modelSort?.method));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [combo?.id]);
const modelsRef = useRef(models);
const sortMethodRef = useRef<SortMethod>(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
)}
</div>
<div className="flex items-center justify-between gap-2 mb-2">
<ComboSortSelect value={sortMethod} onChange={handleSortChange} t={t} />
</div>
{models.length === 0 ? (
<div className="text-center py-4 border border-dashed border-black/10 dark:border-white/10 rounded-lg bg-black/[0.01] dark:bg-white/[0.01]">
<span className="material-symbols-outlined text-text-muted text-xl mb-1">

View File

@@ -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",

118
src/lib/combos/comboSort.ts Normal file
View File

@@ -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<string>(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<string, number> | Record<string, number>;
function toMap(rankings: Rankings): Map<string, number> {
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<ComboStep[]> {
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<Map<string, number>> {
const res = await fetch("/api/free-provider-rankings");
if (!res.ok) throw new Error(`free-provider-rankings ${res.status}`);
const data = (await res.json()) as { rankings: Array<{ id: string; averageScore: number }> };
return new Map(data.rankings.map((r) => [r.id, r.averageScore]));
}
/** Re-apply the current method to a models array. Sync for manual/provider/name,
* async for score (fetches rankings when getRankings is provided). */
export async function reapplyCurrentSort(
steps: ComboStep[],
method: SortMethod,
getRankings?: () => Promise<Rankings>
): Promise<ComboStep[]> {
if (method === "manual") return steps;
if (method === "score") {
const rankings = getRankings ? await getRankings() : new Map<string, number>();
return sortComboStepsByScore(steps, rankings);
}
return sortComboStepsSync(steps, method);
}

View File

@@ -260,6 +260,12 @@ export const comboRuntimeConfigSchema = z
})
.strict()
.optional(),
// Optional client-side sort hint for combo models.
// Honored in the dashboard builder; reserved for future server-side use. Inert on execution.
modelSort: z
.object({ method: z.enum(["manual", "provider", "score", "name"]) })
.passthrough()
.optional(),
})
.passthrough()
.transform((config) => {

View File

@@ -0,0 +1,49 @@
import { JSDOM } from "jsdom";
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
for (const key of [
"window",
"document",
"HTMLElement",
"Element",
"Node",
"Event",
"CustomEvent",
"MouseEvent",
"KeyboardEvent",
] as const) {
try {
(globalThis as unknown as Record<string, unknown>)[key] = (
dom.window as unknown as Record<string, unknown>
)[key];
} catch {}
}
try {
Object.defineProperty(globalThis, "navigator", {
value: dom.window.navigator,
configurable: true,
writable: true,
});
} catch {}
try {
(globalThis as unknown as Record<string, unknown>).getComputedStyle =
dom.window.getComputedStyle.bind(dom.window);
} catch {}
if (typeof (globalThis as unknown as { matchMedia?: unknown }).matchMedia !== "function") {
(globalThis as unknown as { window: { matchMedia?: unknown } }).window.matchMedia = () =>
({
matches: false,
media: "",
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}) as unknown as MediaQueryList;
(globalThis as unknown as Record<string, unknown>).matchMedia = (
globalThis as unknown as { window: { matchMedia: unknown } }
).window.matchMedia;
}

View File

@@ -0,0 +1,112 @@
// tests/unit/combo/comboSort.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
PROVIDER_ORDER,
sortComboStepsSync,
sortComboStepsByScore,
reapplyCurrentSort,
type ComboStep,
} from "@/lib/combos/comboSort";
const m = (id: string, providerId: string, model: string): ComboStep => ({
id,
kind: "model",
model,
providerId,
weight: 0,
});
const ref = (id: string, comboName: string): ComboStep => ({
id,
kind: "combo-ref",
comboName,
weight: 0,
});
describe("sortComboStepsSync", () => {
it("manual returns the input unchanged", () => {
const steps = [m("a", "openai", "gpt"), m("b", "anthropic", "claude")];
assert.equal(sortComboStepsSync(steps, "manual"), steps);
});
it("provider groups by PROVIDER_ORDER, stable intra-group, combo-ref at end", () => {
const steps = [
m("x", "anthropic", "claude-3"),
m("y", "openai", "gpt-4"),
ref("r", "other-combo"),
m("z", "anthropic", "claude-2"),
];
const out = sortComboStepsSync(steps, "provider");
// combo-ref always last (no providerId).
assert.equal(out[out.length - 1].id, "r");
const nonRef = out.filter((s) => s.id !== "r").map((s) => s.id);
// all three model steps are present, and the two anthropic steps stay grouped.
assert.deepEqual(nonRef.slice().sort(), ["x", "y", "z"]);
const ai = nonRef.indexOf("x");
const zi = nonRef.indexOf("z");
assert.ok(Math.abs(ai - zi) === 1, "steps of the same provider are adjacent");
});
it("name sorts alphabetically with a stable tiebreak", () => {
const steps = [m("a", "openai", "zeta"), m("b", "openai", "alpha"), m("c", "openai", "alpha")];
const out = sortComboStepsSync(steps, "name");
assert.deepEqual(
out.map((s) => s.id),
["b", "c", "a"]
);
});
it("PROVIDER_ORDER is non-empty and stable", () => {
assert.ok(PROVIDER_ORDER.length > 0);
});
});
describe("score sort", () => {
it("sorts scored steps descending, unscored stable at end", async () => {
const steps = [
m("a", "openai", "gpt"), // score 90
m("b", "anthropic", "claude"), // no score
m("c", "google", "gemini"), // score 70
ref("r", "other"), // no providerId
];
const rankings = new Map<string, number>([
["openai", 90],
["google", 70],
]);
const out = await sortComboStepsByScore(steps, rankings);
assert.deepEqual(
out.map((s) => s.id),
["a", "c", "b", "r"]
);
});
it("reapplyCurrentSort applies score async and sync methods", async () => {
const steps = [m("a", "openai", "gpt"), m("b", "claude", "claude")];
const syncOut = await reapplyCurrentSort(steps, "provider");
assert.deepEqual(
syncOut.map((s) => s.id),
["b", "a"]
); // claude (anthropic) before openai
const rankOut = await reapplyCurrentSort(steps, "score", async () => new Map([["openai", 5]]));
assert.deepEqual(
rankOut.map((s) => s.id),
["a", "b"]
);
});
it("reapplyCurrentSort after an add keeps provider grouping", async () => {
const base = [m("a", "anthropic", "claude")];
const added = [...base, m("b", "openai", "gpt")];
const out = await reapplyCurrentSort(added, "provider");
// Expected order derived from PROVIDER_ORDER (robust to provider precedence).
const expected = ["a", "b"].sort((x, y) => {
const px = x === "a" ? "anthropic" : "openai";
const py = y === "a" ? "anthropic" : "openai";
return PROVIDER_ORDER.indexOf(px) - PROVIDER_ORDER.indexOf(py);
});
assert.deepEqual(
out.map((s) => s.id),
expected
);
});
});

View File

@@ -0,0 +1,18 @@
import "../../_setup/jsdomGlobal.ts";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { render, screen, fireEvent } from "@testing-library/react";
import { ComboSortSelect } from "@/app/(dashboard)/dashboard/combos/ComboSortSelect";
const t = (_k: string, f: string) => f;
describe("ComboSortSelect", () => {
it("renders four options and emits the chosen method", () => {
let chosen = "";
render(<ComboSortSelect value="manual" onChange={(mm) => (chosen = mm)} t={t} />);
const select = screen.getByRole("combobox");
assert.equal((select as HTMLSelectElement).options.length, 4);
fireEvent.change(select, { target: { value: "provider" } });
assert.equal(chosen, "provider");
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { comboRuntimeConfigSchema, updateComboSchema } from "@/shared/validation/schemas/combo";
describe("comboRuntimeConfigSchema.modelSort", () => {
it("accepts a valid modelSort method", () => {
const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "provider" } });
assert.deepEqual(parsed.modelSort, { method: "provider" });
});
it("rejects an invalid method", () => {
assert.throws(() => comboRuntimeConfigSchema.parse({ modelSort: { method: "bogus" } }));
});
it("allows extra keys on the modelSort sub-object (passthrough)", () => {
const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "name", future: 1 } });
assert.equal(parsed.modelSort?.method, "name");
});
it("is optional", () => {
const parsed = comboRuntimeConfigSchema.parse({});
assert.equal(parsed.modelSort, undefined);
});
});
describe("modelSort persistence", () => {
it("round-trips through updateComboSchema (models + config)", () => {
const parsed = updateComboSchema.parse({
models: [{ id: "m1", kind: "model", model: "openai/gpt", providerId: "openai", weight: 0 }],
config: { modelSort: { method: "score" } },
});
assert.equal(parsed.config?.modelSort?.method, "score");
});
it("passes through comboRuntimeConfigSchema", () => {
const parsed = comboRuntimeConfigSchema.parse({ modelSort: { method: "provider" } });
assert.equal(parsed.modelSort?.method, "provider");
});
});