Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
### ✨ New Features

- **Provider/model param filters**: config-driven parameter denylist/allowlist per provider/model with auto-learn from upstream 400s (#6649 — thanks @ThongAccount, closes #6625)
- **feat(dashboard):** 9router-parity **Routing Strategy** settings card on Settings → Routing, plus a per-provider account-routing override on the provider detail page ([#6678](https://github.com/diegosouzapw/OmniRoute/pull/6678)) — surfaces the existing account round-robin / sticky-limit knobs and adds a new combo-level sticky round-robin (`comboStickyRoundRobinLimit`, resolved via `resolveComboStickyRoundRobinLimit()` — per-combo → global combo sticky → account sticky cascade) so combo targets can batch calls per target the same way account fallback already does. A new `providerStrategies` setting (Zod-validated map, `src/shared/validation/settingsSchemas.ts`) lets a specific provider override the global `fallbackStrategy`/`stickyRoundRobinLimit` without touching the account-wide default, wired into `getProviderCredentials()` (`src/sse/services/auth.ts`) ahead of the global fallback. Regression guard: `tests/unit/combo-rr-sticky-9router.test.ts`, `tests/unit/settings-ui-layout-static.test.ts`. (thanks @SeaXen)

### 🐛 Bug Fixes

Expand Down
5 changes: 3 additions & 2 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2612,
"src/app/(dashboard)/dashboard/health/page.tsx": 1091,
"src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 784,
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 786,
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": 942,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx": 961,
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": 1278,
Expand Down Expand Up @@ -258,9 +258,10 @@
"src/shared/services/cliRuntime.ts": 1100,
"src/shared/validation/schemas.ts": 2523,
"_rebaseline_2026_06_28_5275_correlation_id_extract": "Extraction of the safe CorrelationId subset of #5275 (hartmark) — request correlation id stored in call_logs (migration 109) and returned via the X-Correlation-Id response header, WITHOUT the combo/resilience or build/lazy-loading changes (those stay in #5275). Own growth: callLogs.ts 975->985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 791<cap pre-feature). Cohesive request/logging chokepoint wiring; structural shrink of chat.ts tracked in #3501.",
"_rebaseline_2026_07_09_6678_routing_strategy_9router": "#6678 (SeaXen) — 9router-parity Routing Strategy settings card + per-provider/combo sticky-round-robin override. Own growth: ProviderDetailPageClient.tsx 784->786 (single ProviderAccountRoutingCard mount + import), auth.ts 2448->2458 (providerStrategies override resolution: fallbackStrategy/stickyRoundRobinLimit per-provider cascade in getProviderCredentials). Both additive, zero unrelated refactor; new UI/logic lives in new files (ProviderAccountRoutingCard.tsx, RoutingStrategyCard.tsx, rrState.ts::resolveComboStickyRoundRobinLimit).",
"src/sse/handlers/chat.ts": 1778,
"src/sse/handlers/chatHelpers.ts": 876,
"src/sse/services/auth.ts": 2448,
"src/sse/services/auth.ts": 2458,
"open-sse/executors/default.ts": 877,
"open-sse/translator/request/openai-responses.ts": 902,
"open-sse/executors/kiro.ts": 944,
Expand Down
15 changes: 7 additions & 8 deletions open-sse/services/combo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
recordStickyRoundRobinSuccess,
getStickyWeightedExecutionKey,
recordStickyWeightedSuccess,
resolveComboStickyRoundRobinLimit,
} from "./combo/rrState.ts";
import {
validateResponseQuality,
Expand Down Expand Up @@ -898,10 +899,9 @@ export async function handleComboChat({
let runtimeStickyTargets: ResolvedComboUnit[] = runtimeUnits;
if (strategy === "round-robin") {
const perComboStickyLimit = (config as Record<string, unknown>).stickyRoundRobinLimit;
runtimeStickyLimit = clampStickyRoundRobinTargetLimit(
perComboStickyLimit !== undefined && perComboStickyLimit !== null
? perComboStickyLimit
: (settings as Record<string, unknown> | null)?.stickyRoundRobinLimit
runtimeStickyLimit = resolveComboStickyRoundRobinLimit(
perComboStickyLimit,
settings as Record<string, unknown> | null
);
const { startIndex, counter } = getStickyRoundRobinStartIndex(
combo.name,
Expand Down Expand Up @@ -2495,10 +2495,9 @@ async function handleRoundRobinCombo({
// sticky batching for both account fallback and combo targets. Values <= 1 preserve
// the historical one-request-per-target rotation.
const perComboStickyLimit = (config as Record<string, unknown>).stickyRoundRobinLimit;
const stickyLimit = clampStickyRoundRobinTargetLimit(
perComboStickyLimit !== undefined && perComboStickyLimit !== null
? perComboStickyLimit
: (settings as Record<string, unknown> | null)?.stickyRoundRobinLimit
const stickyLimit = resolveComboStickyRoundRobinLimit(
perComboStickyLimit,
settings as Record<string, unknown> | null
);
const stickyRoundRobinEnabled = stickyLimit > 1;
// Exhaustion-aware sticky: if the currently sticky target is no longer
Expand Down
19 changes: 19 additions & 0 deletions open-sse/services/combo/rrState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,22 @@ export function recordStickyWeightedSuccess(

weightedStickyTargets.set(comboName, { executionKey, successCount });
}

/**
* Sticky batch size for round-robin combo targets (9router parity).
* Per-combo config → comboStickyRoundRobinLimit → stickyRoundRobinLimit.
* Uses clampStickyRoundRobinTargetLimit defined above in this module.
*/
export function resolveComboStickyRoundRobinLimit(
perComboLimit: unknown,
settings: Record<string, unknown> | null | undefined
): number {
if (perComboLimit !== undefined && perComboLimit !== null) {
return clampStickyRoundRobinTargetLimit(perComboLimit);
}
const comboSticky = settings?.comboStickyRoundRobinLimit;
if (comboSticky !== undefined && comboSticky !== null) {
return clampStickyRoundRobinTargetLimit(comboSticky);
}
return clampStickyRoundRobinTargetLimit(settings?.stickyRoundRobinLimit);
Comment on lines +109 to +116

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The function clampStickyRoundRobinTargetLimit is used in resolveComboStickyRoundRobinLimit but is not imported or defined in this file. This will cause a compilation or runtime error. Please ensure it is imported, or if it is defined in open-sse/services/combo.ts, move its definition to rrState.ts (or a shared utility file) to avoid a circular dependency between combo.ts and rrState.ts.

}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import CustomModelsSection from "./components/CustomModelsSection";
import ConnectionsListPanel from "./components/ConnectionsListPanel";
import CoolingConnectionsPanel from "./components/CoolingConnectionsPanel";
import ConnectionsHeaderToolbar from "./components/ConnectionsHeaderToolbar";
import ProviderAccountRoutingCard from "../../settings/components/ProviderAccountRoutingCard";
import ZedImportCard from "./components/ZedImportCard";
import ProviderPageHeader from "./components/ProviderPageHeader";
import CompatibleNodeCard from "./components/CompatibleNodeCard";
Expand Down Expand Up @@ -488,6 +489,7 @@ export default function ProviderDetailPageClient() {
)}
{!isUpstreamProxyProvider && !isFreeNoAuth && (
<Card>
<ProviderAccountRoutingCard providerKey={providerId} connectionCount={connections.length} />
<ConnectionsHeaderToolbar
providerId={providerId}
providerInfo={providerInfo}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { Input, Select } from "@/shared/components";
import { useTranslations } from "next-intl";
import { ACCOUNT_FALLBACK_STRATEGY_VALUES } from "@/shared/constants/routingStrategies";

type ProviderStrategyOverride = {
fallbackStrategy?: string;
stickyRoundRobinLimit?: number;
};

type Props = {
providerKey: string;
connectionCount: number;
};

const STRATEGY_OPTIONS = ACCOUNT_FALLBACK_STRATEGY_VALUES.filter((v) =>
["fill-first", "round-robin", "priority", "p2c", "random", "least-used"].includes(v)
);

function clampProviderStickyLimit(raw: string): number {
const val = parseInt(raw, 10);
return Math.min(10, Math.max(1, Number.isNaN(val) ? 3 : val));
}

/** Loads/saves the per-provider account-routing override. Extracted out of the
* component body to keep ProviderAccountRoutingCard's own render function small. */
function useProviderAccountRoutingState(providerKey: string) {
const [strategy, setStrategy] = useState<string>("");
const [stickyLimit, setStickyLimit] = useState("3");
const [busy, setBusy] = useState(false);

const load = useCallback(async () => {
const res = await fetch("/api/settings", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
const override = ((data?.providerStrategies || {}) as Record<string, ProviderStrategyOverride>)[
providerKey
];
setStrategy(override?.fallbackStrategy || "");
setStickyLimit(
override?.stickyRoundRobinLimit != null ? String(override.stickyRoundRobinLimit) : ""
);
}, [providerKey]);

useEffect(() => {
load().catch(console.error);
}, [load]);

const save = useCallback(
async (nextStrategy: string, nextSticky: string) => {
setBusy(true);
try {
const res = await fetch("/api/settings", { cache: "no-store" });
if (!res.ok) throw new Error("Failed to fetch current settings");
const data = await res.json();
const current = (data?.providerStrategies || {}) as Record<
string,
ProviderStrategyOverride
>;
const override: ProviderStrategyOverride = {};
if (nextStrategy) override.fallbackStrategy = nextStrategy;
if (nextStrategy === "round-robin" && nextSticky !== "") {
override.stickyRoundRobinLimit = clampProviderStickyLimit(nextSticky);
}
const updated = { ...current };
if (Object.keys(override).length === 0) delete updated[providerKey];
else updated[providerKey] = override;
const patchRes = await fetch("/api/settings", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerStrategies: updated }),
});
if (!patchRes.ok) throw new Error("Failed to save provider routing settings");
} catch (e) {
console.error(e);
} finally {
setBusy(false);
}
},
[providerKey]
);

return { strategy, setStrategy, stickyLimit, setStickyLimit, busy, save };
}

export default function ProviderAccountRoutingCard({ providerKey, connectionCount }: Props) {
const t = useTranslations("settings");
const { strategy, setStrategy, stickyLimit, setStickyLimit, busy, save } =
useProviderAccountRoutingState(providerKey);

if (connectionCount < 2) return null;

return (
<div className="mb-4 rounded-lg border border-border/60 bg-black/[0.02] dark:bg-white/[0.02] p-3">
<p className="text-sm font-medium">{t("providerAccountRoutingTitle")}</p>
<p className="text-xs text-text-muted mb-3">{t("providerAccountRoutingDesc")}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Select
label={t("providerRoutingStrategy")}
disabled={busy}
value={strategy}
onChange={(e) => {
const v = e.target.value;
setStrategy(v);
save(v, stickyLimit).catch(console.error);
}}
>
<option value="">{t("providerRoutingInheritGlobal")}</option>
{STRATEGY_OPTIONS.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</Select>
{strategy === "round-robin" && (
<Input
label={t("stickyLimit")}
type="number"
min={1}
max={10}
disabled={busy}
value={stickyLimit || "3"}
onChange={(e) => setStickyLimit(e.target.value)}
onBlur={() => save(strategy, stickyLimit).catch(console.error)}
/>
)}
</div>
</div>
);
}
Loading
Loading