mirror of
https://github.com/router-for-me/Cli-Proxy-API-Management-Center.git
synced 2026-09-01 15:34:05 +08:00
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:
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']
|
||||
),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "WebSockets(websockets)",
|
||||
"websockets_hint": "为该凭证开启 Responses API 的 websocket 传输。",
|
||||
"using_api_label": "使用官方 API(using_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": {
|
||||
|
||||
@@ -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": "WebSockets(websockets)",
|
||||
"websockets_hint": "為該憑證開啟 Responses API 的 websocket 傳輸。",
|
||||
"using_api_label": "使用官方 API(using_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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface AuthFileItem {
|
||||
lastRefresh?: string | number;
|
||||
modified?: number;
|
||||
priority?: number;
|
||||
weight?: number;
|
||||
note?: string;
|
||||
success?: unknown;
|
||||
failed?: unknown;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user