feat: add support for weighted round-robin routing strategy and credential weight management

- Implemented 'weighted-round-robin' as a new routing strategy in DashboardPage.
- Introduced weight input fields in ApiKeyEntriesEditor, BaseProviderForm, and SponsorProviderForm.
- Added validation for credential weights, including integer checks and maximum limits.
- Updated localization files to include new weight-related labels and hints.
- Created utility functions for credential weight validation and parsing.
- Added tests for credential weight handling and visual config routing strategy.
This commit is contained in:
Supra4E8C
2026-07-30 07:22:24 +08:00
parent 9524cc7f32
commit 8faaa39534
26 changed files with 543 additions and 38 deletions
@@ -1140,6 +1140,12 @@ export function VisualConfigEditor({
'config_management.visual.sections.network.strategy_round_robin'
),
},
{
value: 'weighted-round-robin',
label: t(
'config_management.visual.sections.network.strategy_weighted_round_robin'
),
},
{
value: 'fill-first',
label: t(
+2 -8
View File
@@ -6,13 +6,7 @@
// JSX in <FieldAnchor fieldId="..."> using the same `fieldId`).
export type VisualSectionId =
| 'connectivity'
| 'network'
| 'logging'
| 'quota'
| 'streaming'
| 'advanced'
| 'payload';
'connectivity' | 'network' | 'logging' | 'quota' | 'streaming' | 'advanced' | 'payload';
export interface ConfigFieldSearchEntry {
/** Stable anchor id; matches FieldAnchor's `fieldId` and the rendered DOM id. */
@@ -159,7 +153,7 @@ export const CONFIG_FIELD_SEARCH_INDEX: ConfigFieldSearchEntry[] = [
labelKey: L('sections.network.routing_strategy'),
hintKey: L('sections.network.routing_strategy_hint'),
yamlKeys: ['routing', 'strategy'],
keywords: ['round-robin', 'fill-first'],
keywords: ['round-robin', 'weighted-round-robin', 'wrr', 'fill-first'],
},
{
fieldId: 'disableImageGeneration',
@@ -15,6 +15,7 @@ import {
supportsAuthFileUsingApi,
supportsAuthFileWebsockets,
} from '@/features/authFiles/constants';
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
import styles from './AuthFileDetailsSheet.module.scss';
/** API 边界归一化补写的派生字段——INFO 视图里只展示后端原始形状,避免重复噪音。 */
@@ -134,7 +135,8 @@ export function AuthFileDetailsSheet(props: AuthFileDetailsSheetProps) {
editor?.saving === true ||
!dirty ||
!editor?.json ||
Boolean(editor?.headersTouched && editor.headersError)
Boolean(editor?.headersTouched && editor.headersError) ||
Boolean(editor?.weightError)
}
>
{t('common.save')}
@@ -163,12 +165,7 @@ export function AuthFileDetailsSheet(props: AuthFileDetailsSheetProps) {
: t('auth_files.prefix_proxy_invalid_content_label')}
</label>
{editor.json ? (
<textarea
className={styles.textarea}
rows={10}
readOnly
value={previewText}
/>
<textarea className={styles.textarea} rows={10} readOnly value={previewText} />
) : (
<pre className={styles.invalidPreview}>{invalidContentPreview}</pre>
)}
@@ -196,6 +193,18 @@ export function AuthFileDetailsSheet(props: AuthFileDetailsSheetProps) {
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('priority', e.target.value)}
/>
<Input
label={t('auth_files.weight_label')}
type="number"
step="1"
max={MAX_CREDENTIAL_WEIGHT}
value={editor.weight}
placeholder="1"
hint={t('auth_files.weight_hint')}
error={editor.weightError ?? undefined}
disabled={disableControls || editor.saving || !editor.json}
onChange={(e) => onChange('weight', e.target.value)}
/>
{supportsAuthFileWebsockets(editor.providerKey) && (
<div className="form-group">
<label>{t('auth_files.websockets_label')}</label>
@@ -13,6 +13,12 @@ import {
supportsAuthFileWebsockets,
supportsAuthFileUsingApi,
} from '@/features/authFiles/constants';
import {
parseCredentialWeightText,
readCredentialWeight,
validateCredentialWeightText,
type CredentialWeightError,
} from '@/utils/credentialWeight';
type AuthFileHeaders = Record<string, string>;
type AuthFileHeadersErrorKey =
@@ -20,13 +26,15 @@ type AuthFileHeadersErrorKey =
| 'auth_files.headers_invalid_object'
| 'auth_files.headers_invalid_value';
type AuthFileContentErrorKey =
| 'auth_files.prefix_proxy_invalid_json'
| 'auth_files.prefix_proxy_html_challenge';
'auth_files.prefix_proxy_invalid_json' | 'auth_files.prefix_proxy_html_challenge';
type AuthFileWeightErrorKey = 'auth_files.weight_invalid_integer' | 'auth_files.weight_invalid_max';
type AuthFileEditorErrorKey = AuthFileHeadersErrorKey | AuthFileWeightErrorKey;
export type PrefixProxyEditorField =
| 'prefix'
| 'proxyUrl'
| 'priority'
| 'weight'
| 'websockets'
| 'usingApi'
| 'note'
@@ -48,6 +56,8 @@ export type PrefixProxyEditorState = {
prefix: string;
proxyUrl: string;
priority: string;
weight: string;
weightError: string | null;
websockets: boolean;
websocketsTouched: boolean;
usingApi: boolean;
@@ -112,6 +122,9 @@ const parseHeadersText = (
return { value: parsed as AuthFileHeaders, errorKey: null };
};
const credentialWeightErrorKey = (error: CredentialWeightError): AuthFileWeightErrorKey =>
error === 'max' ? 'auth_files.weight_invalid_max' : 'auth_files.weight_invalid_integer';
const normalizeTextField = (value: unknown): string =>
typeof value === 'string' ? value.trim() : '';
@@ -219,9 +232,9 @@ const applyHeadersPatch = (
}
};
const buildAuthFileFieldsPatch = (
export const buildAuthFileFieldsPatch = (
editor: PrefixProxyEditorState,
resolveHeadersError: (key: AuthFileHeadersErrorKey) => string
resolveError: (key: AuthFileEditorErrorKey) => string
): AuthFileFieldsPatch => {
const original = editor.json ?? {};
const patch: AuthFileFieldsPatch = {};
@@ -255,6 +268,18 @@ const buildAuthFileFieldsPatch = (
}
}
const weightError = validateCredentialWeightText(editor.weight);
if (weightError) {
throw new Error(resolveError(credentialWeightErrorKey(weightError)));
}
const originalWeight = readCredentialWeight(original.weight);
const nextWeight = parseCredentialWeightText(editor.weight);
if (nextWeight === undefined) {
if (originalWeight !== undefined) patch.weight = null;
} else if (nextWeight !== originalWeight) {
patch.weight = nextWeight;
}
if (editor.noteTouched) {
const originalNote = normalizeTextField(original.note);
const nextNote = editor.note.trim();
@@ -282,7 +307,7 @@ const buildAuthFileFieldsPatch = (
if (editor.headersTouched) {
const { value: parsedHeaders, errorKey } = parseHeadersText(editor.headersText);
if (errorKey) {
throw new Error(resolveHeadersError(errorKey));
throw new Error(resolveError(errorKey));
}
const headersPatch = buildHeadersPatch(
normalizeHeaders(original.headers),
@@ -298,10 +323,10 @@ const buildAuthFileFieldsPatch = (
const buildPrefixProxyUpdatedText = (
editor: PrefixProxyEditorState | null,
resolveHeadersError: (key: AuthFileHeadersErrorKey) => string
resolveError: (key: AuthFileEditorErrorKey) => string
): string => {
if (!editor?.json) return editor?.rawText ?? '';
const patch = buildAuthFileFieldsPatch(editor, resolveHeadersError);
const patch = buildAuthFileFieldsPatch(editor, resolveError);
let next: Record<string, unknown> = { ...editor.json };
if (patch.prefix !== undefined) {
if (patch.prefix) {
@@ -326,6 +351,14 @@ const buildPrefixProxyUpdatedText = (
}
}
if (patch.weight !== undefined) {
if (patch.weight === null) {
delete next.weight;
} else {
next.weight = patch.weight;
}
}
if (patch.note !== undefined) {
if (patch.note) {
next.note = patch.note;
@@ -357,7 +390,8 @@ export function useAuthFilesPrefixProxyEditor(
const [prefixProxyEditor, setPrefixProxyEditor] = useState<PrefixProxyEditorState | null>(null);
const hasBlockingValidationError = Boolean(
prefixProxyEditor?.headersTouched && prefixProxyEditor.headersError
(prefixProxyEditor?.headersTouched && prefixProxyEditor.headersError) ||
prefixProxyEditor?.weightError
);
const prefixProxyUpdatedText =
prefixProxyEditor && !hasBlockingValidationError
@@ -399,6 +433,8 @@ export function useAuthFilesPrefixProxyEditor(
prefix: '',
proxyUrl: '',
priority: '',
weight: '',
weightError: null,
websockets: false,
websocketsTouched: false,
usingApi: false,
@@ -447,6 +483,7 @@ export function useAuthFilesPrefixProxyEditor(
const prefix = typeof json.prefix === 'string' ? json.prefix : '';
const proxyUrl = typeof json.proxy_url === 'string' ? json.proxy_url : '';
const priority = parsePriorityValue(json.priority);
const weight = readCredentialWeight(json.weight);
const websockets = supportsAuthFileWebsockets(providerKey)
? readAuthFileWebsockets(json)
: false;
@@ -474,6 +511,8 @@ export function useAuthFilesPrefixProxyEditor(
prefix,
proxyUrl,
priority: priority !== undefined ? String(priority) : '',
weight: weight !== undefined ? String(weight) : '',
weightError: null,
websockets,
websocketsTouched: false,
usingApi,
@@ -505,6 +544,15 @@ export function useAuthFilesPrefixProxyEditor(
if (field === 'prefix') return { ...prev, prefix: String(value) };
if (field === 'proxyUrl') return { ...prev, proxyUrl: String(value) };
if (field === 'priority') return { ...prev, priority: String(value) };
if (field === 'weight') {
const weight = String(value);
const error = validateCredentialWeightText(weight);
return {
...prev,
weight,
weightError: error ? t(credentialWeightErrorKey(error)) : null,
};
}
if (field === 'websockets') {
return { ...prev, websockets: Boolean(value), websocketsTouched: true };
}
+3
View File
@@ -72,6 +72,9 @@ export function DashboardPage() {
const raw = config?.routingStrategy?.trim() ?? '';
if (!raw) return DASH;
if (raw === 'round-robin') return t('basic_settings.routing_strategy_round_robin');
if (raw === 'weighted-round-robin') {
return t('basic_settings.routing_strategy_weighted_round_robin');
}
if (raw === 'fill-first') return t('basic_settings.routing_strategy_fill_first');
return raw;
}, [config?.routingStrategy, t]);
@@ -9,6 +9,7 @@ import {
IconX,
} from '@/components/ui/icons';
import { maskApiKey } from '@/utils/format';
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
import type { ApiKeyEntryInput } from '../../types';
import type { ConnectivityState, ConnectivityStatus } from './useConnectivityTest';
import { ConnectivityStatusIcon } from './ConnectivityStatusIcon';
@@ -237,6 +238,24 @@ export function ApiKeyEntriesEditor({
placeholder="http://127.0.0.1:7890"
/>
</div>
<div className={styles.field}>
<label className={styles.label}>{t('providersPage.form.weight')}</label>
<input
className={styles.input}
type="number"
step="1"
max={MAX_CREDENTIAL_WEIGHT}
value={entry.weight ?? ''}
onChange={(e) =>
onUpdate(idx, {
weight: e.target.value === '' ? undefined : Number(e.target.value),
})
}
disabled={mutating}
placeholder="1"
/>
<span className={styles.labelHint}>{t('providersPage.form.weightHint')}</span>
</div>
</div>
) : null}
</div>
@@ -29,6 +29,7 @@ import { ApiKeyEntriesEditor } from './ApiKeyEntriesEditor';
import { ModelEntriesEditor } from './ModelEntriesEditor';
import styles from './sharedForm.module.scss';
import { CLAUDE_API_BASE_URL } from '../../claudeApi';
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
interface BaseProviderFormProps {
brand: ProviderBrand;
@@ -45,6 +46,7 @@ const emptyModel = (): ModelEntryInput => ({ name: '', alias: '' });
const emptyApiKeyEntry = (): ApiKeyEntryInput => ({
apiKey: '',
proxyUrl: '',
weight: undefined,
});
const XAI_API_BASE_URL = 'https://api.x.ai/v1';
@@ -75,6 +77,7 @@ function buildInitialForm(
disabled: false,
disableCooling: false,
priority: undefined,
weight: undefined,
models: [emptyModel()],
headers: [emptyHeader()],
excludedModelsText: '',
@@ -127,6 +130,7 @@ function buildInitialForm(
apiKey: '',
existingApiKey: entry.apiKey,
proxyUrl: entry.proxyUrl ?? '',
weight: entry.weight,
authIndex: entry.authIndex,
}))
: [emptyApiKeyEntry()],
@@ -149,6 +153,7 @@ function buildInitialForm(
disabled,
disableCooling: cfg.disableCooling === true,
priority: cfg.priority,
weight: cfg.weight,
models: cfg.models?.length
? cfg.models.map((m) => ({
name: m.name,
@@ -375,6 +380,18 @@ export function BaseProviderForm({
if (descriptor.baseUrlRequired && !form.baseUrl.trim()) {
return t('providersPage.form.validation.baseUrlRequired');
}
const weights = [
...(brand === 'openaiCompatibility'
? (form.apiKeyEntries ?? []).map((entry) => entry.weight)
: []),
...(brand !== 'openaiCompatibility' ? [form.weight] : []),
];
if (weights.some((weight) => weight !== undefined && !Number.isSafeInteger(weight))) {
return t('providersPage.form.validation.weightInteger');
}
if (weights.some((weight) => weight !== undefined && weight > MAX_CREDENTIAL_WEIGHT)) {
return t('providersPage.form.validation.weightMax', { max: MAX_CREDENTIAL_WEIGHT });
}
return null;
};
@@ -578,6 +595,28 @@ export function BaseProviderForm({
</div>
) : null}
{brand !== 'openaiCompatibility' ? (
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-weight`}>
{t('providersPage.form.weight')}
</label>
<input
id={`${fid}-weight`}
type="number"
step="1"
max={MAX_CREDENTIAL_WEIGHT}
className={styles.input}
value={form.weight ?? ''}
placeholder="1"
onChange={(e) =>
updateField('weight', e.target.value === '' ? undefined : Number(e.target.value))
}
disabled={mutating}
/>
<span className={styles.labelHint}>{t('providersPage.form.weightHint')}</span>
</div>
) : null}
{descriptor.supportsTestModel ? (
<div className={styles.field}>
<label className={styles.label} htmlFor={`${fid}-testModel`}>
@@ -16,6 +16,7 @@ import {
} from '@/components/ui/icons';
import { hasDisableAllModelsRule } from '@/components/providers/utils';
import { maskApiKey } from '@/utils/format';
import { MAX_CREDENTIAL_WEIGHT } from '@/utils/credentialWeight';
import type { ModelInfo } from '@/utils/models';
import type { ApiKeyFunUsageSummary } from '../../sponsor';
import { isSponsorPartialMutationError } from '../../sponsorMutationRecovery';
@@ -90,6 +91,7 @@ const emptySponsorKeyEntry = (
disabled: false,
disableCooling: false,
priority: undefined,
weight: undefined,
models: [emptyModel()],
});
@@ -102,6 +104,7 @@ const emptySponsorForm = (definition: SponsorProviderDefinition): ProviderEntryF
disabled: false,
disableCooling: false,
priority: undefined,
weight: undefined,
models: [],
headers: [],
excludedModelsText: '',
@@ -138,8 +141,7 @@ const isHealthyUsageSummary = (summary: ApiKeyFunUsageSummary): boolean => {
const modelsFromConfig = (
models:
| Array<{ name?: string; alias?: string; priority?: number; testModel?: string }>
| undefined
Array<{ name?: string; alias?: string; priority?: number; testModel?: string }> | undefined
): ModelEntryInput[] =>
models?.length
? models.map((model) => ({
@@ -166,6 +168,7 @@ const sponsorEntryFromProviderKey = (
disabled: hasDisableAllModelsRule(config.excludedModels),
disableCooling: config.disableCooling === true,
priority: config.priority,
weight: config.weight,
models: modelsFromConfig(config.models),
});
@@ -183,6 +186,7 @@ const sponsorEntryFromOpenAI = (
disabled: config.disabled === true,
disableCooling: config.disableCooling === true,
priority: config.priority,
weight: firstEntry?.weight,
models: modelsFromConfig(config.models),
};
};
@@ -698,6 +702,28 @@ function SponsorKeyEntryCard({
</div>
</div>
<div className={styles.field}>
<label className={styles.label} htmlFor={`${formId}-group-${index}-weight`}>
{t('providersPage.form.weight')}
</label>
<input
id={`${formId}-group-${index}-weight`}
type="number"
step="1"
max={MAX_CREDENTIAL_WEIGHT}
className={styles.input}
value={entry.weight ?? ''}
placeholder="1"
onChange={(event) =>
updateEntry({
weight: event.target.value === '' ? undefined : Number(event.target.value),
})
}
disabled={mutating}
/>
<span className={styles.labelHint}>{t('providersPage.form.weightHint')}</span>
</div>
<label className={styles.checkboxRow}>
<input
type="checkbox"
@@ -824,6 +850,16 @@ export function SponsorProviderForm({
if (protocolSet.size !== entries.length) {
return t('providersPage.sponsor.validation.protocolDuplicate');
}
if (
entries.some((entry) => entry.weight !== undefined && !Number.isSafeInteger(entry.weight))
) {
return t('providersPage.form.validation.weightInteger');
}
if (
entries.some((entry) => entry.weight !== undefined && entry.weight > MAX_CREDENTIAL_WEIGHT)
) {
return t('providersPage.form.validation.weightMax', { max: MAX_CREDENTIAL_WEIGHT });
}
return null;
};
+3
View File
@@ -155,6 +155,7 @@ export interface SponsorKeyEntryInput {
disabled: boolean;
disableCooling?: boolean;
priority?: number;
weight?: number;
models: ModelEntryInput[];
}
@@ -162,6 +163,7 @@ export interface ApiKeyEntryInput {
apiKey: string;
existingApiKey?: string;
proxyUrl: string;
weight?: number;
authIndex?: string;
}
@@ -183,6 +185,7 @@ export interface ProviderEntryFormInput {
disabled: boolean;
disableCooling?: boolean;
priority?: number;
weight?: number;
/** 高级折叠区 */
models: ModelEntryInput[];
@@ -156,6 +156,7 @@ const buildProviderKeyConfig = (
const next: ProviderKeyConfig = {
apiKey: apiKeyChanged ? input.apiKey.trim() : (existing?.apiKey ?? ''),
priority: input.priority,
weight: input.weight,
prefix: input.prefix.trim() || undefined,
baseUrl: input.baseUrl.trim() || undefined,
proxyUrl: input.proxyUrl.trim() || undefined,
@@ -209,6 +210,7 @@ const buildOpenAIConfig = (
return {
apiKey: entry.apiKey.trim() || fallbackApiKey,
proxyUrl: entry.proxyUrl.trim() || undefined,
weight: entry.weight,
authIndex: entry.authIndex?.trim() || undefined,
};
})
@@ -248,6 +250,7 @@ const buildSponsorOpenAIConfig = (
...(firstExistingEntry ?? {}),
apiKey,
proxyUrl: entry.proxyUrl.trim() || undefined,
weight: entry.weight,
},
]
: [];
@@ -285,6 +288,7 @@ const buildSponsorProviderKeyConfig = (
proxyUrl: entry.proxyUrl.trim() || undefined,
prefix: entry.prefix.trim() || undefined,
priority: entry.priority,
weight: entry.weight,
disableCooling: entry.disableCooling === true,
excludedModels: excluded,
models: models.length ? models : undefined,
@@ -310,6 +314,7 @@ const buildSponsorGeminiConfig = (
proxyUrl: entry.proxyUrl.trim() || undefined,
prefix: entry.prefix.trim() || undefined,
priority: entry.priority,
weight: entry.weight,
disableCooling: entry.disableCooling === true,
excludedModels: excluded,
models: models.length ? models : undefined,
+13 -1
View File
@@ -10,6 +10,7 @@ import type {
PayloadParamEntry,
PayloadParamValueType,
PayloadRule,
RoutingStrategy,
VisualConfigValues,
VisualConfigValidationErrors,
PayloadParamValidationErrorCode,
@@ -432,6 +433,17 @@ function parsePayloadProtocol(raw: unknown): string | undefined {
return raw.trim() ? raw : undefined;
}
export function parseRoutingStrategy(raw: unknown): RoutingStrategy {
const normalized = String(raw ?? '')
.trim()
.toLowerCase();
if (['weighted-round-robin', 'weightedroundrobin', 'wrr'].includes(normalized)) {
return 'weighted-round-robin';
}
if (['fill-first', 'fillfirst', 'ff'].includes(normalized)) return 'fill-first';
return 'round-robin';
}
export function parseDisableImageGenerationMode(raw: unknown): DisableImageGenerationMode {
if (raw === true) return 'true';
if (typeof raw === 'string') {
@@ -1153,7 +1165,7 @@ export function useVisualConfig() {
quotaSwitchPreviewModel: Boolean(quotaExceeded?.['switch-preview-model'] ?? true),
quotaAntigravityCredits: Boolean(quotaExceeded?.['antigravity-credits'] ?? false),
routingStrategy: routing?.strategy === 'fill-first' ? 'fill-first' : 'round-robin',
routingStrategy: parseRoutingStrategy(routing?.strategy),
routingSessionAffinity: Boolean(
routing?.['session-affinity'] ?? routing?.sessionAffinity ?? routing?.['sessionAffinity']
),
+11 -1
View File
@@ -212,6 +212,7 @@
"request_log_warning": "Keep this off unless you need detailed troubleshooting.",
"ws_auth_enable": "Require auth for /ws/*",
"routing_strategy_round_robin": "round-robin (cycle)",
"routing_strategy_weighted_round_robin": "weighted-round-robin (weighted cycle)",
"routing_strategy_fill_first": "fill-first (prioritize)"
},
"auth_files": {
@@ -340,6 +341,10 @@
"priority_label": "Priority (priority)",
"priority_placeholder": "e.g. 10 or -1",
"priority_hint": "Integers only. Invalid values are ignored. Larger value means higher priority.",
"weight_label": "Scheduling weight (weight)",
"weight_hint": "Defaults to 1. Values at or below 0 exclude this credential from weighted scheduling; maximum 1,000,000.",
"weight_invalid_integer": "Weight must be an integer.",
"weight_invalid_max": "Weight cannot exceed 1,000,000.",
"websockets_label": "WebSockets (websockets)",
"websockets_hint": "Enable Responses API websocket transport for this credential.",
"using_api_label": "Use official API (using_api)",
@@ -990,6 +995,7 @@
"routing_strategy": "Routing Strategy",
"routing_strategy_hint": "Select credential selection strategy",
"strategy_round_robin": "Round Robin",
"strategy_weighted_round_robin": "Weighted Round Robin",
"strategy_fill_first": "Fill First",
"session_affinity_ttl": "Session Affinity TTL",
"force_model_prefix": "Force Model Prefix",
@@ -1519,6 +1525,8 @@
"proxyUrl": "Proxy URL",
"prefix": "Prefix",
"priority": "Priority",
"weight": "Scheduling weight",
"weightHint": "Default 1 · ≤ 0 is excluded · maximum 1,000,000",
"disabled": "Disable this entry",
"disabledHint": "Disabled entries won't be used by the gateway",
"websockets": "Enable WebSockets",
@@ -1560,7 +1568,9 @@
"validation": {
"nameRequired": "Name is required",
"apiKeyRequired": "At least one API key is required",
"baseUrlRequired": "Base URL is required"
"baseUrlRequired": "Base URL is required",
"weightInteger": "Scheduling weight must be an integer",
"weightMax": "Scheduling weight cannot exceed {{max}}"
}
},
"delete": {
+11 -1
View File
@@ -211,6 +211,7 @@
"request_log_warning": "Оставьте выключенным, если подробная диагностика не нужна.",
"ws_auth_enable": "Требовать аутентификацию для /ws/*",
"routing_strategy_round_robin": "round-robin (цикл)",
"routing_strategy_weighted_round_robin": "weighted-round-robin (взвешенный цикл)",
"routing_strategy_fill_first": "fill-first (приоритет)"
},
"auth_files": {
@@ -339,6 +340,10 @@
"priority_label": "Приоритет (priority)",
"priority_placeholder": "например: 10 или -1",
"priority_hint": "Только целые числа. Некорректные значения игнорируются. Чем больше число, тем выше приоритет.",
"weight_label": "Вес планирования (weight)",
"weight_hint": "По умолчанию 1. Значения ≤ 0 исключают эти учётные данные из взвешенного планирования; максимум 1 000 000.",
"weight_invalid_integer": "Вес должен быть целым числом.",
"weight_invalid_max": "Вес не может превышать 1 000 000.",
"websockets_label": "WebSockets (websockets)",
"websockets_hint": "Включает websocket-транспорт Responses API для этих учётных данных.",
"using_api_label": "Использовать официальный API (using_api)",
@@ -977,6 +982,7 @@
"routing_strategy": "Стратегия маршрутизации",
"routing_strategy_hint": "Выберите стратегию подбора учётных данных",
"strategy_round_robin": "По кругу",
"strategy_weighted_round_robin": "Взвешенный круговой алгоритм",
"strategy_fill_first": "Сначала заполнить",
"session_affinity_ttl": "TTL привязки сессии",
"force_model_prefix": "Принудительный префикс модели",
@@ -1497,6 +1503,8 @@
"proxyUrl": "Proxy URL",
"prefix": "Префикс",
"priority": "Приоритет",
"weight": "Вес планирования",
"weightHint": "По умолчанию 1 · ≤ 0 исключает запись · максимум 1 000 000",
"disabled": "Отключить эту запись",
"disabledHint": "Отключённые записи не используются шлюзом",
"websockets": "Включить WebSockets",
@@ -1538,7 +1546,9 @@
"validation": {
"nameRequired": "Название обязательно",
"apiKeyRequired": "Нужен хотя бы один API-ключ",
"baseUrlRequired": "Base URL обязателен"
"baseUrlRequired": "Base URL обязателен",
"weightInteger": "Вес планирования должен быть целым числом",
"weightMax": "Вес планирования не может превышать {{max}}"
}
},
"delete": {
+11 -1
View File
@@ -212,6 +212,7 @@
"request_log_warning": "仅在需要排查问题时开启,日常请保持关闭。",
"ws_auth_enable": "启用 /ws/* 鉴权",
"routing_strategy_round_robin": "round-robin (轮询)",
"routing_strategy_weighted_round_robin": "weighted-round-robin (加权轮询)",
"routing_strategy_fill_first": "fill-first (优先填充)"
},
"auth_files": {
@@ -340,6 +341,10 @@
"priority_label": "优先级(priority",
"priority_placeholder": "例如: 10 或 -1",
"priority_hint": "仅支持整数;非法值会被忽略。数值越大优先级越高。",
"weight_label": "调度权重(weight",
"weight_hint": "默认值为 1;小于或等于 0 时不参与加权调度;最大值为 1,000,000。",
"weight_invalid_integer": "权重必须是整数。",
"weight_invalid_max": "权重不能超过 1,000,000。",
"websockets_label": "WebSocketswebsockets",
"websockets_hint": "为该凭证开启 Responses API 的 websocket 传输。",
"using_api_label": "使用官方 APIusing_api",
@@ -990,6 +995,7 @@
"routing_strategy": "路由策略",
"routing_strategy_hint": "选择凭据选择策略",
"strategy_round_robin": "轮询 (Round Robin)",
"strategy_weighted_round_robin": "加权轮询 (Weighted Round Robin)",
"strategy_fill_first": "填充优先 (Fill First)",
"session_affinity_ttl": "会话粘性 TTL",
"force_model_prefix": "强制模型前缀",
@@ -1519,6 +1525,8 @@
"proxyUrl": "代理 URL",
"prefix": "前缀",
"priority": "优先级",
"weight": "调度权重",
"weightHint": "默认 1 · ≤ 0 时不参与 · 最大 1,000,000",
"disabled": "停用此条目",
"disabledHint": "停用后不会被网关使用",
"websockets": "启用 WebSockets",
@@ -1560,7 +1568,9 @@
"validation": {
"nameRequired": "名称必填",
"apiKeyRequired": "至少填写一个 API 密钥",
"baseUrlRequired": "服务地址必填"
"baseUrlRequired": "服务地址必填",
"weightInteger": "调度权重必须是整数",
"weightMax": "调度权重不能超过 {{max}}"
}
},
"delete": {
+11 -1
View File
@@ -212,6 +212,7 @@
"request_log_warning": "僅在需要排查問題時開啟,日常請保持關閉。",
"ws_auth_enable": "啟用 /ws/* 驗證",
"routing_strategy_round_robin": "round-robin(輪詢)",
"routing_strategy_weighted_round_robin": "weighted-round-robin(加權輪詢)",
"routing_strategy_fill_first": "fill-first(優先填充)"
},
"auth_files": {
@@ -340,6 +341,10 @@
"priority_label": "優先順序(priority",
"priority_placeholder": "例如: 10 或 -1",
"priority_hint": "僅支援整數;無效值會被忽略。數值越大優先順序越高。",
"weight_label": "調度權重(weight",
"weight_hint": "預設值為 1;小於或等於 0 時不參與加權調度;最大值為 1,000,000。",
"weight_invalid_integer": "權重必須是整數。",
"weight_invalid_max": "權重不能超過 1,000,000。",
"websockets_label": "WebSocketswebsockets",
"websockets_hint": "為該憑證開啟 Responses API 的 websocket 傳輸。",
"using_api_label": "使用官方 APIusing_api",
@@ -1016,6 +1021,7 @@
"routing_strategy": "路由策略",
"routing_strategy_hint": "選擇憑證選擇策略",
"strategy_round_robin": "輪詢(Round Robin",
"strategy_weighted_round_robin": "加權輪詢(Weighted Round Robin",
"strategy_fill_first": "填充優先(Fill First",
"session_affinity_ttl": "會話黏性 TTL",
"force_model_prefix": "強制模型前綴",
@@ -1545,6 +1551,8 @@
"proxyUrl": "代理 URL",
"prefix": "前綴",
"priority": "優先級",
"weight": "調度權重",
"weightHint": "預設 1 · ≤ 0 時不參與 · 最大 1,000,000",
"disabled": "停用此條目",
"disabledHint": "停用後不會被閘道使用",
"websockets": "啟用 WebSockets",
@@ -1586,7 +1594,9 @@
"validation": {
"nameRequired": "名稱必填",
"apiKeyRequired": "至少填寫一個 API 金鑰",
"baseUrlRequired": "服務位址必填"
"baseUrlRequired": "服務位址必填",
"weightInteger": "調度權重必須是整數",
"weightMax": "調度權重不能超過 {{max}}"
}
},
"delete": {
+1
View File
@@ -21,6 +21,7 @@ export type AuthFileFieldsPatch = {
proxy_url?: string;
headers?: Record<string, string>;
priority?: number;
weight?: number | null;
websockets?: boolean;
using_api?: boolean;
note?: string;
+7 -1
View File
@@ -21,6 +21,7 @@ const RESPONSE_ONLY_FIELDS = ['auth-index'] as const;
const PROVIDER_COMMON_KEY_FIELDS = [
'api-key',
'priority',
'weight',
'prefix',
'base-url',
'proxy-url',
@@ -41,6 +42,7 @@ const CLAUDE_KEY_FIELDS = [
const VERTEX_KEY_FIELDS = [
'api-key',
'priority',
'weight',
'prefix',
'base-url',
'proxy-url',
@@ -65,7 +67,7 @@ const OPENAI_PROVIDER_FIELDS = [
const MODEL_ALIAS_FIELDS = ['name', 'alias', 'priority', 'test-model'] as const;
const OPENAI_MODEL_ALIAS_FIELDS = [...MODEL_ALIAS_FIELDS, 'image', 'thinking'] as const;
const API_KEY_ENTRY_FIELDS = ['api-key', 'proxy-url'] as const;
const API_KEY_ENTRY_FIELDS = ['api-key', 'proxy-url', 'weight'] as const;
const CLOAK_FIELDS = ['mode', 'strict-mode', 'sensitive-words', 'cache-user-id'] as const;
@@ -315,12 +317,14 @@ const serializeModelAliases = (models?: ModelAlias[], includeOpenAIFields = fals
const serializeApiKeyEntry = (entry: ApiKeyEntry) => {
const payload: Record<string, unknown> = { 'api-key': entry.apiKey };
if (entry.proxyUrl) payload['proxy-url'] = entry.proxyUrl;
if (entry.weight !== undefined) payload.weight = entry.weight;
return payload;
};
const serializeProviderKey = (config: ProviderKeyConfig) => {
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.priority !== undefined) payload.priority = config.priority;
if (config.weight !== undefined) payload.weight = config.weight;
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.websockets !== undefined) payload.websockets = config.websockets;
@@ -370,6 +374,7 @@ const serializeVertexModelAliases = (models?: ModelAlias[]) =>
const serializeVertexKey = (config: ProviderKeyConfig) => {
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.priority !== undefined) payload.priority = config.priority;
if (config.weight !== undefined) payload.weight = config.weight;
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
@@ -386,6 +391,7 @@ const serializeVertexKey = (config: ProviderKeyConfig) => {
const serializeGeminiKey = (config: GeminiKeyConfig) => {
const payload: Record<string, unknown> = { 'api-key': config.apiKey };
if (config.priority !== undefined) payload.priority = config.priority;
if (config.weight !== undefined) payload.weight = config.weight;
if (config.prefix?.trim()) payload.prefix = config.prefix.trim();
if (config.baseUrl) payload['base-url'] = config.baseUrl;
if (config.proxyUrl) payload['proxy-url'] = config.proxyUrl;
+7
View File
@@ -9,6 +9,7 @@ import type {
import type { Config } from '@/types/config';
import { buildHeaderObject } from '@/utils/headers';
import { isRecord } from '@/utils/helpers';
import { readCredentialWeight } from '@/utils/credentialWeight';
const normalizeBoolean = (value: unknown): boolean | undefined =>
typeof value === 'boolean' ? value : undefined;
@@ -109,12 +110,14 @@ const normalizeApiKeyEntry = (entry: unknown): ApiKeyEntry | null => {
if (!trimmed) return null;
const proxyUrl = record?.['proxy-url'];
const weight = readCredentialWeight(record?.weight);
const authIndex = normalizeAuthIndex(record?.['auth-index']);
const result: ApiKeyEntry = {
apiKey: trimmed,
proxyUrl: proxyUrl ? String(proxyUrl) : undefined,
};
if (weight !== undefined) result.weight = weight;
if (authIndex) result.authIndex = authIndex;
return result;
};
@@ -127,6 +130,8 @@ const normalizeProviderKeyConfig = (item: unknown): ProviderKeyConfig | null =>
if (!trimmed) return null;
const config: ProviderKeyConfig = { apiKey: trimmed };
const weight = readCredentialWeight(record?.weight);
if (weight !== undefined) config.weight = weight;
const priority = record?.priority;
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
const parsed = Number(priority);
@@ -195,6 +200,8 @@ const normalizeGeminiKeyConfig = (item: unknown): GeminiKeyConfig | null => {
if (!trimmed) return null;
const config: GeminiKeyConfig = { apiKey: trimmed };
const weight = readCredentialWeight(record?.weight);
if (weight !== undefined) config.weight = weight;
const priority = record?.priority;
if (priority !== undefined && priority !== null && String(priority).trim() !== '') {
const parsed = Number(priority);
+1
View File
@@ -33,6 +33,7 @@ export interface AuthFileItem {
lastRefresh?: string | number;
modified?: number;
priority?: number;
weight?: number;
note?: string;
success?: unknown;
failed?: unknown;
+3
View File
@@ -15,6 +15,7 @@ export interface ModelAlias {
export interface ApiKeyEntry {
apiKey: string;
proxyUrl?: string;
weight?: number;
authIndex?: string;
}
@@ -28,6 +29,7 @@ export interface CloakConfig {
export interface GeminiKeyConfig {
apiKey: string;
priority?: number;
weight?: number;
prefix?: string;
baseUrl?: string;
proxyUrl?: string;
@@ -41,6 +43,7 @@ export interface GeminiKeyConfig {
export interface ProviderKeyConfig {
apiKey: string;
priority?: number;
weight?: number;
prefix?: string;
baseUrl?: string;
websockets?: boolean;
+4 -7
View File
@@ -1,11 +1,10 @@
export type PayloadParamValueType = 'string' | 'number' | 'boolean' | 'json';
export type DisableImageGenerationMode = 'false' | 'true' | 'chat' | 'passthrough';
export type RoutingStrategy = 'round-robin' | 'weighted-round-robin' | 'fill-first';
export type PluginStoreAuthType = 'none' | 'bearer' | 'basic' | 'header' | 'github-token';
export type PluginStoreAuthApplyTo = 'registry' | 'metadata' | 'artifact';
export type PayloadParamValidationErrorCode =
| 'payload_invalid_number'
| 'payload_invalid_boolean'
| 'payload_invalid_json';
'payload_invalid_number' | 'payload_invalid_boolean' | 'payload_invalid_json';
export type VisualConfigFieldPath =
| 'port'
@@ -21,9 +20,7 @@ export type VisualConfigFieldPath =
| 'streaming.nonstreamKeepaliveInterval';
export type VisualConfigValidationErrorCode =
| 'port_range'
| 'non_negative_integer'
| 'integer_range_1_3600';
'port_range' | 'non_negative_integer' | 'integer_range_1_3600';
export type VisualConfigValidationErrors = Partial<
Record<VisualConfigFieldPath, VisualConfigValidationErrorCode>
@@ -121,7 +118,7 @@ export type VisualConfigValues = {
quotaSwitchProject: boolean;
quotaSwitchPreviewModel: boolean;
quotaAntigravityCredits: boolean;
routingStrategy: 'round-robin' | 'fill-first';
routingStrategy: RoutingStrategy;
routingSessionAffinity: boolean;
routingSessionAffinityTTL: string;
wsAuth: boolean;
+37
View File
@@ -0,0 +1,37 @@
export const DEFAULT_CREDENTIAL_WEIGHT = 1;
export const MAX_CREDENTIAL_WEIGHT = 1_000_000;
export type CredentialWeightError = 'integer' | 'max';
export const readCredentialWeight = (value: unknown): number | undefined => {
const normalized =
typeof value === 'number'
? value
: typeof value === 'string' && /^[+-]?\d+$/.test(value.trim())
? Number(value.trim())
: undefined;
if (
normalized === undefined ||
!Number.isSafeInteger(normalized) ||
normalized > MAX_CREDENTIAL_WEIGHT
) {
return undefined;
}
return normalized;
};
export const validateCredentialWeightText = (value: string): CredentialWeightError | null => {
const trimmed = value.trim();
if (!trimmed) return null;
if (!/^[+-]?\d+$/.test(trimmed)) return 'integer';
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed)) return 'integer';
return parsed > MAX_CREDENTIAL_WEIGHT ? 'max' : null;
};
export const parseCredentialWeightText = (value: string): number | undefined => {
if (validateCredentialWeightText(value)) return undefined;
const trimmed = value.trim();
return trimmed ? Number(trimmed) : undefined;
};
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test';
import {
buildAuthFileFieldsPatch,
type PrefixProxyEditorState,
} from '../src/features/authFiles/hooks/useAuthFilesPrefixProxyEditor';
const makeEditor = (json: Record<string, unknown>, weight: string): PrefixProxyEditorState => ({
fileName: 'credential.json',
fileInfoText: '',
loading: false,
saving: false,
error: null,
originalText: JSON.stringify(json),
rawText: JSON.stringify(json),
invalidContentPreview: '',
json,
providerKey: 'codex',
prefix: '',
proxyUrl: '',
priority: '',
weight,
weightError: null,
websockets: false,
websocketsTouched: false,
usingApi: false,
usingApiTouched: false,
note: '',
noteTouched: false,
headersText: '',
headersTouched: false,
headersError: null,
});
const resolveError = (key: string) => key;
describe('auth-file credential weight patch', () => {
test('writes numeric weight and uses null to restore the default', () => {
expect(buildAuthFileFieldsPatch(makeEditor({}, '0'), resolveError)).toEqual({ weight: 0 });
expect(buildAuthFileFieldsPatch(makeEditor({ weight: 5 }, ''), resolveError)).toEqual({
weight: null,
});
});
test('recognizes a numeric string in an existing auth file', () => {
expect(buildAuthFileFieldsPatch(makeEditor({ weight: '7' }, '7'), resolveError)).toEqual({});
expect(buildAuthFileFieldsPatch(makeEditor({ weight: '7' }, ''), resolveError)).toEqual({
weight: null,
});
});
test('rejects invalid and oversized values before PATCH', () => {
expect(() => buildAuthFileFieldsPatch(makeEditor({}, '1.5'), resolveError)).toThrow(
'auth_files.weight_invalid_integer'
);
expect(() => buildAuthFileFieldsPatch(makeEditor({}, '1000001'), resolveError)).toThrow(
'auth_files.weight_invalid_max'
);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, test } from 'bun:test';
import {
MAX_CREDENTIAL_WEIGHT,
parseCredentialWeightText,
readCredentialWeight,
validateCredentialWeightText,
} from '../src/utils/credentialWeight';
describe('credential weight validation', () => {
test('accepts the default range and non-positive scheduling exclusions', () => {
expect(parseCredentialWeightText('')).toBeUndefined();
expect(parseCredentialWeightText('1')).toBe(1);
expect(parseCredentialWeightText('0')).toBe(0);
expect(parseCredentialWeightText('-2')).toBe(-2);
expect(parseCredentialWeightText(String(MAX_CREDENTIAL_WEIGHT))).toBe(MAX_CREDENTIAL_WEIGHT);
});
test('rejects non-integers and values above the backend maximum', () => {
expect(validateCredentialWeightText('1.5')).toBe('integer');
expect(validateCredentialWeightText('1e3')).toBe('integer');
expect(validateCredentialWeightText(String(MAX_CREDENTIAL_WEIGHT + 1))).toBe('max');
expect(parseCredentialWeightText('1.5')).toBeUndefined();
});
test('reads only valid numeric response fields', () => {
expect(readCredentialWeight(7)).toBe(7);
expect(readCredentialWeight(0)).toBe(0);
expect(readCredentialWeight(' 7 ')).toBe(7);
expect(readCredentialWeight('7.5')).toBeUndefined();
expect(readCredentialWeight(MAX_CREDENTIAL_WEIGHT + 1)).toBeUndefined();
});
});
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { apiClient } from '../src/services/api/client';
import { providersApi } from '../src/services/api/providers';
import {
normalizeGeminiKeyConfig,
normalizeOpenAIProvider,
normalizeProviderKeyConfig,
} from '../src/services/api/transformers';
const originalGet = apiClient.get;
const originalPut = apiClient.put;
afterEach(() => {
apiClient.get = originalGet;
apiClient.put = originalPut;
});
describe('provider credential weight normalization', () => {
test('reads weight for direct API key credentials', () => {
expect(normalizeGeminiKeyConfig({ 'api-key': 'gemini-key', weight: 5 })?.weight).toBe(5);
expect(normalizeProviderKeyConfig({ 'api-key': 'provider-key', weight: 0 })?.weight).toBe(0);
});
test('reads per-key weight for OpenAI-compatible providers', () => {
const provider = normalizeOpenAIProvider({
name: 'example',
'base-url': 'https://example.com/v1',
'api-key-entries': [{ 'api-key': 'key-a', weight: 3 }, { 'api-key': 'key-b' }],
});
expect(provider?.apiKeyEntries[0]?.weight).toBe(3);
expect(provider?.apiKeyEntries[1]?.weight).toBeUndefined();
});
test('removes a cleared Vertex weight while preserving unknown fields', async () => {
let written: unknown;
apiClient.get = (async () => ({
'vertex-api-key': [
{
'api-key': 'vertex-key',
'base-url': 'https://vertex.example',
weight: 9,
'future-field': 'keep',
},
],
})) as typeof apiClient.get;
apiClient.put = (async (_url: string, data?: unknown) => {
written = data;
return undefined;
}) as typeof apiClient.put;
await providersApi.updateVertexConfig('vertex-key', 'https://vertex.example', {
apiKey: 'vertex-key',
baseUrl: 'https://vertex.example',
weight: undefined,
});
expect(written).toEqual([
{
'api-key': 'vertex-key',
'base-url': 'https://vertex.example',
'future-field': 'keep',
},
]);
});
test('writes and clears nested OpenAI-compatible key weights', async () => {
let written: unknown;
apiClient.get = (async () => ({
'openai-compatibility': [
{
name: 'example',
'base-url': 'https://example.com/v1',
'api-key-entries': [
{ 'api-key': 'key-a', weight: 8, custom: 'keep-a' },
{ 'api-key': 'key-b', custom: 'keep-b' },
],
},
],
})) as typeof apiClient.get;
apiClient.put = (async (_url: string, data?: unknown) => {
written = data;
return undefined;
}) as typeof apiClient.put;
await providersApi.updateOpenAIProvider('example', 0, {
name: 'example',
baseUrl: 'https://example.com/v1',
apiKeyEntries: [
{ apiKey: 'key-a', weight: undefined },
{ apiKey: 'key-b', weight: 4 },
],
});
expect(written).toEqual([
{
name: 'example',
'base-url': 'https://example.com/v1',
'api-key-entries': [
{ 'api-key': 'key-a', custom: 'keep-a' },
{ 'api-key': 'key-b', custom: 'keep-b', weight: 4 },
],
},
]);
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test';
import { createElement, useState } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { parse as parseYaml } from 'yaml';
import { parseRoutingStrategy, useVisualConfig } from '../src/hooks/useVisualConfig';
describe('visual config weighted routing strategy', () => {
test('recognizes the weighted-round-robin backend value', () => {
expect(parseRoutingStrategy('weighted-round-robin')).toBe('weighted-round-robin');
expect(parseRoutingStrategy('weightedroundrobin')).toBe('weighted-round-robin');
expect(parseRoutingStrategy('wrr')).toBe('weighted-round-robin');
expect(parseRoutingStrategy('fill-first')).toBe('fill-first');
expect(parseRoutingStrategy('fillfirst')).toBe('fill-first');
expect(parseRoutingStrategy('ff')).toBe('fill-first');
expect(parseRoutingStrategy(undefined)).toBe('round-robin');
});
test('writes weighted-round-robin without coercing it to round-robin', () => {
function Harness() {
const visualConfig = useVisualConfig();
const [phase, setPhase] = useState(0);
if (phase === 0) {
visualConfig.setVisualValues({ routingStrategy: 'weighted-round-robin' });
setPhase(1);
} else {
return createElement(
'pre',
null,
visualConfig.applyVisualChangesToYaml('routing:\n strategy: round-robin\n')
);
}
return null;
}
const markup = renderToStaticMarkup(createElement(Harness));
const result = markup.slice('<pre>'.length, -'</pre>'.length);
expect(parseYaml(result)).toEqual({ routing: { strategy: 'weighted-round-robin' } });
});
});