Skip to content

feat(settings): 9router-style Routing Strategy card + sticky parity#6678

Open
SeaXen wants to merge 4 commits into
diegosouzapw:release/v3.8.47from
SeaXen:feat/9router-routing-strategy-ui
Open

feat(settings): 9router-style Routing Strategy card + sticky parity#6678
SeaXen wants to merge 4 commits into
diegosouzapw:release/v3.8.47from
SeaXen:feat/9router-routing-strategy-ui

Conversation

@SeaXen

@SeaXen SeaXen commented Jul 8, 2026

Copy link
Copy Markdown

Problem

Users comparing 9router Profile → Routing Strategy could not find equivalent controls in OmniRoute (sticky round-robin was buried under advanced combo defaults).

Solution

  • Settings → Routing: new top Routing Strategy card (9router parity)
    • Account Round Robin + Sticky Limit
    • Combo Round Robin + Combo Sticky Limit
  • New settings keys: comboStrategy, comboStickyRoundRobinLimit, providerStrategies
  • Provider detail (2+ connections): per-provider account strategy override
  • Runtime: providerStrategies honored in auth.ts; combo sticky uses comboStickyRoundRobinLimit before global account sticky

How to verify

  1. Open Settings → Routing — first card is Routing Strategy
  2. Toggle account Round Robin → Sticky Limit appears
  3. Toggle Combo Round Robin → Combo Sticky Limit appears
  4. Provider with multiple accounts → override strategy on provider page

Tests

  • tests/unit/combo-rr-sticky-9router.test.ts
  • settings-ui-layout-static.test.ts updated for card order

@SeaXen SeaXen requested a review from diegosouzapw as a code owner July 8, 2026 18:10

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces a Routing Strategy configuration interface to achieve 9router parity, enabling global and per-provider overrides for account round-robin, sticky limits, and combo rotation. The reviewer feedback highlights several critical and high-severity issues that need to be addressed: a potential data-loss bug in the provider strategy saving logic where other provider strategies could be wiped out on failure, a missing import of clampStickyRoundRobinTargetLimit in rrState.ts, and UX issues in the input fields where clamping values on onChange prevents natural typing. Additionally, the feedback suggests catching errors in useEffect hooks and using optional chaining to prevent runtime errors.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +46 to +47
const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {};

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.

critical

If the GET request to /api/settings fails or returns a non-OK response, res.ok is false and data defaults to {}. This means current (which holds all other provider strategies) will also be empty {}. When the subsequent PATCH request is sent, it will write { providerStrategies: { [providerKey]: override } }, which will completely wipe out all other provider strategies in the database.

To prevent this critical data loss, you should throw an error and abort the save operation if the GET request fails.

Suggested change
const res = await fetch("/api/settings", { cache: "no-store" });
const data = res.ok ? await res.json() : {};
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();

Comment on lines +108 to +115
if (perComboLimit !== undefined && perComboLimit !== null) {
return clampStickyRoundRobinTargetLimit(perComboLimit);
}
const comboSticky = settings?.comboStickyRoundRobinLimit;
if (comboSticky !== undefined && comboSticky !== null) {
return clampStickyRoundRobinTargetLimit(comboSticky);
}
return clampStickyRoundRobinTargetLimit(settings?.stickyRoundRobinLimit);

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.

Comment on lines +51 to +53
if (nextStrategy === "round-robin" && nextSticky !== "") {
override.stickyRoundRobinLimit = Number(nextSticky) || 3;
}

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 Zod schema in settingsSchemas.ts enforces stickyRoundRobinLimit: z.number().int().min(1).max(1000). If a user types or pastes an invalid value (e.g., 0 or a negative number), Number(nextSticky) || 3 could result in an invalid value being sent to the server, causing a Zod validation error (400 Bad Request) and failing to save. We should clamp the value on the client side before saving.

Suggested change
if (nextStrategy === "round-robin" && nextSticky !== "") {
override.stickyRoundRobinLimit = Number(nextSticky) || 3;
}
if (nextStrategy === "round-robin" && nextSticky !== "") {
const val = parseInt(nextSticky, 10);
override.stickyRoundRobinLimit = Math.min(10, Math.max(1, isNaN(val) ? 3 : val));
}

Comment on lines +29 to +30
const data = await fetch("/api/settings", { cache: "no-store" }).then((r) => r.json());
const override = ((data.providerStrategies || {}) as Record<string, ProviderStrategyOverride>)[

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.

medium

Use optional chaining when accessing providerStrategies on data to prevent potential runtime TypeErrors if data is null, undefined, or not an object.

Suggested change
const data = await fetch("/api/settings", { cache: "no-store" }).then((r) => r.json());
const override = ((data.providerStrategies || {}) as Record<string, ProviderStrategyOverride>)[
const data = await fetch("/api/settings", { cache: "no-store" }).then((r) => r.ok ? r.json() : null);
const override = ((data?.providerStrategies || {}) as Record<string, ProviderStrategyOverride>)[
providerKey
];

Comment on lines +7 to +12
type RoutingSettings = {
fallbackStrategy?: string;
stickyRoundRobinLimit?: number;
comboStrategy?: string;
comboStickyRoundRobinLimit?: number;
};

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.

medium

Update the RoutingSettings type definition to allow string values for the limit fields. This is necessary to support storing the raw string input in the state while the user is typing, before it is parsed and clamped on blur.

Suggested change
type RoutingSettings = {
fallbackStrategy?: string;
stickyRoundRobinLimit?: number;
comboStrategy?: string;
comboStickyRoundRobinLimit?: number;
};
type RoutingSettings = {
fallbackStrategy?: string;
stickyRoundRobinLimit?: number | string;
comboStrategy?: string;
comboStickyRoundRobinLimit?: number | string;
};

Comment on lines +134 to +147
onChange={(e) => {
const num = Math.min(10, Math.max(1, parseInt(e.target.value, 10) || 3));
setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: num }));
}}
onBlur={() =>
run(async () => {
const limit = settings.stickyRoundRobinLimit ?? 3;
const updated = await patchSettings({ stickyRoundRobinLimit: limit });
setSettings((prev) => ({
...prev,
stickyRoundRobinLimit: updated.stickyRoundRobinLimit ?? limit,
}));
})
}

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.

medium

Clamping and defaulting the value in the onChange handler makes it extremely difficult for users to type or edit the input. For example, if a user presses backspace to clear the input, parseInt("", 10) returns NaN, which immediately defaults to 3, preventing them from clearing the input.

Instead, allow the state to hold the raw string value during typing, and only clamp/validate on onBlur when saving.

Suggested change
onChange={(e) => {
const num = Math.min(10, Math.max(1, parseInt(e.target.value, 10) || 3));
setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: num }));
}}
onBlur={() =>
run(async () => {
const limit = settings.stickyRoundRobinLimit ?? 3;
const updated = await patchSettings({ stickyRoundRobinLimit: limit });
setSettings((prev) => ({
...prev,
stickyRoundRobinLimit: updated.stickyRoundRobinLimit ?? limit,
}));
})
}
onChange={(e) => {
setSettings((prev) => ({ ...prev, stickyRoundRobinLimit: e.target.value }));
}}
onBlur={() =>
run(async () => {
const limit = Math.min(10, Math.max(1, parseInt(String(settings.stickyRoundRobinLimit), 10) || 3));
const updated = await patchSettings({ stickyRoundRobinLimit: limit });
setSettings((prev) => ({
...prev,
stickyRoundRobinLimit: updated.stickyRoundRobinLimit ?? limit,
}));
})
}

Comment on lines +188 to +201
onChange={(e) => {
const num = Math.min(100, Math.max(1, parseInt(e.target.value, 10) || 1));
setSettings((prev) => ({ ...prev, comboStickyRoundRobinLimit: num }));
}}
onBlur={() =>
run(async () => {
const limit = settings.comboStickyRoundRobinLimit ?? 1;
const updated = await patchSettings({ comboStickyRoundRobinLimit: limit });
setSettings((prev) => ({
...prev,
comboStickyRoundRobinLimit: updated.comboStickyRoundRobinLimit ?? limit,
}));
})
}

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.

medium

Similar to the stickyRoundRobinLimit input, clamping and defaulting the value in onChange prevents users from clearing the input or typing freely. Let's defer the clamping and validation to onBlur.

Suggested change
onChange={(e) => {
const num = Math.min(100, Math.max(1, parseInt(e.target.value, 10) || 1));
setSettings((prev) => ({ ...prev, comboStickyRoundRobinLimit: num }));
}}
onBlur={() =>
run(async () => {
const limit = settings.comboStickyRoundRobinLimit ?? 1;
const updated = await patchSettings({ comboStickyRoundRobinLimit: limit });
setSettings((prev) => ({
...prev,
comboStickyRoundRobinLimit: updated.comboStickyRoundRobinLimit ?? limit,
}));
})
}
onChange={(e) => {
setSettings((prev) => ({ ...prev, comboStickyRoundRobinLimit: e.target.value }));
}}
onBlur={() =>
run(async () => {
const limit = Math.min(100, Math.max(1, parseInt(String(settings.comboStickyRoundRobinLimit), 10) || 1));
const updated = await patchSettings({ comboStickyRoundRobinLimit: limit });
setSettings((prev) => ({
...prev,
comboStickyRoundRobinLimit: updated.comboStickyRoundRobinLimit ?? limit,
}));
})
}

Comment on lines +64 to +66
useEffect(() => {
load();
}, [load]);

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.

medium

The load function is asynchronous and can throw errors (e.g., if the network request fails). Calling it inside useEffect without catching errors will result in an unhandled promise rejection.

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

@SeaXen

SeaXen commented Jul 8, 2026

Copy link
Copy Markdown
Author

Addressed gemini-code-assist review in latest push:

  • Critical: ProviderAccountRoutingCard aborts save if GET settings fails (no wipe of other providerStrategies); PATCH errors logged.
  • High: client-side clamp for per-provider sticky limit (1–10).
  • Medium: optional chaining on settings load; sticky inputs use raw string on onChange, clamp on onBlur; load().catch in RoutingStrategyCard.
  • rrState.ts: clampStickyRoundRobinTargetLimit is already defined in the same file (lines 30–34) — no import/circular-dep issue; added doc comment.

Semgrep already passing on prior commit.

@diegosouzapw diegosouzapw changed the base branch from main to release/v3.8.47 July 9, 2026 07:25
…o sticky override (diegosouzapw#6678)

Add a Routing Strategy settings card (Settings -> Routing) surfacing account
round-robin/sticky-limit knobs plus a new combo-level sticky round-robin
(comboStickyRoundRobinLimit), and a per-provider account-routing override
(providerStrategies) wired into getProviderCredentials() ahead of the global
fallback strategy. Rebased onto release/v3.8.47 (credit-preserving
reconstruction: unrelated package.json/electron/proxyDispatcher drift from the
PR's stale base was dropped, only the author's own 12 files were re-applied).
Split ProviderAccountRoutingCard/RoutingStrategyCard into smaller
hook+subcomponent pieces to stay under the frozen complexity/file-size gates;
rebaselined ProviderDetailPageClient.tsx/auth.ts's frozen file-size caps for
the small additive growth.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
@diegosouzapw diegosouzapw force-pushed the feat/9router-routing-strategy-ui branch from eac4136 to e9c2d75 Compare July 9, 2026 09:18
SeaXen and others added 3 commits July 9, 2026 06:32
…ap.testFiles (diegosouzapw#6678)

CI's Fast Quality Gates -> check:mutation-test-coverage --strict flagged the new
test as missing from stryker.conf.json's tap.testFiles (it covers the mutated
module open-sse/services/combo/rrState.ts). Adds the single entry, alphabetized
next to the existing combo-rr-fallback-advance-948.test.ts.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants