Skip to content

Commit e962a45

Browse files
committed
feat(config): support sticky round robin routing
Expose sticky-round-robin and sticky TTL in the management UI so routing can be configured and displayed consistently.
1 parent 3d88a39 commit e962a45

10 files changed

Lines changed: 104 additions & 15 deletions

File tree

src/components/config/VisualConfigEditor.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,7 @@ export function VisualConfigEditor({
186186
const shouldRenderFloatingSidebar = !isMobile && isFloatingSidebar && isCurrentLayer;
187187
const routingStrategyLabelId = useId();
188188
const routingStrategyHintId = `${routingStrategyLabelId}-hint`;
189+
const stickyTTLInputId = useId();
189190
const keepaliveInputId = useId();
190191
const keepaliveHintId = `${keepaliveInputId}-hint`;
191192
const keepaliveErrorId = `${keepaliveInputId}-error`;
@@ -219,6 +220,7 @@ export function VisualConfigEditor({
219220
);
220221
const maxRetryCredentialsError = getValidationMessage(t, validationErrors?.maxRetryCredentials);
221222
const maxRetryIntervalError = getValidationMessage(t, validationErrors?.maxRetryInterval);
223+
const routingStickyTTLError = getValidationMessage(t, validationErrors?.routingStickyTTL);
222224
const keepaliveError = getValidationMessage(t, validationErrors?.['streaming.keepaliveSeconds']);
223225
const bootstrapRetriesError = getValidationMessage(
224226
t,
@@ -307,6 +309,7 @@ export function VisualConfigEditor({
307309
'forwardRequestHeaders',
308310
'maxRetryCredentials',
309311
'maxRetryInterval',
312+
'routingStickyTTL',
310313
]),
311314
},
312315
{
@@ -884,6 +887,12 @@ export function VisualConfigEditor({
884887
value: 'round-robin',
885888
label: t('config_management.visual.sections.network.strategy_round_robin'),
886889
},
890+
{
891+
value: 'sticky-round-robin',
892+
label: t(
893+
'config_management.visual.sections.network.strategy_sticky_round_robin'
894+
),
895+
},
887896
{
888897
value: 'fill-first',
889898
label: t('config_management.visual.sections.network.strategy_fill_first'),
@@ -900,6 +909,19 @@ export function VisualConfigEditor({
900909
}
901910
/>
902911
</FieldShell>
912+
{values.routingStrategy === 'sticky-round-robin' && (
913+
<Input
914+
label={t('config_management.visual.sections.network.sticky_ttl')}
915+
type="number"
916+
placeholder="0"
917+
value={values.routingStickyTTL}
918+
onChange={(e) => onChange({ routingStickyTTL: e.target.value })}
919+
disabled={disabled}
920+
hint={t('config_management.visual.sections.network.sticky_ttl_hint')}
921+
id={stickyTTLInputId}
922+
error={routingStickyTTLError}
923+
/>
924+
)}
903925
</SectionGrid>
904926

905927
<SectionGrid>

src/hooks/useVisualConfig.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ export function getVisualConfigValidationErrors(
184184
forwardRequestHeaders: getDuplicateHeaderKeyError(values.forwardRequestHeaders),
185185
maxRetryCredentials: getNonNegativeIntegerError(values.maxRetryCredentials),
186186
maxRetryInterval: getNonNegativeIntegerError(values.maxRetryInterval),
187+
routingStickyTTL:
188+
values.routingStrategy === 'sticky-round-robin'
189+
? getNonNegativeIntegerError(values.routingStickyTTL)
190+
: undefined,
187191
'streaming.keepaliveSeconds': getNonNegativeIntegerError(values.streaming.keepaliveSeconds),
188192
'streaming.bootstrapRetries': getNonNegativeIntegerError(values.streaming.bootstrapRetries),
189193
'streaming.nonstreamKeepaliveInterval': getNonNegativeIntegerError(
@@ -735,6 +739,9 @@ function getNextDirtyFields(
735739
if (Object.prototype.hasOwnProperty.call(patch, 'routingStrategy')) {
736740
updateDirty('routingStrategy', nextValues.routingStrategy === baselineValues.routingStrategy);
737741
}
742+
if (Object.prototype.hasOwnProperty.call(patch, 'routingStickyTTL')) {
743+
updateDirty('routingStickyTTL', nextValues.routingStickyTTL === baselineValues.routingStickyTTL);
744+
}
738745
if (Object.prototype.hasOwnProperty.call(patch, 'payloadDefaultRules')) {
739746
updateDirty(
740747
'payloadDefaultRules',
@@ -909,13 +916,19 @@ export function useVisualConfig() {
909916
forwardRequestHeaders: parseHeaderEntries(parsed['forward-request-headers']),
910917
maxRetryCredentials: String(parsed['max-retry-credentials'] ?? ''),
911918
maxRetryInterval: String(parsed['max-retry-interval'] ?? ''),
919+
routingStickyTTL: String(routing?.['sticky-ttl'] ?? ''),
912920
wsAuth: Boolean(parsed['ws-auth']),
913921

914922
quotaSwitchProject: Boolean(quotaExceeded?.['switch-project'] ?? true),
915923
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
916924
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? true),
917925

918-
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
926+
routingStrategy:
927+
routing?.strategy === 'fill-first'
928+
? 'fill-first'
929+
: routing?.strategy === 'sticky-round-robin'
930+
? 'sticky-round-robin'
931+
: 'round-robin',
919932

920933
payloadDefaultRules: parsePayloadRules(payload?.default),
921934
payloadDefaultRawRules: parseRawPayloadRules(payload?.['default-raw']),
@@ -1030,9 +1043,27 @@ export function useVisualConfig() {
10301043
deleteIfMapEmpty(doc, ['quota-exceeded']);
10311044
}
10321045

1033-
if (docHas(doc, ['routing']) || values.routingStrategy !== 'round-robin') {
1046+
const routingStickyTTL =
1047+
typeof values.routingStickyTTL === 'string' ? values.routingStickyTTL : '';
1048+
const shouldWriteRoutingStickyTTL = values.routingStrategy === 'sticky-round-robin';
1049+
const routingDefined =
1050+
docHas(doc, ['routing']) ||
1051+
values.routingStrategy !== 'round-robin' ||
1052+
(shouldWriteRoutingStickyTTL && routingStickyTTL.trim());
1053+
if (routingDefined) {
10341054
ensureMapInDoc(doc, ['routing']);
1035-
doc.setIn(['routing', 'strategy'], values.routingStrategy);
1055+
if (values.routingStrategy === 'round-robin') {
1056+
if (docHas(doc, ['routing', 'strategy'])) {
1057+
doc.deleteIn(['routing', 'strategy']);
1058+
}
1059+
} else {
1060+
doc.setIn(['routing', 'strategy'], values.routingStrategy);
1061+
}
1062+
if (shouldWriteRoutingStickyTTL) {
1063+
setIntFromStringInDoc(doc, ['routing', 'sticky-ttl'], routingStickyTTL);
1064+
} else if (docHas(doc, ['routing', 'sticky-ttl'])) {
1065+
doc.deleteIn(['routing', 'sticky-ttl']);
1066+
}
10361067
deleteIfMapEmpty(doc, ['routing']);
10371068
}
10381069

src/i18n/locales/en.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,11 @@
183183
"ws_auth_enable": "Require auth for /ws/*",
184184
"routing_title": "Routing Strategy",
185185
"routing_strategy_label": "Routing strategy:",
186-
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key.",
186+
"routing_strategy_hint": "round-robin cycles through keys; fill-first prioritizes the first available key; sticky-round-robin keeps a sticky cycle per access identity.",
187187
"routing_strategy_update": "Update",
188188
"routing_strategy_round_robin": "round-robin (cycle)",
189-
"routing_strategy_fill_first": "fill-first (prioritize)"
189+
"routing_strategy_fill_first": "fill-first (prioritize)",
190+
"routing_strategy_sticky_round_robin": "sticky-round-robin (sticky cycle)"
190191
},
191192
"api_keys": {
192193
"title": "API Keys Management",
@@ -1271,9 +1272,12 @@
12711272
"max_retry_credentials_hint": "Leave empty to keep it unset. Set to 0 to preserve legacy behavior and try all available credentials.",
12721273
"max_retry_interval": "Max Retry Interval (seconds)",
12731274
"routing_strategy": "Routing Strategy",
1274-
"routing_strategy_hint": "Select credential selection strategy",
1275+
"routing_strategy_hint": "Select the credential selection strategy. sticky-round-robin reuses the same sticky route per access identity until the TTL expires.",
12751276
"strategy_round_robin": "Round Robin",
1277+
"strategy_sticky_round_robin": "Sticky Round Robin",
12761278
"strategy_fill_first": "Fill First",
1279+
"sticky_ttl": "Sticky TTL (seconds)",
1280+
"sticky_ttl_hint": "Leave empty to keep it unset; only applies to sticky-round-robin.",
12771281
"force_model_prefix": "Force Model Prefix",
12781282
"force_model_prefix_desc": "Unprefixed model requests only use credentials without prefix",
12791283
"forward_request_headers_title": "Global Forward Headers",

src/i18n/locales/ru.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,11 @@
183183
"ws_auth_enable": "Требовать аутентификацию для /ws/*",
184184
"routing_title": "Стратегия маршрутизации",
185185
"routing_strategy_label": "Стратегия маршрутизации:",
186-
"routing_strategy_hint": "round-robin циклически перебирает ключи; fill-first отдаёт приоритет первому доступному ключу.",
186+
"routing_strategy_hint": "round-robin циклически перебирает ключи; fill-first отдаёт приоритет первому доступному ключу; sticky-round-robin сохраняет липкий цикл для каждой входной identity.",
187187
"routing_strategy_update": "Обновить",
188188
"routing_strategy_round_robin": "round-robin (цикл)",
189-
"routing_strategy_fill_first": "fill-first (приоритет)"
189+
"routing_strategy_fill_first": "fill-first (приоритет)",
190+
"routing_strategy_sticky_round_robin": "sticky-round-robin (липкий цикл)"
190191
},
191192
"api_keys": {
192193
"title": "Управление API-ключами",
@@ -1270,9 +1271,12 @@
12701271
"max_retry_credentials_hint": "Оставьте пустым, чтобы не задавать поле. Значение 0 сохраняет legacy-поведение и позволяет перебрать все доступные учётные данные.",
12711272
"max_retry_interval": "Максимальный интервал повтора (сек)",
12721273
"routing_strategy": "Стратегия маршрутизации",
1273-
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
1274+
"routing_strategy_hint": "Выберите стратегию подбора учётных данных; sticky-round-robin повторно использует один и тот же липкий маршрут для входной identity, пока не истечёт TTL.",
12741275
"strategy_round_robin": "По кругу",
1276+
"strategy_sticky_round_robin": "Липкий цикл",
12751277
"strategy_fill_first": "Сначала заполнить",
1278+
"sticky_ttl": "Sticky TTL (сек)",
1279+
"sticky_ttl_hint": "Оставьте пустым, чтобы не задавать поле; применяется только к sticky-round-robin.",
12761280
"force_model_prefix": "Принудительный префикс модели",
12771281
"force_model_prefix_desc": "Запросы к моделям без префикса используют только учётные данные без префикса",
12781282
"forward_request_headers_title": "Глобальные заголовки переадресации",

src/i18n/locales/zh-CN.json

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,11 @@
183183
"ws_auth_enable": "启用 /ws/* 鉴权",
184184
"routing_title": "路由策略",
185185
"routing_strategy_label": "路由策略:",
186-
"routing_strategy_hint": "round-robin 为轮询,fill-first 为优先填充。",
186+
"routing_strategy_hint": "round-robin 为轮询,fill-first 为优先填充,sticky-round-robin 为按入口身份保持粘性轮询",
187187
"routing_strategy_update": "更新",
188188
"routing_strategy_round_robin": "round-robin (轮询)",
189-
"routing_strategy_fill_first": "fill-first (优先填充)"
189+
"routing_strategy_fill_first": "fill-first (优先填充)",
190+
"routing_strategy_sticky_round_robin": "sticky-round-robin (粘性轮询)"
190191
},
191192
"api_keys": {
192193
"title": "API 密钥管理",
@@ -1271,9 +1272,12 @@
12711272
"max_retry_credentials_hint": "留空表示不设置;设为 0 表示保留 legacy 行为,并尝试所有可用凭据。",
12721273
"max_retry_interval": "最大重试间隔 (秒)",
12731274
"routing_strategy": "路由策略",
1274-
"routing_strategy_hint": "选择凭据选择策略",
1275+
"routing_strategy_hint": "选择凭据选择策略;sticky-round-robin 会按入口身份复用同一条粘性路由,直到 TTL 过期。",
12751276
"strategy_round_robin": "轮询 (Round Robin)",
1277+
"strategy_sticky_round_robin": "粘性轮询 (Sticky Round Robin)",
12761278
"strategy_fill_first": "填充优先 (Fill First)",
1279+
"sticky_ttl": "粘性 TTL (秒)",
1280+
"sticky_ttl_hint": "留空表示不设置;仅对 sticky-round-robin 生效。",
12771281
"force_model_prefix": "强制模型前缀",
12781282
"force_model_prefix_desc": "未带前缀的模型请求只使用无前缀凭据",
12791283
"forward_request_headers_title": "全局转发请求头",

src/pages/DashboardPage.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,12 +251,14 @@ export function DashboardPage() {
251251
? t('basic_settings.routing_strategy_round_robin')
252252
: routingStrategyRaw === 'fill-first'
253253
? t('basic_settings.routing_strategy_fill_first')
254-
: routingStrategyRaw;
254+
: routingStrategyRaw === 'sticky-round-robin'
255+
? t('basic_settings.routing_strategy_sticky_round_robin')
256+
: routingStrategyRaw;
255257
const routingStrategyBadgeClass = !routingStrategyRaw
256258
? styles.configBadgeUnknown
257259
: routingStrategyRaw === 'round-robin'
258260
? styles.configBadgeRoundRobin
259-
: routingStrategyRaw === 'fill-first'
261+
: routingStrategyRaw === 'fill-first' || routingStrategyRaw === 'sticky-round-robin'
260262
? styles.configBadgeFillFirst
261263
: styles.configBadgeUnknown;
262264

src/services/api/transformers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,17 @@ export const normalizeConfigResponse = (raw: unknown): Config => {
404404
if (strategyRaw !== undefined && strategyRaw !== null) {
405405
config.routingStrategy = String(strategyRaw);
406406
}
407+
const stickyTTLRaw = isRecord(routing)
408+
? (routing['sticky-ttl'] ?? routing.stickyTTL)
409+
: (raw['routing-sticky-ttl'] ?? raw.routingStickyTTL);
410+
if (typeof stickyTTLRaw === 'number' && Number.isFinite(stickyTTLRaw)) {
411+
config.routingStickyTTL = stickyTTLRaw;
412+
} else if (typeof stickyTTLRaw === 'string' && stickyTTLRaw.trim() !== '') {
413+
const parsed = Number(stickyTTLRaw);
414+
if (Number.isFinite(parsed)) {
415+
config.routingStickyTTL = parsed;
416+
}
417+
}
407418
const apiKeysRaw = raw['api-keys'] ?? raw.apiKeys;
408419
if (Array.isArray(apiKeysRaw)) {
409420
config.apiKeys = apiKeysRaw.map((key) => String(key)).filter((key) => key.trim() !== '');

src/stores/useConfigStore.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const SECTION_KEYS: RawConfigSection[] = [
4545
'ws-auth',
4646
'force-model-prefix',
4747
'routing/strategy',
48+
'routing/sticky-ttl',
4849
'api-keys',
4950
'ampcode',
5051
'gemini-api-key',
@@ -80,6 +81,8 @@ const extractSectionValue = (config: Config | null, section?: RawConfigSection)
8081
return config.forceModelPrefix;
8182
case 'routing/strategy':
8283
return config.routingStrategy;
84+
case 'routing/sticky-ttl':
85+
return config.routingStickyTTL;
8386
case 'api-keys':
8487
return config.apiKeys;
8588
case 'ampcode':
@@ -223,6 +226,9 @@ export const useConfigStore = create<ConfigState>((set, get) => ({
223226
case 'routing/strategy':
224227
nextConfig.routingStrategy = value as Config['routingStrategy'];
225228
break;
229+
case 'routing/sticky-ttl':
230+
nextConfig.routingStickyTTL = value as Config['routingStickyTTL'];
231+
break;
226232
case 'api-keys':
227233
nextConfig.apiKeys = value as Config['apiKeys'];
228234
break;

src/types/config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface Config {
2525
wsAuth?: boolean;
2626
forceModelPrefix?: boolean;
2727
routingStrategy?: string;
28+
routingStickyTTL?: number;
2829
apiKeys?: string[];
2930
ampcode?: AmpcodeConfig;
3031
geminiApiKeys?: GeminiKeyConfig[];
@@ -49,6 +50,7 @@ export type RawConfigSection =
4950
| 'ws-auth'
5051
| 'force-model-prefix'
5152
| 'routing/strategy'
53+
| 'routing/sticky-ttl'
5254
| 'api-keys'
5355
| 'ampcode'
5456
| 'gemini-api-key'

src/types/visualConfig.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type VisualConfigFieldPath =
1313
| 'forwardRequestHeaders'
1414
| 'maxRetryCredentials'
1515
| 'maxRetryInterval'
16+
| 'routingStickyTTL'
1617
| 'streaming.keepaliveSeconds'
1718
| 'streaming.bootstrapRetries'
1819
| 'streaming.nonstreamKeepaliveInterval';
@@ -80,10 +81,11 @@ export type VisualConfigValues = {
8081
forwardRequestHeaders: HeaderEntry[];
8182
maxRetryCredentials: string;
8283
maxRetryInterval: string;
84+
routingStickyTTL: string;
8385
quotaSwitchProject: boolean;
8486
quotaSwitchPreviewModel: boolean;
8587
quotaAntigravityCredits: boolean;
86-
routingStrategy: 'round-robin' | 'fill-first';
88+
routingStrategy: 'round-robin' | 'fill-first' | 'sticky-round-robin';
8789
wsAuth: boolean;
8890
payloadDefaultRules: PayloadRule[];
8991
payloadDefaultRawRules: PayloadRule[];
@@ -121,6 +123,7 @@ export const DEFAULT_VISUAL_VALUES: VisualConfigValues = {
121123
forwardRequestHeaders: [],
122124
maxRetryCredentials: '',
123125
maxRetryInterval: '',
126+
routingStickyTTL: '',
124127
quotaSwitchProject: true,
125128
quotaSwitchPreviewModel: true,
126129
quotaAntigravityCredits: true,

0 commit comments

Comments
 (0)